Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1f61b0d2e | |||
| 155dc1c578 | |||
| 5e33a91154 | |||
| ee32942f71 | |||
| 4a4d861592 | |||
| 42c66552c8 | |||
| dc6ec2ba78 | |||
| 7fb7061ca7 | |||
| e85ea1ff28 | |||
| fe91d4b56a | |||
| 9218ac237b | |||
| 6560369b98 | |||
| 30b080efbd | |||
| f01b7075fb | |||
| ff72c007c0 | |||
| 6bdb157af3 | |||
| 04afa61d55 | |||
| 7549972048 | |||
| 720c5d6080 | |||
| e7d38d340f | |||
| d750f1ceed | |||
| 7e471bfea4 | |||
| aa254a3772 | |||
| ff0994e1c7 | |||
| 3cd1dece52 | |||
| 9ac2b70df3 | |||
| 2d6c05e962 | |||
| 8384bbfc0a | |||
| 31ee7a2e9a | |||
| f84509c824 | |||
| f0d86cbaec | |||
| 3132150fb8 | |||
| 24f7bac3ea | |||
| 0930201e5d | |||
| df218ee6c8 | |||
| 27c39415c2 | |||
| f155b98a92 | |||
| f0043b4be5 | |||
| a6db53873a | |||
| 397965f6e9 | |||
| 76de357cbf | |||
| 40724ad877 | |||
| be6ecbe0b1 | |||
| 72ae105166 | |||
| 06baaa1522 | |||
| fa17c70d85 | |||
| c606972f0a | |||
| d4dde58e13 | |||
| 71917eb0ec | |||
| 1b129636ed | |||
| c2d438fba3 | |||
| ee4553130b | |||
| bf6ccea1e2 | |||
| e0eac6ab7e | |||
| 094eb5c17e | |||
| 3d3182095d | |||
| 706f8e1482 | |||
| ea7e07034a | |||
| 1fd92d6a5d | |||
| 810ebad9ba | |||
| c6554c8f80 | |||
| 19a8d9e9b3 | |||
| a490287b4a | |||
| 90b0c91b2f | |||
| 1493132974 | |||
| 6a4468193b | |||
| 4dd99b5240 | |||
| 7961ba87ed | |||
| 6952bea6e1 | |||
| 53600175b9 | |||
| e5956d4039 | |||
| 1f9850c04d | |||
| df43cb7a90 | |||
| bea664af0f | |||
| b265c640ca | |||
| a3d6f32202 | |||
| 16e65d39be | |||
| 186bdb486f | |||
| ea40cc7692 | |||
| 16ca373c55 | |||
| 0d60b34c17 | |||
| 60a89998fe |
@@ -46,6 +46,10 @@ local
|
||||
.aider*
|
||||
.cursorrules
|
||||
.cursor/*
|
||||
.claude/*
|
||||
.gemini/*
|
||||
.trae/*
|
||||
.claude-code-router/*
|
||||
|
||||
# vitest
|
||||
coverage
|
||||
|
||||
Vendored
+2
-2
@@ -10,7 +10,7 @@
|
||||
"windows": {
|
||||
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
|
||||
},
|
||||
"runtimeArgs": ["--sourcemap"],
|
||||
"runtimeArgs": ["--inspect", "--sourcemap"],
|
||||
"env": {
|
||||
"REMOTE_DEBUGGING_PORT": "9222"
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
"request": "attach",
|
||||
"type": "chrome",
|
||||
"webRoot": "${workspaceFolder}/src/renderer",
|
||||
"timeout": 60000,
|
||||
"timeout": 3000000,
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# Security Policy
|
||||
|
||||
## 📢 Reporting a Vulnerability
|
||||
|
||||
At Cherry Studio, we take security seriously and appreciate your efforts to responsibly disclose vulnerabilities. If you discover a security issue, please report it as soon as possible.
|
||||
|
||||
**Please do not create public issues for security-related reports.**
|
||||
|
||||
- To report a security issue, please use the GitHub Security Advisories tab to "[Open a draft security advisory](https://github.com/CherryHQ/cherry-studio/security/advisories/new)".
|
||||
- Include a detailed description of the issue, steps to reproduce, potential impact, and any possible mitigations.
|
||||
- If applicable, please also attach proof-of-concept code or screenshots.
|
||||
|
||||
We will acknowledge your report within **72 hours** and provide a status update as we investigate.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Supported Versions
|
||||
|
||||
We aim to support the latest released version and one previous minor release.
|
||||
|
||||
| Version | Supported |
|
||||
|-----------------|--------------------|
|
||||
| Latest (`main`) | ✅ Supported |
|
||||
| Previous minor | ✅ Supported |
|
||||
| Older versions | ❌ Not supported |
|
||||
|
||||
If you are using an unsupported version, we strongly recommend updating to the latest release to receive security fixes.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Security Measures
|
||||
|
||||
Cherry Studio integrates several security best practices, including:
|
||||
|
||||
- Strict dependency updates and regular vulnerability scanning.
|
||||
- TypeScript strict mode and linting to reduce potential injection or runtime issues.
|
||||
- Enforced code formatting and pre-commit hooks.
|
||||
- Internal security reviews before releases.
|
||||
- Dedicated MCP (Model Context Protocol) safeguards for model interactions and data privacy.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Disclosure Policy
|
||||
|
||||
- We follow a **coordinated disclosure** approach.
|
||||
- We will not publicly disclose vulnerabilities until a fix has been developed and released.
|
||||
- Credit will be given to researchers who responsibly disclose vulnerabilities, if requested.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Acknowledgements
|
||||
|
||||
We greatly appreciate contributions from the security community and strive to recognize all researchers who help keep Cherry Studio safe.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Questions?
|
||||
|
||||
For any security-related questions not involving vulnerabilities, please reach out to:
|
||||
**security@cherry-ai.com**
|
||||
|
||||
---
|
||||
|
||||
Thank you for helping keep Cherry Studio and its users secure!
|
||||
@@ -0,0 +1,222 @@
|
||||
# Cherry Studio 记忆功能指南
|
||||
|
||||
## 功能介绍
|
||||
|
||||
Cherry Studio 的记忆功能是一个强大的工具,能够帮助 AI 助手记住对话中的重要信息、用户偏好和上下文。通过记忆功能,您的 AI 助手可以:
|
||||
|
||||
- 📝 **记住重要信息**:自动从对话中提取并存储关键事实和信息
|
||||
- 🧠 **个性化响应**:基于存储的记忆提供更加个性化和相关的回答
|
||||
- 🔍 **智能检索**:在需要时自动搜索相关记忆,增强对话的连贯性
|
||||
- 👥 **多用户支持**:为不同用户维护独立的记忆上下文
|
||||
|
||||
记忆功能特别适用于需要长期保持上下文的场景,例如个人助手、客户服务、教育辅导等。
|
||||
|
||||
## 如何启用记忆功能
|
||||
|
||||
### 1. 全局配置(首次设置)
|
||||
|
||||
在使用记忆功能之前,您需要先进行全局配置:
|
||||
|
||||
1. 点击侧边栏的 **记忆** 图标(记忆棒图标)进入记忆管理页面
|
||||
2. 点击右上角的 **更多** 按钮(三个点),选择 **设置**
|
||||
3. 在设置弹窗中配置以下必要项:
|
||||
- **LLM 模型**:选择用于处理记忆的语言模型(推荐使用 GPT-4 或 Claude 等高级模型)
|
||||
- **嵌入模型**:选择用于生成向量嵌入的模型(如 text-embedding-3-small)
|
||||
- **嵌入维度**:输入嵌入模型的维度(通常为 1536)
|
||||
4. 点击 **确定** 保存配置
|
||||
|
||||
> ⚠️ **注意**:嵌入模型和维度一旦设置后无法更改,请谨慎选择。
|
||||
|
||||
### 2. 为助手启用记忆
|
||||
|
||||
完成全局配置后,您可以为特定助手启用记忆功能:
|
||||
|
||||
1. 进入 **助手** 页面
|
||||
2. 选择要启用记忆的助手,点击 **编辑**
|
||||
3. 在助手设置中找到 **记忆** 部分
|
||||
4. 打开记忆功能开关
|
||||
5. 保存助手设置
|
||||
|
||||
启用后,该助手将在对话过程中自动提取和使用记忆。
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 查看记忆
|
||||
|
||||
1. 点击侧边栏的 **记忆** 图标进入记忆管理页面
|
||||
2. 您可以看到所有存储的记忆卡片,包括:
|
||||
- 记忆内容
|
||||
- 创建时间
|
||||
- 所属用户
|
||||
|
||||
### 添加记忆
|
||||
|
||||
手动添加记忆有两种方式:
|
||||
|
||||
**方式一:在记忆管理页面添加**
|
||||
|
||||
1. 点击右上角的 **添加记忆** 按钮
|
||||
2. 在弹窗中输入记忆内容
|
||||
3. 点击 **添加** 保存
|
||||
|
||||
**方式二:在对话中自动提取**
|
||||
|
||||
- 当助手启用记忆功能后,系统会自动从对话中提取重要信息并存储为记忆
|
||||
|
||||
### 编辑记忆
|
||||
|
||||
1. 在记忆卡片上点击 **更多** 按钮(三个点)
|
||||
2. 选择 **编辑**
|
||||
3. 修改记忆内容
|
||||
4. 点击 **保存**
|
||||
|
||||
### 删除记忆
|
||||
|
||||
1. 在记忆卡片上点击 **更多** 按钮
|
||||
2. 选择 **删除**
|
||||
3. 确认删除操作
|
||||
|
||||
## 记忆搜索
|
||||
|
||||
记忆管理页面提供了强大的搜索功能:
|
||||
|
||||
1. 在页面顶部的搜索框中输入关键词
|
||||
2. 系统会实时过滤显示匹配的记忆
|
||||
3. 搜索支持模糊匹配,可以搜索记忆内容的任何部分
|
||||
|
||||
## 用户管理
|
||||
|
||||
记忆功能支持多用户,您可以为不同的用户维护独立的记忆库:
|
||||
|
||||
### 切换用户
|
||||
|
||||
1. 在记忆管理页面,点击右上角的用户选择器
|
||||
2. 选择要切换到的用户
|
||||
3. 页面会自动加载该用户的记忆
|
||||
|
||||
### 添加新用户
|
||||
|
||||
1. 点击用户选择器
|
||||
2. 选择 **添加新用户**
|
||||
3. 输入用户 ID(支持字母、数字、下划线和连字符)
|
||||
4. 点击 **添加**
|
||||
|
||||
### 删除用户
|
||||
|
||||
1. 切换到要删除的用户
|
||||
2. 点击右上角的 **更多** 按钮
|
||||
3. 选择 **删除用户**
|
||||
4. 确认删除(注意:这将删除该用户的所有记忆)
|
||||
|
||||
> 💡 **提示**:默认用户(default-user)无法删除。
|
||||
|
||||
## 设置说明
|
||||
|
||||
### LLM 模型
|
||||
|
||||
- 用于处理记忆提取和更新的语言模型
|
||||
- 建议选择能力较强的模型以获得更好的记忆提取效果
|
||||
- 可随时更改
|
||||
|
||||
### 嵌入模型
|
||||
|
||||
- 用于将文本转换为向量,支持语义搜索
|
||||
- 一旦设置后无法更改(为了保证现有记忆的兼容性)
|
||||
- 推荐使用 OpenAI 的 text-embedding 系列模型
|
||||
|
||||
### 嵌入维度
|
||||
|
||||
- 嵌入向量的维度,需要与选择的嵌入模型匹配
|
||||
- 常见维度:
|
||||
- text-embedding-3-small: 1536
|
||||
- text-embedding-3-large: 3072
|
||||
- text-embedding-ada-002: 1536
|
||||
|
||||
### 自定义提示词(可选)
|
||||
|
||||
- **事实提取提示词**:自定义如何从对话中提取信息
|
||||
- **记忆更新提示词**:自定义如何更新现有记忆
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 合理组织记忆
|
||||
|
||||
- 保持记忆简洁明了,每条记忆专注于一个具体信息
|
||||
- 使用清晰的语言描述事实,避免模糊表达
|
||||
- 定期审查和清理过时或不准确的记忆
|
||||
|
||||
### 2. 多用户场景
|
||||
|
||||
- 为不同的使用场景创建独立用户(如工作、个人、学习等)
|
||||
- 使用有意义的用户 ID,便于识别和管理
|
||||
- 定期备份重要用户的记忆数据
|
||||
|
||||
### 3. 模型选择建议
|
||||
|
||||
- **LLM 模型**:GPT-4、Claude 3 等高级模型能更准确地提取和理解信息
|
||||
- **嵌入模型**:选择与您的主要使用语言匹配的模型
|
||||
|
||||
### 4. 性能优化
|
||||
|
||||
- 避免存储过多冗余记忆,这可能影响搜索性能
|
||||
- 定期整理和合并相似的记忆
|
||||
- 对于大量记忆的场景,考虑按主题或时间进行分类管理
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 为什么我无法启用记忆功能?
|
||||
|
||||
A: 请确保您已经完成全局配置,包括选择 LLM 模型和嵌入模型。
|
||||
|
||||
### Q: 记忆会自动同步到所有助手吗?
|
||||
|
||||
A: 不会。每个助手的记忆功能需要单独启用,且记忆是按用户隔离的。
|
||||
|
||||
### Q: 如何导出我的记忆数据?
|
||||
|
||||
A: 目前系统暂不支持直接导出功能,但所有记忆都存储在本地数据库中。
|
||||
|
||||
### Q: 删除的记忆可以恢复吗?
|
||||
|
||||
A: 删除操作是永久的,无法恢复。建议在删除前仔细确认。
|
||||
|
||||
### Q: 记忆功能会影响对话速度吗?
|
||||
|
||||
A: 记忆功能在后台异步处理,不会明显影响对话响应速度。但过多的记忆可能会略微增加搜索时间。
|
||||
|
||||
### Q: 如何清空所有记忆?
|
||||
|
||||
A: 您可以删除当前用户并重新创建,或者手动删除所有记忆条目。
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 隐私保护
|
||||
|
||||
- 所有记忆数据都存储在您的本地设备上,不会上传到云端
|
||||
- 请勿在记忆中存储敏感信息(如密码、私钥等)
|
||||
- 定期审查记忆内容,确保没有意外存储的隐私信息
|
||||
|
||||
### 数据安全
|
||||
|
||||
- 记忆数据存储在本地数据库中
|
||||
- 建议定期备份重要数据
|
||||
- 更换设备时请注意迁移记忆数据
|
||||
|
||||
### 使用限制
|
||||
|
||||
- 单条记忆的长度建议不超过 500 字
|
||||
- 每个用户的记忆数量建议控制在 1000 条以内
|
||||
- 过多的记忆可能影响系统性能
|
||||
|
||||
## 技术细节
|
||||
|
||||
记忆功能使用了先进的 RAG(检索增强生成)技术:
|
||||
|
||||
1. **信息提取**:使用 LLM 从对话中智能提取关键信息
|
||||
2. **向量化存储**:通过嵌入模型将文本转换为向量,支持语义搜索
|
||||
3. **智能检索**:在对话时自动搜索相关记忆,提供给 AI 作为上下文
|
||||
4. **持续学习**:随着对话进行,不断更新和完善记忆库
|
||||
|
||||
---
|
||||
|
||||
💡 **提示**:记忆功能是 Cherry Studio 的高级特性,合理使用可以大大提升 AI 助手的智能程度和用户体验。如有更多问题,欢迎查阅文档或联系支持团队。
|
||||
@@ -117,8 +117,8 @@ afterSign: scripts/notarize.js
|
||||
artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
• [新增] MCP 工具调用自动审批流程
|
||||
• [优化] 输入框快捷弹窗多选交互支持
|
||||
• [新增] 网页内容生成实时预览功能
|
||||
• [支持] Grok-4 大语言模型接入
|
||||
• [修复] Anthropic 模型输出截断缺陷
|
||||
新增全局记忆功能
|
||||
MCP 支持 DXT 格式导入
|
||||
全局快捷键支持 Linux 系统
|
||||
模型思考过程增加动画效果
|
||||
错误修复和性能优化
|
||||
|
||||
+12
-16
@@ -8,6 +8,9 @@ const visualizerPlugin = (type: 'renderer' | 'main') => {
|
||||
return process.env[`VISUALIZER_${type.toUpperCase()}`] ? [visualizer({ open: true })] : []
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development'
|
||||
const isProd = process.env.NODE_ENV === 'production'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin(), ...visualizerPlugin('main')],
|
||||
@@ -22,16 +25,15 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
external: ['@libsql/client', 'bufferutil', 'utf-8-validate', '@cherrystudio/mac-system-ocr'],
|
||||
output: {
|
||||
// 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
manualChunks: undefined,
|
||||
// 内联所有动态导入,这是关键配置
|
||||
inlineDynamicImports: true
|
||||
manualChunks: undefined, // 彻底禁用代码分割 - 返回 null 强制单文件打包
|
||||
inlineDynamicImports: true // 内联所有动态导入,这是关键配置
|
||||
}
|
||||
},
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
sourcemap: isDev
|
||||
},
|
||||
esbuild: isProd ? { legalComments: 'none' } : {},
|
||||
optimizeDeps: {
|
||||
noDiscovery: process.env.NODE_ENV === 'development'
|
||||
noDiscovery: isDev
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
@@ -42,7 +44,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourcemap: process.env.NODE_ENV === 'development'
|
||||
sourcemap: isDev
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
@@ -60,14 +62,7 @@ export default defineConfig({
|
||||
]
|
||||
]
|
||||
}),
|
||||
// 只在开发环境下启用 CodeInspectorPlugin
|
||||
...(process.env.NODE_ENV === 'development'
|
||||
? [
|
||||
CodeInspectorPlugin({
|
||||
bundler: 'vite'
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(isDev ? [CodeInspectorPlugin({ bundler: 'vite' })] : []), // 只在开发环境下启用 CodeInspectorPlugin
|
||||
...visualizerPlugin('renderer')
|
||||
],
|
||||
resolve: {
|
||||
@@ -95,6 +90,7 @@ export default defineConfig({
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
esbuild: isProd ? { legalComments: 'none' } : {}
|
||||
}
|
||||
})
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.4.11",
|
||||
"version": "1.5.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -64,6 +64,7 @@
|
||||
"@libsql/win32-x64-msvc": "^0.4.7",
|
||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jaison": "^2.0.2",
|
||||
"jschardet": "^3.1.4",
|
||||
"jsdom": "26.1.0",
|
||||
"macos-release": "^3.4.0",
|
||||
@@ -176,6 +177,7 @@
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.2.0",
|
||||
"fetch-socks": "1.3.2",
|
||||
"franc-min": "^6.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"google-auth-library": "^9.15.1",
|
||||
@@ -228,6 +230,7 @@
|
||||
"tiny-pinyin": "^1.3.2",
|
||||
"tokenx": "^1.1.0",
|
||||
"typescript": "^5.6.2",
|
||||
"undici": "6.21.2",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^10.0.0",
|
||||
"vite": "6.2.6",
|
||||
@@ -249,7 +252,8 @@
|
||||
"app-builder-lib@npm:26.0.13": "patch:app-builder-lib@npm%3A26.0.13#~/.yarn/patches/app-builder-lib-npm-26.0.13-a064c9e1d0.patch",
|
||||
"openai@npm:^4.87.3": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch",
|
||||
"app-builder-lib@npm:26.0.15": "patch:app-builder-lib@npm%3A26.0.15#~/.yarn/patches/app-builder-lib-npm-26.0.15-360e5b0476.patch",
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch"
|
||||
"@langchain/core@npm:^0.3.26": "patch:@langchain/core@npm%3A0.3.44#~/.yarn/patches/@langchain-core-npm-0.3.44-41d5c3cb0a.patch",
|
||||
"undici": "6.21.2"
|
||||
},
|
||||
"packageManager": "yarn@4.9.1",
|
||||
"lint-staged": {
|
||||
|
||||
@@ -74,8 +74,10 @@ export enum IpcChannel {
|
||||
Mcp_ServersChanged = 'mcp:servers-changed',
|
||||
Mcp_ServersUpdated = 'mcp:servers-updated',
|
||||
Mcp_CheckConnectivity = 'mcp:check-connectivity',
|
||||
Mcp_UploadDxt = 'mcp:upload-dxt',
|
||||
Mcp_SetProgress = 'mcp:set-progress',
|
||||
Mcp_AbortTool = 'mcp:abort-tool',
|
||||
Mcp_GetServerVersion = 'mcp:get-server-version',
|
||||
|
||||
// Python
|
||||
Python_Execute = 'python:execute',
|
||||
@@ -242,5 +244,17 @@ export enum IpcChannel {
|
||||
Selection_ActionWindowMinimize = 'selection:action-window-minimize',
|
||||
Selection_ActionWindowPin = 'selection:action-window-pin',
|
||||
Selection_ProcessAction = 'selection:process-action',
|
||||
Selection_UpdateActionData = 'selection:update-action-data'
|
||||
Selection_UpdateActionData = 'selection:update-action-data',
|
||||
|
||||
// Memory
|
||||
Memory_Add = 'memory:add',
|
||||
Memory_Search = 'memory:search',
|
||||
Memory_List = 'memory:list',
|
||||
Memory_Delete = 'memory:delete',
|
||||
Memory_Update = 'memory:update',
|
||||
Memory_Get = 'memory:get',
|
||||
Memory_SetConfig = 'memory:set-config',
|
||||
Memory_DeleteUser = 'memory:delete-user',
|
||||
Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user',
|
||||
Memory_GetUsersList = 'memory:get-users-list'
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ const textExtsByCategory = new Map([
|
||||
'.htm',
|
||||
'.xhtml', // HTML
|
||||
'.xml', // XML
|
||||
'.fxml', // JavaFX XML
|
||||
'.org', // Org-mode
|
||||
'.wiki', // Wiki
|
||||
'.tex',
|
||||
|
||||
@@ -43,7 +43,7 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
|
||||
|
||||
if (!packageName) {
|
||||
console.error(`No binary available for ${platformKey}`)
|
||||
return false
|
||||
return 101
|
||||
}
|
||||
|
||||
// Create output directory structure
|
||||
@@ -86,7 +86,7 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
|
||||
fs.chmodSync(outputPath, 0o755)
|
||||
} catch (chmodError) {
|
||||
console.error(`Warning: Failed to set executable permissions on ${filename}`)
|
||||
return false
|
||||
return 102
|
||||
}
|
||||
}
|
||||
console.log(`Extracted ${entry.name} -> ${outputPath}`)
|
||||
@@ -97,8 +97,10 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
|
||||
// Clean up
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Successfully installed bun ${version} for ${platformKey}`)
|
||||
return true
|
||||
return 0
|
||||
} catch (error) {
|
||||
let retCode = 103
|
||||
|
||||
console.error(`Error installing bun for ${platformKey}: ${error.message}`)
|
||||
// Clean up temporary file if it exists
|
||||
if (fs.existsSync(tempFilename)) {
|
||||
@@ -114,9 +116,10 @@ async function downloadBunBinary(platform, arch, version = DEFAULT_BUN_VERSION,
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
|
||||
retCode = 104
|
||||
}
|
||||
|
||||
return false
|
||||
return retCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,16 +162,21 @@ async function installBun() {
|
||||
`Installing bun ${version} for ${platform}-${arch}${isMusl ? ' (MUSL)' : ''}${isBaseline ? ' (baseline)' : ''}...`
|
||||
)
|
||||
|
||||
await downloadBunBinary(platform, arch, version, isMusl, isBaseline)
|
||||
return await downloadBunBinary(platform, arch, version, isMusl, isBaseline)
|
||||
}
|
||||
|
||||
// Run the installation
|
||||
installBun()
|
||||
.then(() => {
|
||||
console.log('Installation successful')
|
||||
process.exit(0)
|
||||
.then((retCode) => {
|
||||
if (retCode === 0) {
|
||||
console.log('Installation successful')
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.error('Installation failed')
|
||||
process.exit(retCode)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Installation failed:', error)
|
||||
process.exit(1)
|
||||
process.exit(100)
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
|
||||
|
||||
if (!packageName) {
|
||||
console.error(`No binary available for ${platformKey}`)
|
||||
return false
|
||||
return 101
|
||||
}
|
||||
|
||||
// Create output directory structure
|
||||
@@ -85,7 +85,7 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
|
||||
fs.chmodSync(outputPath, 0o755)
|
||||
} catch (chmodError) {
|
||||
console.error(`Warning: Failed to set executable permissions on ${filename}`)
|
||||
return false
|
||||
return 102
|
||||
}
|
||||
}
|
||||
console.log(`Extracted ${entry.name} -> ${outputPath}`)
|
||||
@@ -95,8 +95,10 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
|
||||
await zip.close()
|
||||
fs.unlinkSync(tempFilename)
|
||||
console.log(`Successfully installed uv ${version} for ${platform}-${arch}`)
|
||||
return true
|
||||
return 0
|
||||
} catch (error) {
|
||||
let retCode = 103
|
||||
|
||||
console.error(`Error installing uv for ${platformKey}: ${error.message}`)
|
||||
|
||||
if (fs.existsSync(tempFilename)) {
|
||||
@@ -112,9 +114,10 @@ async function downloadUvBinary(platform, arch, version = DEFAULT_UV_VERSION, is
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Warning: Failed to clean up directory: ${cleanupError.message}`)
|
||||
retCode = 104
|
||||
}
|
||||
|
||||
return false
|
||||
return retCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,16 +157,21 @@ async function installUv() {
|
||||
|
||||
console.log(`Installing uv ${version} for ${platform}-${arch}${isMusl ? ' (MUSL)' : ''}...`)
|
||||
|
||||
await downloadUvBinary(platform, arch, version, isMusl)
|
||||
return await downloadUvBinary(platform, arch, version, isMusl)
|
||||
}
|
||||
|
||||
// Run the installation
|
||||
installUv()
|
||||
.then(() => {
|
||||
console.log('Installation successful')
|
||||
process.exit(0)
|
||||
.then((retCode) => {
|
||||
if (retCode === 0) {
|
||||
console.log('Installation successful')
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.error('Installation failed')
|
||||
process.exit(retCode)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Installation failed:', error)
|
||||
process.exit(1)
|
||||
process.exit(100)
|
||||
})
|
||||
|
||||
+101
-18
@@ -1,9 +1,60 @@
|
||||
'use strict'
|
||||
var __createBinding =
|
||||
(this && this.__createBinding) ||
|
||||
(Object.create
|
||||
? function (o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k
|
||||
var desc = Object.getOwnPropertyDescriptor(m, k)
|
||||
if (!desc || ('get' in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||
desc = {
|
||||
enumerable: true,
|
||||
get: function () {
|
||||
return m[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
Object.defineProperty(o, k2, desc)
|
||||
}
|
||||
: function (o, m, k, k2) {
|
||||
if (k2 === undefined) k2 = k
|
||||
o[k2] = m[k]
|
||||
})
|
||||
var __setModuleDefault =
|
||||
(this && this.__setModuleDefault) ||
|
||||
(Object.create
|
||||
? function (o, v) {
|
||||
Object.defineProperty(o, 'default', { enumerable: true, value: v })
|
||||
}
|
||||
: function (o, v) {
|
||||
o['default'] = v
|
||||
})
|
||||
var __importStar =
|
||||
(this && this.__importStar) ||
|
||||
(function () {
|
||||
var ownKeys = function (o) {
|
||||
ownKeys =
|
||||
Object.getOwnPropertyNames ||
|
||||
function (o) {
|
||||
var ar = []
|
||||
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k
|
||||
return ar
|
||||
}
|
||||
return ownKeys(o)
|
||||
}
|
||||
return function (mod) {
|
||||
if (mod && mod.__esModule) return mod
|
||||
var result = {}
|
||||
if (mod != null)
|
||||
for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== 'default') __createBinding(result, mod, k[i])
|
||||
__setModuleDefault(result, mod)
|
||||
return result
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(exports, '__esModule', { value: true })
|
||||
var fs = require('fs')
|
||||
var path = require('path')
|
||||
var fs = __importStar(require('fs'))
|
||||
var path = __importStar(require('path'))
|
||||
var translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||
var baseLocale = 'en-us'
|
||||
var baseLocale = 'zh-cn'
|
||||
var baseFileName = ''.concat(baseLocale, '.json')
|
||||
var baseFilePath = path.join(translationsDir, baseFileName)
|
||||
/**
|
||||
@@ -48,12 +99,43 @@ function syncRecursively(target, template) {
|
||||
}
|
||||
return isUpdated
|
||||
}
|
||||
/**
|
||||
* 检查 JSON 对象中是否存在重复键,并收集所有重复键
|
||||
* @param obj 要检查的对象
|
||||
* @returns 返回重复键的数组(若无重复则返回空数组)
|
||||
*/
|
||||
function checkDuplicateKeys(obj) {
|
||||
var keys = new Set()
|
||||
var duplicateKeys = []
|
||||
var checkObject = function (obj, path) {
|
||||
if (path === void 0) {
|
||||
path = ''
|
||||
}
|
||||
for (var key in obj) {
|
||||
var fullPath = path ? ''.concat(path, '.').concat(key) : key
|
||||
if (keys.has(fullPath)) {
|
||||
// 发现重复键时,添加到数组中(避免重复添加)
|
||||
if (!duplicateKeys.includes(fullPath)) {
|
||||
duplicateKeys.push(fullPath)
|
||||
}
|
||||
} else {
|
||||
keys.add(fullPath)
|
||||
}
|
||||
// 递归检查子对象
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
checkObject(obj[key], fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
checkObject(obj)
|
||||
return duplicateKeys
|
||||
}
|
||||
function syncTranslations() {
|
||||
if (!fs.existsSync(baseFilePath)) {
|
||||
console.error(
|
||||
'\u4E3B\u6A21\u677F\u6587\u4EF6 '.concat(
|
||||
baseFileName,
|
||||
' \u4E0D\u5B58\u5728\uFF0C\u8BF7\u68C0\u67E5\u8DEF\u5F84\u6216\u6587\u4EF6\u540D\u3002'
|
||||
' \u4E0D\u5B58\u5728\uFF0C\u8BF7\u68C0\u67E5\u8DEF\u5F84\u6216\u6587\u4EF6\u540D'
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -63,9 +145,18 @@ function syncTranslations() {
|
||||
try {
|
||||
baseJson = JSON.parse(baseContent)
|
||||
} catch (error) {
|
||||
console.error('\u89E3\u6790 '.concat(baseFileName, ' \u51FA\u9519:'), error)
|
||||
console.error('\u89E3\u6790 '.concat(baseFileName, ' \u51FA\u9519\u3002').concat(error))
|
||||
return
|
||||
}
|
||||
// 检查主模板是否存在重复键
|
||||
var duplicateKeys = checkDuplicateKeys(baseJson)
|
||||
if (duplicateKeys.length > 0) {
|
||||
throw new Error(
|
||||
'\u4E3B\u6A21\u677F\u6587\u4EF6 '
|
||||
.concat(baseFileName, ' \u5B58\u5728\u4EE5\u4E0B\u91CD\u590D\u952E\uFF1A\n')
|
||||
.concat(duplicateKeys.join('\n'))
|
||||
)
|
||||
}
|
||||
var files = fs.readdirSync(translationsDir).filter(function (file) {
|
||||
return file.endsWith('.json') && file !== baseFileName
|
||||
})
|
||||
@@ -77,27 +168,19 @@ function syncTranslations() {
|
||||
var fileContent = fs.readFileSync(filePath, 'utf-8')
|
||||
targetJson = JSON.parse(fileContent)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'\u89E3\u6790 '.concat(
|
||||
file,
|
||||
' \u51FA\u9519\uFF0C\u8DF3\u8FC7\u6B64\u6587\u4EF6\u3002\u9519\u8BEF\u4FE1\u606F:'
|
||||
),
|
||||
error
|
||||
)
|
||||
console.error('\u89E3\u6790 '.concat(file, ' \u51FA\u9519\uFF0C\u8DF3\u8FC7\u6B64\u6587\u4EF6\u3002'), error)
|
||||
continue
|
||||
}
|
||||
var isUpdated = syncRecursively(targetJson, baseJson)
|
||||
if (isUpdated) {
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(targetJson, null, 2), 'utf-8')
|
||||
console.log(
|
||||
'\u6587\u4EF6 '.concat(file, ' \u5DF2\u66F4\u65B0\u540C\u6B65\u4E3B\u6A21\u677F\u7684\u5185\u5BB9\u3002')
|
||||
)
|
||||
fs.writeFileSync(filePath, JSON.stringify(targetJson, null, 2) + '\n', 'utf-8')
|
||||
console.log('\u6587\u4EF6 '.concat(file, ' \u5DF2\u66F4\u65B0\u540C\u6B65\u4E3B\u6A21\u677F\u7684\u5185\u5BB9'))
|
||||
} catch (error) {
|
||||
console.error('\u5199\u5165 '.concat(file, ' \u51FA\u9519:'), error)
|
||||
console.error('\u5199\u5165 '.concat(file, ' \u51FA\u9519\u3002').concat(error))
|
||||
}
|
||||
} else {
|
||||
console.log('\u6587\u4EF6 '.concat(file, ' \u65E0\u9700\u66F4\u65B0\u3002'))
|
||||
console.log('\u6587\u4EF6 '.concat(file, ' \u65E0\u9700\u66F4\u65B0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-4
@@ -2,7 +2,7 @@ import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
const translationsDir = path.join(__dirname, '../src/renderer/src/i18n/locales')
|
||||
const baseLocale = 'zh-CN'
|
||||
const baseLocale = 'zh-cn'
|
||||
const baseFileName = `${baseLocale}.json`
|
||||
const baseFilePath = path.join(translationsDir, baseFileName)
|
||||
|
||||
@@ -52,6 +52,39 @@ function syncRecursively(target: any, template: any): boolean {
|
||||
return isUpdated
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 JSON 对象中是否存在重复键,并收集所有重复键
|
||||
* @param obj 要检查的对象
|
||||
* @returns 返回重复键的数组(若无重复则返回空数组)
|
||||
*/
|
||||
function checkDuplicateKeys(obj: Record<string, any>): string[] {
|
||||
const keys = new Set<string>()
|
||||
const duplicateKeys: string[] = []
|
||||
|
||||
const checkObject = (obj: Record<string, any>, path: string = '') => {
|
||||
for (const key in obj) {
|
||||
const fullPath = path ? `${path}.${key}` : key
|
||||
|
||||
if (keys.has(fullPath)) {
|
||||
// 发现重复键时,添加到数组中(避免重复添加)
|
||||
if (!duplicateKeys.includes(fullPath)) {
|
||||
duplicateKeys.push(fullPath)
|
||||
}
|
||||
} else {
|
||||
keys.add(fullPath)
|
||||
}
|
||||
|
||||
// 递归检查子对象
|
||||
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||
checkObject(obj[key], fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkObject(obj)
|
||||
return duplicateKeys
|
||||
}
|
||||
|
||||
function syncTranslations() {
|
||||
if (!fs.existsSync(baseFilePath)) {
|
||||
console.error(`主模板文件 ${baseFileName} 不存在,请检查路径或文件名`)
|
||||
@@ -63,10 +96,16 @@ function syncTranslations() {
|
||||
try {
|
||||
baseJson = JSON.parse(baseContent)
|
||||
} catch (error) {
|
||||
console.error(`解析 ${baseFileName} 出错:`, error)
|
||||
console.error(`解析 ${baseFileName} 出错。${error}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查主模板是否存在重复键
|
||||
const duplicateKeys = checkDuplicateKeys(baseJson)
|
||||
if (duplicateKeys.length > 0) {
|
||||
throw new Error(`主模板文件 ${baseFileName} 存在以下重复键:\n${duplicateKeys.join('\n')}`)
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(translationsDir).filter((file) => file.endsWith('.json') && file !== baseFileName)
|
||||
|
||||
for (const file of files) {
|
||||
@@ -76,7 +115,7 @@ function syncTranslations() {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
||||
targetJson = JSON.parse(fileContent)
|
||||
} catch (error) {
|
||||
console.error(`解析 ${file} 出错,跳过此文件。错误信息:`, error)
|
||||
console.error(`解析 ${file} 出错,跳过此文件。`, error)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -87,7 +126,7 @@ function syncTranslations() {
|
||||
fs.writeFileSync(filePath, JSON.stringify(targetJson, null, 2) + '\n', 'utf-8')
|
||||
console.log(`文件 ${file} 已更新同步主模板的内容`)
|
||||
} catch (error) {
|
||||
console.error(`写入 ${file} 出错:`, error)
|
||||
console.error(`写入 ${file} 出错。${error}`)
|
||||
}
|
||||
} else {
|
||||
console.log(`文件 ${file} 无需更新`)
|
||||
|
||||
+9
-1
@@ -11,7 +11,7 @@ import { app } from 'electron'
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import Logger from 'electron-log'
|
||||
|
||||
import { isDev, isWin } from './constant'
|
||||
import { isDev, isWin, isLinux } from './constant'
|
||||
import { registerIpc } from './ipc'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import mcpService from './services/MCPService'
|
||||
@@ -46,6 +46,14 @@ if (isWin) {
|
||||
app.commandLine.appendSwitch('wm-window-animations-disabled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable GlobalShortcutsPortal for Linux Wayland Protocol
|
||||
* see: https://www.electronjs.org/docs/latest/api/global-shortcut
|
||||
*/
|
||||
if (isLinux && process.env.XDG_SESSION_TYPE === 'wayland') {
|
||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal')
|
||||
}
|
||||
|
||||
// Enable features for unresponsive renderer js call stacks
|
||||
app.commandLine.appendSwitch('enable-features', 'DocumentPolicyIncludeJSCallStacksInCrashReports')
|
||||
app.on('web-contents-created', (_, webContents) => {
|
||||
|
||||
+59
-4
@@ -8,7 +8,7 @@ import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
@@ -17,15 +17,17 @@ import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import DxtService from './services/DxtService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
import FileStorage from './services/FileStorage'
|
||||
import FileService from './services/FileSystemService'
|
||||
import KnowledgeService from './services/KnowledgeService'
|
||||
import mcpService from './services/MCPService'
|
||||
import MemoryService from './services/memory/MemoryService'
|
||||
import NotificationService from './services/NotificationService'
|
||||
import * as NutstoreService from './services/NutstoreService'
|
||||
import ObsidianVaultService from './services/ObsidianVaultService'
|
||||
import { ProxyConfig, proxyManager } from './services/ProxyManager'
|
||||
import { proxyManager } from './services/ProxyManager'
|
||||
import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
@@ -46,6 +48,8 @@ const backupManager = new BackupManager()
|
||||
const exportService = new ExportService(fileManager)
|
||||
const obsidianVaultService = new ObsidianVaultService()
|
||||
const vertexAIService = VertexAIService.getInstance()
|
||||
const memoryService = MemoryService.getInstance()
|
||||
const dxtService = new DxtService()
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const appUpdater = new AppUpdater(mainWindow)
|
||||
@@ -74,9 +78,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
if (proxy === 'system') {
|
||||
proxyConfig = { mode: 'system' }
|
||||
} else if (proxy) {
|
||||
proxyConfig = { mode: 'custom', url: proxy }
|
||||
proxyConfig = { mode: 'fixed_servers', proxyRules: proxy }
|
||||
} else {
|
||||
proxyConfig = { mode: 'none' }
|
||||
proxyConfig = { mode: 'direct' }
|
||||
}
|
||||
|
||||
await proxyManager.configureProxy(proxyConfig)
|
||||
@@ -453,6 +457,38 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Rerank, KnowledgeService.rerank)
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Check_Quota, KnowledgeService.checkQuota)
|
||||
|
||||
// memory
|
||||
ipcMain.handle(IpcChannel.Memory_Add, async (_, messages, config) => {
|
||||
return await memoryService.add(messages, config)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_Search, async (_, query, config) => {
|
||||
return await memoryService.search(query, config)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_List, async (_, config) => {
|
||||
return await memoryService.list(config)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_Delete, async (_, id) => {
|
||||
return await memoryService.delete(id)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_Update, async (_, id, memory, metadata) => {
|
||||
return await memoryService.update(id, memory, metadata)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_Get, async (_, memoryId) => {
|
||||
return await memoryService.get(memoryId)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_SetConfig, async (_, config) => {
|
||||
memoryService.setConfig(config)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_DeleteUser, async (_, userId) => {
|
||||
return await memoryService.deleteUser(userId)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_DeleteAllMemoriesForUser, async (_, userId) => {
|
||||
return await memoryService.deleteAllMemoriesForUser(userId)
|
||||
})
|
||||
ipcMain.handle(IpcChannel.Memory_GetUsersList, async () => {
|
||||
return await memoryService.getUsersList()
|
||||
})
|
||||
|
||||
// window
|
||||
ipcMain.handle(IpcChannel.Windows_SetMinimumSize, (_, width: number, height: number) => {
|
||||
mainWindow?.setMinimumSize(width, height)
|
||||
@@ -503,10 +539,29 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
ipcMain.handle(IpcChannel.Mcp_GetInstallInfo, mcpService.getInstallInfo)
|
||||
ipcMain.handle(IpcChannel.Mcp_CheckConnectivity, mcpService.checkMcpConnectivity)
|
||||
ipcMain.handle(IpcChannel.Mcp_AbortTool, mcpService.abortTool)
|
||||
ipcMain.handle(IpcChannel.Mcp_GetServerVersion, mcpService.getServerVersion)
|
||||
ipcMain.handle(IpcChannel.Mcp_SetProgress, (_, progress: number) => {
|
||||
mainWindow.webContents.send('mcp-progress', progress)
|
||||
})
|
||||
|
||||
// DXT upload handler
|
||||
ipcMain.handle(IpcChannel.Mcp_UploadDxt, async (event, fileBuffer: ArrayBuffer, fileName: string) => {
|
||||
try {
|
||||
// Create a temporary file with the uploaded content
|
||||
const tempPath = await fileManager.createTempFile(event, fileName)
|
||||
await fileManager.writeFile(event, tempPath, Buffer.from(fileBuffer))
|
||||
|
||||
// Process DXT file using the temporary path
|
||||
return await dxtService.uploadDxt(event, tempPath)
|
||||
} catch (error) {
|
||||
log.error('[IPC] DXT upload error:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to upload DXT file'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Register Python execution handler
|
||||
ipcMain.handle(
|
||||
IpcChannel.Python_Execute,
|
||||
|
||||
+4
-8
@@ -1,19 +1,15 @@
|
||||
import type { BaseEmbeddings } from '@cherrystudio/embedjs-interfaces'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import { ApiClient } from '@types'
|
||||
|
||||
import EmbeddingsFactory from './EmbeddingsFactory'
|
||||
|
||||
export default class Embeddings {
|
||||
private sdk: BaseEmbeddings
|
||||
constructor({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams) {
|
||||
constructor({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }) {
|
||||
this.sdk = EmbeddingsFactory.create({
|
||||
model,
|
||||
provider,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
embedApiClient,
|
||||
dimensions
|
||||
} as KnowledgeBaseParams)
|
||||
})
|
||||
}
|
||||
public async init(): Promise<void> {
|
||||
return this.sdk.init()
|
||||
+3
-2
@@ -3,14 +3,15 @@ import { OllamaEmbeddings } from '@cherrystudio/embedjs-ollama'
|
||||
import { OpenAiEmbeddings } from '@cherrystudio/embedjs-openai'
|
||||
import { AzureOpenAiEmbeddings } from '@cherrystudio/embedjs-openai/src/azure-openai-embeddings'
|
||||
import { getInstanceName } from '@main/utils'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import { ApiClient } from '@types'
|
||||
|
||||
import { VOYAGE_SUPPORTED_DIM_MODELS } from './utils'
|
||||
import { VoyageEmbeddings } from './VoyageEmbeddings'
|
||||
|
||||
export default class EmbeddingsFactory {
|
||||
static create({ model, provider, apiKey, apiVersion, baseURL, dimensions }: KnowledgeBaseParams): BaseEmbeddings {
|
||||
static create({ embedApiClient, dimensions }: { embedApiClient: ApiClient; dimensions?: number }): BaseEmbeddings {
|
||||
const batchSize = 10
|
||||
const { model, provider, apiKey, apiVersion, baseURL } = embedApiClient
|
||||
if (provider === 'voyageai') {
|
||||
return new VoyageEmbeddings({
|
||||
modelName: model,
|
||||
+9
-9
@@ -5,7 +5,7 @@ export default abstract class BaseReranker {
|
||||
protected base: KnowledgeBaseParams
|
||||
|
||||
constructor(base: KnowledgeBaseParams) {
|
||||
if (!base.rerankModel) {
|
||||
if (!base.rerankApiClient) {
|
||||
throw new Error('Rerank model is required')
|
||||
}
|
||||
this.base = base
|
||||
@@ -17,11 +17,11 @@ export default abstract class BaseReranker {
|
||||
* Get Rerank Request Url
|
||||
*/
|
||||
protected getRerankUrl() {
|
||||
if (this.base.rerankModelProvider === 'bailian') {
|
||||
if (this.base.rerankApiClient?.provider === 'bailian') {
|
||||
return 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank'
|
||||
}
|
||||
|
||||
let baseURL = this.base.rerankBaseURL
|
||||
let baseURL = this.base.rerankApiClient?.baseURL
|
||||
|
||||
if (baseURL && baseURL.endsWith('/')) {
|
||||
// `/` 结尾强制使用rerankBaseURL
|
||||
@@ -39,20 +39,20 @@ export default abstract class BaseReranker {
|
||||
* Get Rerank Request Body
|
||||
*/
|
||||
protected getRerankRequestBody(query: string, searchResults: ExtractChunkData[]) {
|
||||
const provider = this.base.rerankModelProvider
|
||||
const provider = this.base.rerankApiClient?.provider
|
||||
const documents = searchResults.map((doc) => doc.pageContent)
|
||||
const topN = this.base.documentCount
|
||||
|
||||
if (provider === 'voyageai') {
|
||||
return {
|
||||
model: this.base.rerankModel,
|
||||
model: this.base.rerankApiClient?.model,
|
||||
query,
|
||||
documents,
|
||||
top_k: topN
|
||||
}
|
||||
} else if (provider === 'bailian') {
|
||||
return {
|
||||
model: this.base.rerankModel,
|
||||
model: this.base.rerankApiClient?.model,
|
||||
input: {
|
||||
query,
|
||||
documents
|
||||
@@ -69,7 +69,7 @@ export default abstract class BaseReranker {
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
model: this.base.rerankModel,
|
||||
model: this.base.rerankApiClient?.model,
|
||||
query,
|
||||
documents,
|
||||
top_n: topN
|
||||
@@ -81,7 +81,7 @@ export default abstract class BaseReranker {
|
||||
* Extract Rerank Result
|
||||
*/
|
||||
protected extractRerankResult(data: any) {
|
||||
const provider = this.base.rerankModelProvider
|
||||
const provider = this.base.rerankApiClient?.provider
|
||||
if (provider === 'bailian') {
|
||||
return data.output.results
|
||||
} else if (provider === 'voyageai') {
|
||||
@@ -129,7 +129,7 @@ export default abstract class BaseReranker {
|
||||
|
||||
public defaultHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.base.rerankApiKey}`,
|
||||
Authorization: `Bearer ${this.base.rerankApiClient?.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import AxiosProxy from '@main/services/AxiosProxy'
|
||||
import { KnowledgeBaseParams } from '@types'
|
||||
import axios from 'axios'
|
||||
|
||||
import BaseReranker from './BaseReranker'
|
||||
|
||||
@@ -15,7 +15,7 @@ export default class GeneralReranker extends BaseReranker {
|
||||
const requestBody = this.getRerankRequestBody(query, searchResults)
|
||||
|
||||
try {
|
||||
const { data } = await AxiosProxy.axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
const { data } = await axios.post(url, requestBody, { headers: this.defaultHeaders() })
|
||||
|
||||
const rerankResults = this.extractRerankResult(data)
|
||||
return this.getRerankResult(searchResults, rerankResults)
|
||||
@@ -1,29 +0,0 @@
|
||||
import { AxiosInstance, default as axios_ } from 'axios'
|
||||
import { ProxyAgent } from 'proxy-agent'
|
||||
|
||||
import { proxyManager } from './ProxyManager'
|
||||
|
||||
class AxiosProxy {
|
||||
private cacheAxios: AxiosInstance | null = null
|
||||
private proxyAgent: ProxyAgent | null = null
|
||||
|
||||
get axios(): AxiosInstance {
|
||||
const currentProxyAgent = proxyManager.getProxyAgent()
|
||||
|
||||
// 如果代理发生变化或尚未初始化,则重新创建 axios 实例
|
||||
if (this.cacheAxios === null || (currentProxyAgent !== null && this.proxyAgent !== currentProxyAgent)) {
|
||||
this.proxyAgent = currentProxyAgent
|
||||
|
||||
// 创建带有代理配置的 axios 实例
|
||||
this.cacheAxios = axios_.create({
|
||||
proxy: false,
|
||||
httpAgent: currentProxyAgent || undefined,
|
||||
httpsAgent: currentProxyAgent || undefined
|
||||
})
|
||||
}
|
||||
|
||||
return this.cacheAxios
|
||||
}
|
||||
}
|
||||
|
||||
export default new AxiosProxy()
|
||||
@@ -321,14 +321,22 @@ class BackupManager {
|
||||
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {
|
||||
const filename = webdavConfig.fileName || 'cherry-studio.backup.zip'
|
||||
const backupedFilePath = await this.backup(_, filename, data, undefined, webdavConfig.skipBackupFile)
|
||||
const contentLength = (await fs.stat(backupedFilePath)).size
|
||||
const webdavClient = new WebDav(webdavConfig)
|
||||
try {
|
||||
const result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
overwrite: true,
|
||||
contentLength
|
||||
})
|
||||
// 上传成功后删除本地备份文件
|
||||
let result
|
||||
if (webdavConfig.disableStream) {
|
||||
const fileContent = await fs.readFile(backupedFilePath)
|
||||
result = await webdavClient.putFileContents(filename, fileContent, {
|
||||
overwrite: true
|
||||
})
|
||||
} else {
|
||||
const contentLength = (await fs.stat(backupedFilePath)).size
|
||||
result = await webdavClient.putFileContents(filename, fs.createReadStream(backupedFilePath), {
|
||||
overwrite: true,
|
||||
contentLength
|
||||
})
|
||||
}
|
||||
|
||||
await fs.remove(backupedFilePath)
|
||||
return result
|
||||
} catch (error) {
|
||||
|
||||
@@ -25,7 +25,8 @@ export enum ConfigKeys {
|
||||
SelectionAssistantRemeberWinSize = 'selectionAssistantRemeberWinSize',
|
||||
SelectionAssistantFilterMode = 'selectionAssistantFilterMode',
|
||||
SelectionAssistantFilterList = 'selectionAssistantFilterList',
|
||||
DisableHardwareAcceleration = 'disableHardwareAcceleration'
|
||||
DisableHardwareAcceleration = 'disableHardwareAcceleration',
|
||||
Proxy = 'proxy'
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import axios from 'axios'
|
||||
import { app, safeStorage } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
|
||||
import aoxisProxy from './AxiosProxy'
|
||||
|
||||
// 配置常量,集中管理
|
||||
const CONFIG = {
|
||||
GITHUB_CLIENT_ID: 'Iv1.b507a08c87ecfe98',
|
||||
@@ -96,7 +95,7 @@ class CopilotService {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await aoxisProxy.axios.get(CONFIG.API_URLS.GITHUB_USER, config)
|
||||
const response = await axios.get(CONFIG.API_URLS.GITHUB_USER, config)
|
||||
return {
|
||||
login: response.data.login,
|
||||
avatar: response.data.avatar_url
|
||||
@@ -117,7 +116,7 @@ class CopilotService {
|
||||
try {
|
||||
this.updateHeaders(headers)
|
||||
|
||||
const response = await aoxisProxy.axios.post<AuthResponse>(
|
||||
const response = await axios.post<AuthResponse>(
|
||||
CONFIG.API_URLS.GITHUB_DEVICE_CODE,
|
||||
{
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
@@ -149,7 +148,7 @@ class CopilotService {
|
||||
await this.delay(currentDelay)
|
||||
|
||||
try {
|
||||
const response = await aoxisProxy.axios.post<TokenResponse>(
|
||||
const response = await axios.post<TokenResponse>(
|
||||
CONFIG.API_URLS.GITHUB_ACCESS_TOKEN,
|
||||
{
|
||||
client_id: CONFIG.GITHUB_CLIENT_ID,
|
||||
@@ -211,7 +210,7 @@ class CopilotService {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await aoxisProxy.axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
|
||||
const response = await axios.get<CopilotTokenResponse>(CONFIG.API_URLS.COPILOT_TOKEN, config)
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { getMcpDir, getTempDir } from '@main/utils/file'
|
||||
import logger from 'electron-log'
|
||||
import * as fs from 'fs'
|
||||
import StreamZip from 'node-stream-zip'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
// Type definitions
|
||||
export interface DxtManifest {
|
||||
dxt_version: string
|
||||
name: string
|
||||
display_name?: string
|
||||
version: string
|
||||
description?: string
|
||||
long_description?: string
|
||||
author?: {
|
||||
name?: string
|
||||
email?: string
|
||||
url?: string
|
||||
}
|
||||
repository?: {
|
||||
type?: string
|
||||
url?: string
|
||||
}
|
||||
homepage?: string
|
||||
documentation?: string
|
||||
support?: string
|
||||
icon?: string
|
||||
server: {
|
||||
type: string
|
||||
entry_point: string
|
||||
mcp_config: {
|
||||
command: string
|
||||
args: string[]
|
||||
env?: Record<string, string>
|
||||
platform_overrides?: {
|
||||
[platform: string]: {
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tools?: Array<{
|
||||
name: string
|
||||
description: string
|
||||
}>
|
||||
keywords?: string[]
|
||||
license?: string
|
||||
user_config?: Record<string, any>
|
||||
compatibility?: {
|
||||
claude_desktop?: string
|
||||
platforms?: string[]
|
||||
runtimes?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export interface DxtUploadResult {
|
||||
success: boolean
|
||||
data?: {
|
||||
manifest: DxtManifest
|
||||
extractDir: string
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function performVariableSubstitution(
|
||||
value: string,
|
||||
extractDir: string,
|
||||
userConfig?: Record<string, any>
|
||||
): string {
|
||||
let result = value
|
||||
|
||||
// Replace ${__dirname} with the extraction directory
|
||||
result = result.replace(/\$\{__dirname\}/g, extractDir)
|
||||
|
||||
// Replace ${HOME} with user's home directory
|
||||
result = result.replace(/\$\{HOME\}/g, os.homedir())
|
||||
|
||||
// Replace ${DESKTOP} with user's desktop directory
|
||||
const desktopDir = path.join(os.homedir(), 'Desktop')
|
||||
result = result.replace(/\$\{DESKTOP\}/g, desktopDir)
|
||||
|
||||
// Replace ${DOCUMENTS} with user's documents directory
|
||||
const documentsDir = path.join(os.homedir(), 'Documents')
|
||||
result = result.replace(/\$\{DOCUMENTS\}/g, documentsDir)
|
||||
|
||||
// Replace ${DOWNLOADS} with user's downloads directory
|
||||
const downloadsDir = path.join(os.homedir(), 'Downloads')
|
||||
result = result.replace(/\$\{DOWNLOADS\}/g, downloadsDir)
|
||||
|
||||
// Replace ${pathSeparator} or ${/} with the platform-specific path separator
|
||||
result = result.replace(/\$\{pathSeparator\}/g, path.sep)
|
||||
result = result.replace(/\$\{\/\}/g, path.sep)
|
||||
|
||||
// Replace ${user_config.KEY} with user-configured values
|
||||
if (userConfig) {
|
||||
result = result.replace(/\$\{user_config\.([^}]+)\}/g, (match, key) => {
|
||||
return userConfig[key] || match // Keep original if not found
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function applyPlatformOverrides(mcpConfig: any, extractDir: string, userConfig?: Record<string, any>): any {
|
||||
const platform = process.platform
|
||||
const resolvedConfig = { ...mcpConfig }
|
||||
|
||||
// Apply platform-specific overrides
|
||||
if (mcpConfig.platform_overrides && mcpConfig.platform_overrides[platform]) {
|
||||
const override = mcpConfig.platform_overrides[platform]
|
||||
|
||||
// Override command if specified
|
||||
if (override.command) {
|
||||
resolvedConfig.command = override.command
|
||||
}
|
||||
|
||||
// Override args if specified
|
||||
if (override.args) {
|
||||
resolvedConfig.args = override.args
|
||||
}
|
||||
|
||||
// Merge environment variables
|
||||
if (override.env) {
|
||||
resolvedConfig.env = { ...resolvedConfig.env, ...override.env }
|
||||
}
|
||||
}
|
||||
|
||||
// Apply variable substitution to all string values
|
||||
if (resolvedConfig.command) {
|
||||
resolvedConfig.command = performVariableSubstitution(resolvedConfig.command, extractDir, userConfig)
|
||||
}
|
||||
|
||||
if (resolvedConfig.args) {
|
||||
resolvedConfig.args = resolvedConfig.args.map((arg: string) =>
|
||||
performVariableSubstitution(arg, extractDir, userConfig)
|
||||
)
|
||||
}
|
||||
|
||||
if (resolvedConfig.env) {
|
||||
for (const [key, value] of Object.entries(resolvedConfig.env)) {
|
||||
resolvedConfig.env[key] = performVariableSubstitution(value as string, extractDir, userConfig)
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedConfig
|
||||
}
|
||||
|
||||
export interface ResolvedMcpConfig {
|
||||
command: string
|
||||
args: string[]
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
class DxtService {
|
||||
private tempDir = path.join(getTempDir(), 'dxt_uploads')
|
||||
private mcpDir = getMcpDir()
|
||||
|
||||
constructor() {
|
||||
this.ensureDirectories()
|
||||
}
|
||||
|
||||
private ensureDirectories() {
|
||||
try {
|
||||
// Create temp directory
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true })
|
||||
}
|
||||
// Create MCP directory
|
||||
if (!fs.existsSync(this.mcpDir)) {
|
||||
fs.mkdirSync(this.mcpDir, { recursive: true })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[DxtService] Failed to create directories:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private async moveDirectory(source: string, destination: string): Promise<void> {
|
||||
try {
|
||||
// Try rename first (works if on same filesystem)
|
||||
fs.renameSync(source, destination)
|
||||
} catch (error) {
|
||||
// If rename fails (cross-filesystem), use copy + remove
|
||||
logger.info('[DxtService] Cross-filesystem move detected, using copy + remove')
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = path.dirname(destination)
|
||||
if (!fs.existsSync(parentDir)) {
|
||||
fs.mkdirSync(parentDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Recursively copy directory
|
||||
await this.copyDirectory(source, destination)
|
||||
|
||||
// Remove source directory
|
||||
fs.rmSync(source, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
private async copyDirectory(source: string, destination: string): Promise<void> {
|
||||
// Create destination directory
|
||||
fs.mkdirSync(destination, { recursive: true })
|
||||
|
||||
// Read source directory
|
||||
const entries = fs.readdirSync(source, { withFileTypes: true })
|
||||
|
||||
// Copy each entry
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(source, entry.name)
|
||||
const destPath = path.join(destination, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.copyDirectory(sourcePath, destPath)
|
||||
} else {
|
||||
fs.copyFileSync(sourcePath, destPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async uploadDxt(_: Electron.IpcMainInvokeEvent, filePath: string): Promise<DxtUploadResult> {
|
||||
const tempExtractDir = path.join(this.tempDir, `dxt_${uuidv4()}`)
|
||||
|
||||
try {
|
||||
// Validate file exists
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('DXT file not found')
|
||||
}
|
||||
|
||||
// Extract the DXT file (which is a ZIP archive) to a temporary directory
|
||||
logger.info('[DxtService] Extracting DXT file:', filePath)
|
||||
|
||||
const zip = new StreamZip.async({ file: filePath })
|
||||
await zip.extract(null, tempExtractDir)
|
||||
await zip.close()
|
||||
|
||||
// Read and validate the manifest.json
|
||||
const manifestPath = path.join(tempExtractDir, 'manifest.json')
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error('manifest.json not found in DXT file')
|
||||
}
|
||||
|
||||
const manifestContent = fs.readFileSync(manifestPath, 'utf-8')
|
||||
const manifest: DxtManifest = JSON.parse(manifestContent)
|
||||
|
||||
// Validate required fields in manifest
|
||||
if (!manifest.dxt_version) {
|
||||
throw new Error('Invalid manifest: missing dxt_version')
|
||||
}
|
||||
if (!manifest.name) {
|
||||
throw new Error('Invalid manifest: missing name')
|
||||
}
|
||||
if (!manifest.version) {
|
||||
throw new Error('Invalid manifest: missing version')
|
||||
}
|
||||
if (!manifest.server) {
|
||||
throw new Error('Invalid manifest: missing server configuration')
|
||||
}
|
||||
if (!manifest.server.mcp_config) {
|
||||
throw new Error('Invalid manifest: missing server.mcp_config')
|
||||
}
|
||||
if (!manifest.server.mcp_config.command) {
|
||||
throw new Error('Invalid manifest: missing server.mcp_config.command')
|
||||
}
|
||||
if (!Array.isArray(manifest.server.mcp_config.args)) {
|
||||
throw new Error('Invalid manifest: server.mcp_config.args must be an array')
|
||||
}
|
||||
|
||||
// Use server name as the final extract directory for automatic version management
|
||||
// Sanitize the name to prevent creating subdirectories
|
||||
const sanitizedName = manifest.name.replace(/\//g, '-')
|
||||
const serverDirName = `server-${sanitizedName}`
|
||||
const finalExtractDir = path.join(this.mcpDir, serverDirName)
|
||||
|
||||
// Clean up any existing version of this server
|
||||
if (fs.existsSync(finalExtractDir)) {
|
||||
logger.info('[DxtService] Removing existing server directory:', finalExtractDir)
|
||||
fs.rmSync(finalExtractDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// Move the temporary directory to the final location
|
||||
// Use recursive copy + remove instead of rename to handle cross-filesystem moves
|
||||
await this.moveDirectory(tempExtractDir, finalExtractDir)
|
||||
logger.info('[DxtService] DXT server extracted to:', finalExtractDir)
|
||||
|
||||
// Clean up the uploaded DXT file if it's in temp directory
|
||||
if (filePath.startsWith(this.tempDir)) {
|
||||
fs.unlinkSync(filePath)
|
||||
}
|
||||
|
||||
// Return success with manifest and extraction path
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
manifest,
|
||||
extractDir: finalExtractDir
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Clean up on error
|
||||
if (fs.existsSync(tempExtractDir)) {
|
||||
fs.rmSync(tempExtractDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to process DXT file'
|
||||
logger.error('[DxtService] DXT upload error:', error)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolved MCP configuration for a DXT server with platform overrides and variable substitution
|
||||
*/
|
||||
public getResolvedMcpConfig(dxtPath: string, userConfig?: Record<string, any>): ResolvedMcpConfig | null {
|
||||
try {
|
||||
// Read the manifest from the DXT server directory
|
||||
const manifestPath = path.join(dxtPath, 'manifest.json')
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
logger.error('[DxtService] Manifest not found:', manifestPath)
|
||||
return null
|
||||
}
|
||||
|
||||
const manifestContent = fs.readFileSync(manifestPath, 'utf-8')
|
||||
const manifest: DxtManifest = JSON.parse(manifestContent)
|
||||
|
||||
if (!manifest.server?.mcp_config) {
|
||||
logger.error('[DxtService] No mcp_config found in manifest')
|
||||
return null
|
||||
}
|
||||
|
||||
// Apply platform overrides and variable substitution
|
||||
const resolvedConfig = applyPlatformOverrides(manifest.server.mcp_config, dxtPath, userConfig)
|
||||
|
||||
logger.info('[DxtService] Resolved MCP config:', {
|
||||
command: resolvedConfig.command,
|
||||
args: resolvedConfig.args,
|
||||
env: resolvedConfig.env ? Object.keys(resolvedConfig.env) : undefined
|
||||
})
|
||||
|
||||
return resolvedConfig
|
||||
} catch (error) {
|
||||
logger.error('[DxtService] Failed to resolve MCP config:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public cleanupDxtServer(serverName: string): boolean {
|
||||
try {
|
||||
// Handle server names that might contain slashes (e.g., "anthropic/sequential-thinking")
|
||||
// by replacing slashes with the same separator used during installation
|
||||
const sanitizedName = serverName.replace(/\//g, '-')
|
||||
const serverDirName = `server-${sanitizedName}`
|
||||
const serverDir = path.join(this.mcpDir, serverDirName)
|
||||
|
||||
// First try the sanitized path
|
||||
if (fs.existsSync(serverDir)) {
|
||||
logger.info('[DxtService] Removing DXT server directory:', serverDir)
|
||||
fs.rmSync(serverDir, { recursive: true, force: true })
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback: try with original name in case it was stored differently
|
||||
const originalServerDir = path.join(this.mcpDir, `server-${serverName}`)
|
||||
if (fs.existsSync(originalServerDir)) {
|
||||
logger.info('[DxtService] Removing DXT server directory:', originalServerDir)
|
||||
fs.rmSync(originalServerDir, { recursive: true, force: true })
|
||||
return true
|
||||
}
|
||||
|
||||
logger.warn('[DxtService] Server directory not found:', serverDir)
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('[DxtService] Failed to cleanup DXT server:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public cleanup() {
|
||||
try {
|
||||
// Clean up temp directory
|
||||
if (fs.existsSync(this.tempDir)) {
|
||||
fs.rmSync(this.tempDir, { recursive: true, force: true })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[DxtService] Cleanup error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DxtService
|
||||
@@ -270,7 +270,7 @@ class FileStorage {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return 'failed to read file'
|
||||
throw new Error(`Failed to read file: ${filePath}.`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ import type { ExtractChunkData } from '@cherrystudio/embedjs-interfaces'
|
||||
import { LibSqlDb } from '@cherrystudio/embedjs-libsql'
|
||||
import { SitemapLoader } from '@cherrystudio/embedjs-loader-sitemap'
|
||||
import { WebLoader } from '@cherrystudio/embedjs-loader-web'
|
||||
import Embeddings from '@main/knowledage/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/knowledage/loader'
|
||||
import { NoteLoader } from '@main/knowledage/loader/noteLoader'
|
||||
import OcrProvider from '@main/knowledage/ocr/OcrProvider'
|
||||
import PreprocessProvider from '@main/knowledage/preprocess/PreprocessProvider'
|
||||
import Reranker from '@main/knowledage/reranker/Reranker'
|
||||
import Embeddings from '@main/knowledge/embeddings/Embeddings'
|
||||
import { addFileLoader } from '@main/knowledge/loader'
|
||||
import { NoteLoader } from '@main/knowledge/loader/noteLoader'
|
||||
import Reranker from '@main/knowledge/reranker/Reranker'
|
||||
import { windowService } from '@main/services/WindowService'
|
||||
import { getDataPath } from '@main/utils'
|
||||
import { getAllFiles } from '@main/utils/file'
|
||||
@@ -120,27 +120,21 @@ class KnowledgeService {
|
||||
|
||||
private getRagApplication = async ({
|
||||
id,
|
||||
model,
|
||||
provider,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
dimensions
|
||||
embedApiClient,
|
||||
dimensions,
|
||||
documentCount
|
||||
}: KnowledgeBaseParams): Promise<RAGApplication> => {
|
||||
let ragApplication: RAGApplication
|
||||
const embeddings = new Embeddings({
|
||||
model,
|
||||
provider,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
embedApiClient,
|
||||
dimensions
|
||||
} as KnowledgeBaseParams)
|
||||
})
|
||||
try {
|
||||
ragApplication = await new RAGApplicationBuilder()
|
||||
.setModel('NO_MODEL')
|
||||
.setEmbeddingModel(embeddings)
|
||||
.setVectorDatabase(new LibSqlDb({ path: path.join(this.storageDir, id) }))
|
||||
.setSearchResultCount(documentCount || 30)
|
||||
.build()
|
||||
} catch (e) {
|
||||
Logger.error(e)
|
||||
|
||||
@@ -14,6 +14,16 @@ import {
|
||||
type StreamableHTTPClientTransportOptions
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'
|
||||
// Import notification schemas from MCP SDK
|
||||
import {
|
||||
CancelledNotificationSchema,
|
||||
LoggingMessageNotificationSchema,
|
||||
ProgressNotificationSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
ResourceUpdatedNotificationSchema,
|
||||
ToolListChangedNotificationSchema
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
import { nanoid } from '@reduxjs/toolkit'
|
||||
import {
|
||||
GetMCPPromptResponse,
|
||||
@@ -31,6 +41,7 @@ import { memoize } from 'lodash'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { CacheService } from './CacheService'
|
||||
import DxtService from './DxtService'
|
||||
import { CallBackServer } from './mcp/oauth/callback'
|
||||
import { McpOAuthClientProvider } from './mcp/oauth/provider'
|
||||
import getLoginShellEnvironment from './mcp/shell-env'
|
||||
@@ -72,6 +83,7 @@ function withCache<T extends unknown[], R>(
|
||||
class McpService {
|
||||
private clients: Map<string, Client> = new Map()
|
||||
private pendingClients: Map<string, Promise<Client>> = new Map()
|
||||
private dxtService = new DxtService()
|
||||
private activeToolCalls: Map<string, AbortController> = new Map()
|
||||
|
||||
constructor() {
|
||||
@@ -88,6 +100,8 @@ class McpService {
|
||||
this.stopServer = this.stopServer.bind(this)
|
||||
this.abortTool = this.abortTool.bind(this)
|
||||
this.cleanup = this.cleanup.bind(this)
|
||||
this.checkMcpConnectivity = this.checkMcpConnectivity.bind(this)
|
||||
this.getServerVersion = this.getServerVersion.bind(this)
|
||||
}
|
||||
|
||||
private getServerKey(server: MCPServer): string {
|
||||
@@ -136,7 +150,7 @@ class McpService {
|
||||
// Create new client instance for each connection
|
||||
const client = new Client({ name: 'Cherry Studio', version: app.getVersion() }, { capabilities: {} })
|
||||
|
||||
const args = [...(server.args || [])]
|
||||
let args = [...(server.args || [])]
|
||||
|
||||
// let transport: StdioClientTransport | SSEClientTransport | InMemoryTransport | StreamableHTTPClientTransport
|
||||
const authProvider = new McpOAuthClientProvider({
|
||||
@@ -206,6 +220,23 @@ class McpService {
|
||||
} else if (server.command) {
|
||||
let cmd = server.command
|
||||
|
||||
// For DXT servers, use resolved configuration with platform overrides and variable substitution
|
||||
if (server.dxtPath) {
|
||||
const resolvedConfig = this.dxtService.getResolvedMcpConfig(server.dxtPath)
|
||||
if (resolvedConfig) {
|
||||
cmd = resolvedConfig.command
|
||||
args = resolvedConfig.args
|
||||
// Merge resolved environment variables with existing ones
|
||||
server.env = {
|
||||
...server.env,
|
||||
...resolvedConfig.env
|
||||
}
|
||||
Logger.info(`[MCP] Using resolved DXT config - command: ${cmd}, args: ${args?.join(' ')}`)
|
||||
} else {
|
||||
Logger.warn(`[MCP] Failed to resolve DXT config for ${server.name}, falling back to manifest values`)
|
||||
}
|
||||
}
|
||||
|
||||
if (server.command === 'npx') {
|
||||
cmd = await getBinaryPath('bun')
|
||||
Logger.info(`[MCP] Using command: ${cmd}`)
|
||||
@@ -252,7 +283,7 @@ class McpService {
|
||||
this.removeProxyEnv(loginShellEnv)
|
||||
}
|
||||
|
||||
const stdioTransport = new StdioClientTransport({
|
||||
const transportOptions: any = {
|
||||
command: cmd,
|
||||
args,
|
||||
env: {
|
||||
@@ -260,7 +291,15 @@ class McpService {
|
||||
...server.env
|
||||
},
|
||||
stderr: 'pipe'
|
||||
})
|
||||
}
|
||||
|
||||
// For DXT servers, set the working directory to the extracted path
|
||||
if (server.dxtPath) {
|
||||
transportOptions.cwd = server.dxtPath
|
||||
Logger.info(`[MCP] Setting working directory for DXT server: ${server.dxtPath}`)
|
||||
}
|
||||
|
||||
const stdioTransport = new StdioClientTransport(transportOptions)
|
||||
stdioTransport.stderr?.on('data', (data) =>
|
||||
Logger.info(`[MCP] Stdio stderr for server: ${server.name} `, data.toString())
|
||||
)
|
||||
@@ -334,6 +373,12 @@ class McpService {
|
||||
// Store the new client in the cache
|
||||
this.clients.set(serverKey, client)
|
||||
|
||||
// Set up notification handlers
|
||||
this.setupNotificationHandlers(client, server)
|
||||
|
||||
// Clear existing cache to ensure fresh data
|
||||
this.clearServerCache(serverKey)
|
||||
|
||||
Logger.info(`[MCP] Activated server: ${server.name}`)
|
||||
return client
|
||||
} catch (error: any) {
|
||||
@@ -352,6 +397,79 @@ class McpService {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up notification handlers for MCP client
|
||||
*/
|
||||
private setupNotificationHandlers(client: Client, server: MCPServer) {
|
||||
const serverKey = this.getServerKey(server)
|
||||
|
||||
try {
|
||||
// Set up tools list changed notification handler
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
||||
Logger.info(`[MCP] Tools list changed for server: ${server.name}`)
|
||||
// Clear tools cache
|
||||
CacheService.remove(`mcp:list_tool:${serverKey}`)
|
||||
})
|
||||
|
||||
// Set up resources list changed notification handler
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
|
||||
Logger.info(`[MCP] Resources list changed for server: ${server.name}`)
|
||||
// Clear resources cache
|
||||
CacheService.remove(`mcp:list_resources:${serverKey}`)
|
||||
})
|
||||
|
||||
// Set up prompts list changed notification handler
|
||||
client.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
|
||||
Logger.info(`[MCP] Prompts list changed for server: ${server.name}`)
|
||||
// Clear prompts cache
|
||||
CacheService.remove(`mcp:list_prompts:${serverKey}`)
|
||||
})
|
||||
|
||||
// Set up resource updated notification handler
|
||||
client.setNotificationHandler(ResourceUpdatedNotificationSchema, async () => {
|
||||
Logger.info(`[MCP] Resource updated for server: ${server.name}`)
|
||||
// Clear resource-specific caches
|
||||
this.clearResourceCaches(serverKey)
|
||||
})
|
||||
|
||||
// Set up progress notification handler
|
||||
client.setNotificationHandler(ProgressNotificationSchema, async (notification) => {
|
||||
Logger.info(`[MCP] Progress notification received for server: ${server.name}`, notification.params)
|
||||
})
|
||||
|
||||
// Set up cancelled notification handler
|
||||
client.setNotificationHandler(CancelledNotificationSchema, async (notification) => {
|
||||
Logger.info(`[MCP] Operation cancelled for server: ${server.name}`, notification.params)
|
||||
})
|
||||
|
||||
// Set up logging message notification handler
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, async (notification) => {
|
||||
Logger.info(`[MCP] Message from server ${server.name}:`, notification.params)
|
||||
})
|
||||
|
||||
Logger.info(`[MCP] Set up notification handlers for server: ${server.name}`)
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to set up notification handlers for server ${server.name}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear resource-specific caches for a server
|
||||
*/
|
||||
private clearResourceCaches(serverKey: string) {
|
||||
CacheService.remove(`mcp:list_resources:${serverKey}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches for a specific server
|
||||
*/
|
||||
private clearServerCache(serverKey: string) {
|
||||
CacheService.remove(`mcp:list_tool:${serverKey}`)
|
||||
CacheService.remove(`mcp:list_prompts:${serverKey}`)
|
||||
CacheService.remove(`mcp:list_resources:${serverKey}`)
|
||||
Logger.info(`[MCP] Cleared all caches for server: ${serverKey}`)
|
||||
}
|
||||
|
||||
async closeClient(serverKey: string) {
|
||||
const client = this.clients.get(serverKey)
|
||||
if (client) {
|
||||
@@ -359,8 +477,8 @@ class McpService {
|
||||
await client.close()
|
||||
Logger.info(`[MCP] Closed server: ${serverKey}`)
|
||||
this.clients.delete(serverKey)
|
||||
CacheService.remove(`mcp:list_tool:${serverKey}`)
|
||||
Logger.info(`[MCP] Cleared cache for server: ${serverKey}`)
|
||||
// Clear all caches for this server
|
||||
this.clearServerCache(serverKey)
|
||||
} else {
|
||||
Logger.warn(`[MCP] No client found for server: ${serverKey}`)
|
||||
}
|
||||
@@ -378,12 +496,26 @@ class McpService {
|
||||
if (existingClient) {
|
||||
await this.closeClient(serverKey)
|
||||
}
|
||||
|
||||
// If this is a DXT server, cleanup its directory
|
||||
if (server.dxtPath) {
|
||||
try {
|
||||
const cleaned = this.dxtService.cleanupDxtServer(server.name)
|
||||
if (cleaned) {
|
||||
Logger.info(`[MCP] Cleaned up DXT server directory for: ${server.name}`)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[MCP] Failed to cleanup DXT server: ${server.name}`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async restartServer(_: Electron.IpcMainInvokeEvent, server: MCPServer) {
|
||||
Logger.info(`[MCP] Restarting server: ${server.name}`)
|
||||
const serverKey = this.getServerKey(server)
|
||||
await this.closeClient(serverKey)
|
||||
// Clear cache before restarting to ensure fresh data
|
||||
this.clearServerCache(serverKey)
|
||||
await this.initClient(server)
|
||||
}
|
||||
|
||||
@@ -403,6 +535,12 @@ class McpService {
|
||||
public async checkMcpConnectivity(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<boolean> {
|
||||
Logger.info(`[MCP] Checking connectivity for server: ${server.name}`)
|
||||
try {
|
||||
Logger.info(`[MCP] About to call initClient for server: ${server.name}`, { hasInitClient: !!this.initClient })
|
||||
|
||||
if (!this.initClient) {
|
||||
throw new Error('initClient method is not available')
|
||||
}
|
||||
|
||||
const client = await this.initClient(server)
|
||||
// Attempt to list tools as a way to check connectivity
|
||||
await client.listTools()
|
||||
@@ -692,6 +830,31 @@ class McpService {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server version information
|
||||
*/
|
||||
public async getServerVersion(_: Electron.IpcMainInvokeEvent, server: MCPServer): Promise<string | null> {
|
||||
try {
|
||||
Logger.info(`[MCP] Getting server version for: ${server.name}`)
|
||||
const client = await this.initClient(server)
|
||||
|
||||
// Try to get server information which may include version
|
||||
const serverInfo = client.getServerVersion()
|
||||
Logger.info(`[MCP] Server info for ${server.name}:`, serverInfo)
|
||||
|
||||
if (serverInfo && serverInfo.version) {
|
||||
Logger.info(`[MCP] Server version for ${server.name}: ${serverInfo.version}`)
|
||||
return serverInfo.version
|
||||
}
|
||||
|
||||
Logger.warn(`[MCP] No version information available for server: ${server.name}`)
|
||||
return null
|
||||
} catch (error: any) {
|
||||
Logger.error(`[MCP] Failed to get server version for ${server.name}:`, error?.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new McpService()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BrowserWindow, Notification as ElectronNotification } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import icon from '../../../build/icon.png?asset'
|
||||
|
||||
class NotificationService {
|
||||
private window: BrowserWindow
|
||||
|
||||
@@ -15,8 +13,7 @@ class NotificationService {
|
||||
// 使用 Electron Notification API
|
||||
const electronNotification = new ElectronNotification({
|
||||
title: notification.title,
|
||||
body: notification.message,
|
||||
icon: icon
|
||||
body: notification.message
|
||||
})
|
||||
|
||||
electronNotification.on('click', () => {
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
import { ProxyConfig as _ProxyConfig, session } from 'electron'
|
||||
import axios from 'axios'
|
||||
import { app, ProxyConfig, session } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import { socksDispatcher } from 'fetch-socks'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import { getSystemProxy } from 'os-proxy-config'
|
||||
import { ProxyAgent as GeneralProxyAgent } from 'proxy-agent'
|
||||
// import { ProxyAgent, setGlobalDispatcher } from 'undici'
|
||||
|
||||
type ProxyMode = 'system' | 'custom' | 'none'
|
||||
|
||||
export interface ProxyConfig {
|
||||
mode: ProxyMode
|
||||
url?: string
|
||||
}
|
||||
import { ProxyAgent } from 'proxy-agent'
|
||||
import { Dispatcher, EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
|
||||
|
||||
export class ProxyManager {
|
||||
private config: ProxyConfig
|
||||
private proxyAgent: GeneralProxyAgent | null = null
|
||||
private config: ProxyConfig = { mode: 'direct' }
|
||||
private systemProxyInterval: NodeJS.Timeout | null = null
|
||||
private isSettingProxy = false
|
||||
|
||||
private originalGlobalDispatcher: Dispatcher
|
||||
private originalSocksDispatcher: Dispatcher
|
||||
// for http and https
|
||||
private originalHttpGet: typeof http.get
|
||||
private originalHttpRequest: typeof http.request
|
||||
private originalHttpsGet: typeof https.get
|
||||
private originalHttpsRequest: typeof https.request
|
||||
|
||||
constructor() {
|
||||
this.config = {
|
||||
mode: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
private async setSessionsProxy(config: _ProxyConfig): Promise<void> {
|
||||
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
|
||||
await Promise.all(sessions.map((session) => session.setProxy(config)))
|
||||
this.originalGlobalDispatcher = getGlobalDispatcher()
|
||||
this.originalSocksDispatcher = global[Symbol.for('undici.globalDispatcher.1')]
|
||||
this.originalHttpGet = http.get
|
||||
this.originalHttpRequest = http.request
|
||||
this.originalHttpsGet = https.get
|
||||
this.originalHttpsRequest = https.request
|
||||
}
|
||||
|
||||
private async monitorSystemProxy(): Promise<void> {
|
||||
// Clear any existing interval first
|
||||
this.clearSystemProxyMonitor()
|
||||
// Set new interval
|
||||
this.systemProxyInterval = setInterval(async () => {
|
||||
await this.setSystemProxy()
|
||||
}, 10000)
|
||||
this.systemProxyInterval = setInterval(
|
||||
async () => {
|
||||
const currentProxy = await getSystemProxy()
|
||||
if (currentProxy && currentProxy.proxyUrl.toLowerCase() === this.config.proxyRules) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.configureProxy({
|
||||
mode: 'system',
|
||||
proxyRules: currentProxy?.proxyUrl.toLowerCase()
|
||||
})
|
||||
},
|
||||
// 1 minutes
|
||||
1000 * 60
|
||||
)
|
||||
}
|
||||
|
||||
private clearSystemProxyMonitor(): void {
|
||||
@@ -43,99 +59,182 @@ export class ProxyManager {
|
||||
}
|
||||
|
||||
async configureProxy(config: ProxyConfig): Promise<void> {
|
||||
Logger.info('configureProxy', config.mode, config.proxyRules)
|
||||
if (this.isSettingProxy) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isSettingProxy = true
|
||||
|
||||
try {
|
||||
if (config?.mode === this.config?.mode && config?.proxyRules === this.config?.proxyRules) {
|
||||
Logger.info('proxy config is the same, skip configure')
|
||||
return
|
||||
}
|
||||
|
||||
this.config = config
|
||||
this.clearSystemProxyMonitor()
|
||||
if (this.config.mode === 'system') {
|
||||
await this.setSystemProxy()
|
||||
this.monitorSystemProxy()
|
||||
} else if (this.config.mode === 'custom') {
|
||||
await this.setCustomProxy()
|
||||
} else {
|
||||
await this.clearProxy()
|
||||
if (config.mode === 'system') {
|
||||
const currentProxy = await getSystemProxy()
|
||||
if (currentProxy) {
|
||||
Logger.info('current system proxy', currentProxy.proxyUrl)
|
||||
this.config.proxyRules = currentProxy.proxyUrl.toLowerCase()
|
||||
this.monitorSystemProxy()
|
||||
} else {
|
||||
// no system proxy, use direct mode
|
||||
this.config.mode = 'direct'
|
||||
}
|
||||
}
|
||||
|
||||
this.setGlobalProxy()
|
||||
} catch (error) {
|
||||
console.error('Failed to config proxy:', error)
|
||||
Logger.error('Failed to config proxy:', error)
|
||||
throw error
|
||||
} finally {
|
||||
this.isSettingProxy = false
|
||||
}
|
||||
}
|
||||
|
||||
private setEnvironment(url: string): void {
|
||||
if (url === '') {
|
||||
delete process.env.HTTP_PROXY
|
||||
delete process.env.HTTPS_PROXY
|
||||
delete process.env.grpc_proxy
|
||||
delete process.env.http_proxy
|
||||
delete process.env.https_proxy
|
||||
|
||||
delete process.env.SOCKS_PROXY
|
||||
delete process.env.ALL_PROXY
|
||||
return
|
||||
}
|
||||
|
||||
process.env.grpc_proxy = url
|
||||
process.env.HTTP_PROXY = url
|
||||
process.env.HTTPS_PROXY = url
|
||||
process.env.http_proxy = url
|
||||
process.env.https_proxy = url
|
||||
}
|
||||
|
||||
private async setSystemProxy(): Promise<void> {
|
||||
try {
|
||||
const currentProxy = await getSystemProxy()
|
||||
if (!currentProxy || currentProxy.proxyUrl === this.config.url) {
|
||||
return
|
||||
}
|
||||
await this.setSessionsProxy({ mode: 'system' })
|
||||
this.config.url = currentProxy.proxyUrl.toLowerCase()
|
||||
this.setEnvironment(this.config.url)
|
||||
this.proxyAgent = new GeneralProxyAgent()
|
||||
} catch (error) {
|
||||
console.error('Failed to set system proxy:', error)
|
||||
throw error
|
||||
if (url.startsWith('socks')) {
|
||||
process.env.SOCKS_PROXY = url
|
||||
process.env.ALL_PROXY = url
|
||||
}
|
||||
}
|
||||
|
||||
private async setCustomProxy(): Promise<void> {
|
||||
try {
|
||||
if (this.config.url) {
|
||||
this.setEnvironment(this.config.url)
|
||||
this.proxyAgent = new GeneralProxyAgent()
|
||||
await this.setSessionsProxy({ proxyRules: this.config.url })
|
||||
private setGlobalProxy() {
|
||||
this.setEnvironment(this.config.proxyRules || '')
|
||||
this.setGlobalFetchProxy(this.config)
|
||||
this.setSessionsProxy(this.config)
|
||||
|
||||
this.setGlobalHttpProxy(this.config)
|
||||
}
|
||||
|
||||
private setGlobalHttpProxy(config: ProxyConfig) {
|
||||
const proxyUrl = config.proxyRules
|
||||
if (config.mode === 'direct' || !proxyUrl) {
|
||||
http.get = this.originalHttpGet
|
||||
http.request = this.originalHttpRequest
|
||||
https.get = this.originalHttpsGet
|
||||
https.request = this.originalHttpsRequest
|
||||
|
||||
axios.defaults.proxy = undefined
|
||||
axios.defaults.httpAgent = undefined
|
||||
axios.defaults.httpsAgent = undefined
|
||||
return
|
||||
}
|
||||
|
||||
// ProxyAgent 从环境变量读取代理配置
|
||||
const agent = new ProxyAgent()
|
||||
|
||||
// axios 使用代理
|
||||
axios.defaults.proxy = false
|
||||
axios.defaults.httpAgent = agent
|
||||
axios.defaults.httpsAgent = agent
|
||||
|
||||
http.get = this.bindHttpMethod(this.originalHttpGet, agent)
|
||||
http.request = this.bindHttpMethod(this.originalHttpRequest, agent)
|
||||
|
||||
https.get = this.bindHttpMethod(this.originalHttpsGet, agent)
|
||||
https.request = this.bindHttpMethod(this.originalHttpsRequest, agent)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
private bindHttpMethod(originalMethod: Function, agent: http.Agent | https.Agent) {
|
||||
return (...args: any[]) => {
|
||||
let url: string | URL | undefined
|
||||
let options: http.RequestOptions | https.RequestOptions
|
||||
let callback: (res: http.IncomingMessage) => void
|
||||
|
||||
if (typeof args[0] === 'string' || args[0] instanceof URL) {
|
||||
url = args[0]
|
||||
if (typeof args[1] === 'function') {
|
||||
options = {}
|
||||
callback = args[1]
|
||||
} else {
|
||||
options = {
|
||||
...args[1]
|
||||
}
|
||||
callback = args[2]
|
||||
}
|
||||
} else {
|
||||
options = {
|
||||
...args[0]
|
||||
}
|
||||
callback = args[1]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to set custom proxy:', error)
|
||||
throw error
|
||||
|
||||
// for webdav https self-signed certificate
|
||||
if (options.agent instanceof https.Agent) {
|
||||
;(agent as https.Agent).options.rejectUnauthorized = options.agent.options.rejectUnauthorized
|
||||
}
|
||||
|
||||
// 确保只设置 agent,不修改其他网络选项
|
||||
if (!options.agent) {
|
||||
options.agent = agent
|
||||
}
|
||||
|
||||
if (url) {
|
||||
return originalMethod(url, options, callback)
|
||||
}
|
||||
return originalMethod(options, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private clearEnvironment(): void {
|
||||
delete process.env.HTTP_PROXY
|
||||
delete process.env.HTTPS_PROXY
|
||||
delete process.env.grpc_proxy
|
||||
delete process.env.http_proxy
|
||||
delete process.env.https_proxy
|
||||
private setGlobalFetchProxy(config: ProxyConfig) {
|
||||
const proxyUrl = config.proxyRules
|
||||
if (config.mode === 'direct' || !proxyUrl) {
|
||||
setGlobalDispatcher(this.originalGlobalDispatcher)
|
||||
global[Symbol.for('undici.globalDispatcher.1')] = this.originalSocksDispatcher
|
||||
return
|
||||
}
|
||||
|
||||
const url = new URL(proxyUrl)
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
setGlobalDispatcher(new EnvHttpProxyAgent())
|
||||
return
|
||||
}
|
||||
|
||||
global[Symbol.for('undici.globalDispatcher.1')] = socksDispatcher({
|
||||
port: parseInt(url.port),
|
||||
type: url.protocol === 'socks4:' ? 4 : 5,
|
||||
host: url.hostname,
|
||||
userId: url.username || undefined,
|
||||
password: url.password || undefined
|
||||
})
|
||||
}
|
||||
|
||||
private async clearProxy(): Promise<void> {
|
||||
this.clearEnvironment()
|
||||
await this.setSessionsProxy({ mode: 'direct' })
|
||||
this.config = { mode: 'none' }
|
||||
this.proxyAgent = null
|
||||
}
|
||||
private async setSessionsProxy(config: ProxyConfig): Promise<void> {
|
||||
let c = config
|
||||
|
||||
getProxyAgent(): GeneralProxyAgent | null {
|
||||
return this.proxyAgent
|
||||
}
|
||||
if (config.mode === 'direct' || !config.proxyRules) {
|
||||
c = { mode: 'direct' }
|
||||
}
|
||||
|
||||
getProxyUrl(): string {
|
||||
return this.config.url || ''
|
||||
}
|
||||
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
|
||||
await Promise.all(sessions.map((session) => session.setProxy(c)))
|
||||
|
||||
// setGlobalProxy() {
|
||||
// const proxyUrl = this.config.url
|
||||
// if (proxyUrl) {
|
||||
// const [protocol, address] = proxyUrl.split('://')
|
||||
// const [host, port] = address.split(':')
|
||||
// if (!protocol.includes('socks')) {
|
||||
// setGlobalDispatcher(new ProxyAgent(proxyUrl))
|
||||
// } else {
|
||||
// global[Symbol.for('undici.globalDispatcher.1')] = socksDispatcher({
|
||||
// port: parseInt(port),
|
||||
// type: protocol === 'socks5' ? 5 : 4,
|
||||
// host: host
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// set proxy for electron
|
||||
app.setProxy(c)
|
||||
}
|
||||
}
|
||||
|
||||
export const proxyManager = new ProxyManager()
|
||||
|
||||
@@ -1257,14 +1257,15 @@ export class SelectionService {
|
||||
|
||||
// Center of the screen
|
||||
if (!this.isFollowToolbar || !this.toolbarWindow) {
|
||||
const centerX = workArea.x + (workArea.width - actionWindowWidth) / 2
|
||||
const centerY = workArea.y + (workArea.height - actionWindowHeight) / 2
|
||||
const centerX = Math.round(workArea.x + (workArea.width - actionWindowWidth) / 2)
|
||||
const centerY = Math.round(workArea.y + (workArea.height - actionWindowHeight) / 2)
|
||||
|
||||
actionWindow.setPosition(centerX, centerY, false)
|
||||
actionWindow.setBounds({
|
||||
width: actionWindowWidth,
|
||||
height: actionWindowHeight,
|
||||
x: Math.round(centerX),
|
||||
y: Math.round(centerY)
|
||||
x: centerX,
|
||||
y: centerY
|
||||
})
|
||||
} else {
|
||||
// Follow toolbar position
|
||||
|
||||
@@ -55,7 +55,8 @@ function formatShortcutKey(shortcut: string[]): string {
|
||||
return shortcut.join('+')
|
||||
}
|
||||
|
||||
// convert the shortcut recorded by keyboard event key value to electron global shortcut format
|
||||
// convert the shortcut recorded by JS keyboard event key value to electron global shortcut format
|
||||
// see: https://www.electronjs.org/zh/docs/latest/api/accelerator
|
||||
const convertShortcutFormat = (shortcut: string | string[]): string => {
|
||||
const accelerator = (() => {
|
||||
if (Array.isArray(shortcut)) {
|
||||
@@ -68,12 +69,34 @@ const convertShortcutFormat = (shortcut: string | string[]): string => {
|
||||
return accelerator
|
||||
.map((key) => {
|
||||
switch (key) {
|
||||
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
|
||||
// case 'Command':
|
||||
// return 'CommandOrControl'
|
||||
// case 'Control':
|
||||
// return 'Control'
|
||||
// case 'Ctrl':
|
||||
// return 'Control'
|
||||
|
||||
// NEW WAY FOR MODIFIER KEYS
|
||||
// you can see all the modifier keys in the same
|
||||
case 'CommandOrControl':
|
||||
return 'CommandOrControl'
|
||||
case 'Ctrl':
|
||||
return 'Ctrl'
|
||||
case 'Alt':
|
||||
return 'Alt' // Use `Alt` instead of `Option`. The `Option` key only exists on macOS, whereas the `Alt` key is available on all platforms.
|
||||
case 'Meta':
|
||||
return 'Meta' // `Meta` key is mapped to the Windows key on Windows and Linux, `Cmd` on macOS.
|
||||
case 'Shift':
|
||||
return 'Shift'
|
||||
|
||||
// For backward compatibility with old data
|
||||
case 'Command':
|
||||
case 'Cmd':
|
||||
return 'CommandOrControl'
|
||||
case 'Control':
|
||||
return 'Control'
|
||||
case 'Ctrl':
|
||||
return 'Control'
|
||||
return 'Ctrl'
|
||||
|
||||
case 'ArrowUp':
|
||||
return 'Up'
|
||||
case 'ArrowDown':
|
||||
@@ -83,7 +106,7 @@ const convertShortcutFormat = (shortcut: string | string[]): string => {
|
||||
case 'ArrowRight':
|
||||
return 'Right'
|
||||
case 'AltGraph':
|
||||
return 'Alt'
|
||||
return 'AltGr'
|
||||
case 'Slash':
|
||||
return '/'
|
||||
case 'Semicolon':
|
||||
|
||||
@@ -23,7 +23,9 @@ export default class WebDav {
|
||||
password: params.webdavPass,
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false })
|
||||
httpsAgent: new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
})
|
||||
})
|
||||
|
||||
this.putFileContents = this.putFileContents.bind(this)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { is } from '@electron-toolkit/utils'
|
||||
import { isDev, isLinux, isMac, isWin } from '@main/constant'
|
||||
import { getFilesDir } from '@main/utils/file'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { app, BrowserWindow, nativeTheme, shell } from 'electron'
|
||||
import { app, BrowserWindow, nativeTheme, screen, shell } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import windowStateKeeper from 'electron-window-state'
|
||||
import { join } from 'path'
|
||||
@@ -16,6 +16,9 @@ import { configManager } from './ConfigManager'
|
||||
import { contextMenu } from './ContextMenu'
|
||||
import { initSessionUserAgent } from './WebviewService'
|
||||
|
||||
const DEFAULT_MINIWINDOW_WIDTH = 550
|
||||
const DEFAULT_MINIWINDOW_HEIGHT = 400
|
||||
|
||||
export class WindowService {
|
||||
private static instance: WindowService | null = null
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
@@ -26,6 +29,11 @@ export class WindowService {
|
||||
private wasMainWindowFocused: boolean = false
|
||||
private lastRendererProcessCrashTime: number = 0
|
||||
|
||||
private miniWindowSize: { width: number; height: number } = {
|
||||
width: DEFAULT_MINIWINDOW_WIDTH,
|
||||
height: DEFAULT_MINIWINDOW_HEIGHT
|
||||
}
|
||||
|
||||
public static getInstance(): WindowService {
|
||||
if (!WindowService.instance) {
|
||||
WindowService.instance = new WindowService()
|
||||
@@ -426,8 +434,8 @@ export class WindowService {
|
||||
|
||||
public createMiniWindow(isPreload: boolean = false): BrowserWindow {
|
||||
this.miniWindow = new BrowserWindow({
|
||||
width: 550,
|
||||
height: 400,
|
||||
width: this.miniWindowSize.width,
|
||||
height: this.miniWindowSize.height,
|
||||
minWidth: 350,
|
||||
minHeight: 380,
|
||||
maxWidth: 1024,
|
||||
@@ -437,13 +445,12 @@ export class WindowService {
|
||||
transparent: isMac,
|
||||
vibrancy: 'under-window',
|
||||
visualEffectState: 'followWindow',
|
||||
center: true,
|
||||
frame: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: true,
|
||||
useContentSize: true,
|
||||
...(isMac ? { type: 'panel' } : {}),
|
||||
skipTaskbar: true,
|
||||
resizable: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
@@ -485,6 +492,13 @@ export class WindowService {
|
||||
this.miniWindow?.webContents.send(IpcChannel.HideMiniWindow)
|
||||
})
|
||||
|
||||
this.miniWindow.on('resized', () => {
|
||||
this.miniWindowSize = this.miniWindow?.getBounds() || {
|
||||
width: DEFAULT_MINIWINDOW_WIDTH,
|
||||
height: DEFAULT_MINIWINDOW_HEIGHT
|
||||
}
|
||||
})
|
||||
|
||||
this.miniWindow.on('show', () => {
|
||||
this.miniWindow?.webContents.send(IpcChannel.ShowMiniWindow)
|
||||
})
|
||||
@@ -508,10 +522,48 @@ export class WindowService {
|
||||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||||
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
|
||||
|
||||
if (this.miniWindow.isMinimized()) {
|
||||
this.miniWindow.restore()
|
||||
// [Windows] hacky fix
|
||||
// the window is minimized only when in Windows platform
|
||||
// because it's a workround for Windows, see `hideMiniWindow()`
|
||||
if (this.miniWindow?.isMinimized()) {
|
||||
// don't let the window being seen before we finish adusting the position across screens
|
||||
this.miniWindow?.setOpacity(0)
|
||||
// DO NOT use `restore()` here, Electron has the bug with screens of different scale factor
|
||||
// We have to use `show()` here, then set the position and bounds
|
||||
this.miniWindow?.show()
|
||||
}
|
||||
this.miniWindow.show()
|
||||
|
||||
const miniWindowBounds = this.miniWindow.getBounds()
|
||||
|
||||
// Check if miniWindow is on the same screen as mouse cursor
|
||||
const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||
const miniWindowDisplay = screen.getDisplayNearestPoint(miniWindowBounds)
|
||||
|
||||
// Show the miniWindow on the cursor's screen center
|
||||
// If miniWindow is not on the same screen as cursor, move it to cursor's screen center
|
||||
if (cursorDisplay.id !== miniWindowDisplay.id) {
|
||||
const workArea = cursorDisplay.bounds
|
||||
|
||||
// use remembered size to avoid the bug of Electron with screens of different scale factor
|
||||
const miniWindowWidth = this.miniWindowSize.width
|
||||
const miniWindowHeight = this.miniWindowSize.height
|
||||
|
||||
// move to the center of the cursor's screen
|
||||
const miniWindowX = Math.round(workArea.x + (workArea.width - miniWindowWidth) / 2)
|
||||
const miniWindowY = Math.round(workArea.y + (workArea.height - miniWindowHeight) / 2)
|
||||
|
||||
this.miniWindow.setPosition(miniWindowX, miniWindowY, false)
|
||||
this.miniWindow.setBounds({
|
||||
x: miniWindowX,
|
||||
y: miniWindowY,
|
||||
width: miniWindowWidth,
|
||||
height: miniWindowHeight
|
||||
})
|
||||
}
|
||||
|
||||
this.miniWindow?.setOpacity(1)
|
||||
this.miniWindow?.show()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -519,20 +571,26 @@ export class WindowService {
|
||||
}
|
||||
|
||||
public hideMiniWindow() {
|
||||
//hacky-fix:[mac/win] previous window(not self-app) should be focused again after miniWindow hide
|
||||
if (!this.miniWindow || this.miniWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
//[macOs/Windows] hacky fix
|
||||
// previous window(not self-app) should be focused again after miniWindow hide
|
||||
// this workaround is to make previous window focused again after miniWindow hide
|
||||
if (isWin) {
|
||||
this.miniWindow?.minimize()
|
||||
this.miniWindow?.hide()
|
||||
this.miniWindow.setOpacity(0) // don't show the minimizing animation
|
||||
this.miniWindow.minimize()
|
||||
return
|
||||
} else if (isMac) {
|
||||
this.miniWindow?.hide()
|
||||
this.miniWindow.hide()
|
||||
if (!this.wasMainWindowFocused) {
|
||||
app.hide()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.miniWindow?.hide()
|
||||
this.miniWindow.hide()
|
||||
}
|
||||
|
||||
public closeMiniWindow() {
|
||||
|
||||
@@ -0,0 +1,829 @@
|
||||
import { Client, createClient } from '@libsql/client'
|
||||
import Embeddings from '@main/knowledge/embeddings/Embeddings'
|
||||
import type {
|
||||
AddMemoryOptions,
|
||||
AssistantMessage,
|
||||
MemoryConfig,
|
||||
MemoryHistoryItem,
|
||||
MemoryItem,
|
||||
MemoryListOptions,
|
||||
MemorySearchOptions
|
||||
} from '@types'
|
||||
import crypto from 'crypto'
|
||||
import { app } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import path from 'path'
|
||||
|
||||
import { MemoryQueries } from './queries'
|
||||
|
||||
export interface EmbeddingOptions {
|
||||
model: string
|
||||
provider: string
|
||||
apiKey: string
|
||||
apiVersion?: string
|
||||
baseURL: string
|
||||
dimensions?: number
|
||||
batchSize?: number
|
||||
}
|
||||
|
||||
export interface VectorSearchOptions {
|
||||
limit?: number
|
||||
threshold?: number
|
||||
userId?: string
|
||||
agentId?: string
|
||||
filters?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
memories: MemoryItem[]
|
||||
count: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class MemoryService {
|
||||
private static instance: MemoryService | null = null
|
||||
private db: Client | null = null
|
||||
private isInitialized = false
|
||||
private embeddings: Embeddings | null = null
|
||||
private config: MemoryConfig | null = null
|
||||
private static readonly UNIFIED_DIMENSION = 1536
|
||||
private static readonly SIMILARITY_THRESHOLD = 0.85
|
||||
|
||||
private constructor() {
|
||||
// Private constructor to enforce singleton pattern
|
||||
}
|
||||
|
||||
public static getInstance(): MemoryService {
|
||||
if (!MemoryService.instance) {
|
||||
MemoryService.instance = new MemoryService()
|
||||
}
|
||||
return MemoryService.instance
|
||||
}
|
||||
|
||||
public static reload(): MemoryService {
|
||||
if (MemoryService.instance) {
|
||||
MemoryService.instance.close()
|
||||
}
|
||||
MemoryService.instance = new MemoryService()
|
||||
return MemoryService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database connection and create tables
|
||||
*/
|
||||
private async init(): Promise<void> {
|
||||
if (this.isInitialized && this.db) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const dbPath = path.join(userDataPath, 'memories.db')
|
||||
|
||||
this.db = createClient({
|
||||
url: `file:${dbPath}`,
|
||||
intMode: 'number'
|
||||
})
|
||||
|
||||
// Create tables
|
||||
await this.createTables()
|
||||
this.isInitialized = true
|
||||
Logger.info('Memory database initialized successfully')
|
||||
} catch (error) {
|
||||
Logger.error('Failed to initialize memory database:', error)
|
||||
throw new Error(
|
||||
`Memory database initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async createTables(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
// Create memories table with native vector support
|
||||
await this.db.execute(MemoryQueries.createTables.memories)
|
||||
|
||||
// Create memory history table
|
||||
await this.db.execute(MemoryQueries.createTables.memoryHistory)
|
||||
|
||||
// Create indexes
|
||||
await this.db.execute(MemoryQueries.createIndexes.userId)
|
||||
await this.db.execute(MemoryQueries.createIndexes.agentId)
|
||||
await this.db.execute(MemoryQueries.createIndexes.createdAt)
|
||||
await this.db.execute(MemoryQueries.createIndexes.hash)
|
||||
await this.db.execute(MemoryQueries.createIndexes.memoryHistory)
|
||||
|
||||
// Create vector index for similarity search
|
||||
try {
|
||||
await this.db.execute(MemoryQueries.createIndexes.vector)
|
||||
} catch (error) {
|
||||
// Vector index might not be supported in all versions
|
||||
Logger.warn('Failed to create vector index, falling back to non-indexed search:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new memories from messages
|
||||
*/
|
||||
public async add(messages: string | AssistantMessage[], options: AddMemoryOptions): Promise<SearchResult> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const { userId, agentId, runId, metadata } = options
|
||||
|
||||
try {
|
||||
// Convert messages to memory strings
|
||||
const memoryStrings = Array.isArray(messages)
|
||||
? messages.map((m) => (typeof m === 'string' ? m : m.content))
|
||||
: [messages]
|
||||
const addedMemories: MemoryItem[] = []
|
||||
|
||||
for (const memory of memoryStrings) {
|
||||
const trimmedMemory = memory.trim()
|
||||
if (!trimmedMemory) continue
|
||||
|
||||
// Generate hash for deduplication
|
||||
const hash = crypto.createHash('sha256').update(trimmedMemory).digest('hex')
|
||||
|
||||
// Check if memory already exists
|
||||
const existing = await this.db.execute({
|
||||
sql: MemoryQueries.memory.checkExistsIncludeDeleted,
|
||||
args: [hash]
|
||||
})
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
const existingRecord = existing.rows[0] as any
|
||||
const isDeleted = existingRecord.is_deleted === 1
|
||||
|
||||
if (!isDeleted) {
|
||||
// Active record exists, skip insertion
|
||||
Logger.info(`Memory already exists with hash: ${hash}`)
|
||||
continue
|
||||
} else {
|
||||
// Deleted record exists, restore it instead of inserting new one
|
||||
Logger.info(`Restoring deleted memory with hash: ${hash}`)
|
||||
|
||||
// Generate embedding if model is configured
|
||||
let embedding: number[] | null = null
|
||||
const embedderApiClient = this.config?.embedderApiClient
|
||||
if (embedderApiClient) {
|
||||
try {
|
||||
embedding = await this.generateEmbedding(trimmedMemory)
|
||||
Logger.info(
|
||||
`Generated embedding for restored memory with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})`
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error('Failed to generate embedding for restored memory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Restore the deleted record
|
||||
await this.db.execute({
|
||||
sql: MemoryQueries.memory.restoreDeleted,
|
||||
args: [
|
||||
trimmedMemory,
|
||||
embedding ? this.embeddingToVector(embedding) : null,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
now,
|
||||
existingRecord.id
|
||||
]
|
||||
})
|
||||
|
||||
// Add to history
|
||||
await this.addHistory(existingRecord.id, null, trimmedMemory, 'ADD')
|
||||
|
||||
addedMemories.push({
|
||||
id: existingRecord.id,
|
||||
memory: trimmedMemory,
|
||||
hash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Generate embedding if model is configured
|
||||
let embedding: number[] | null = null
|
||||
if (this.config?.embedderApiClient) {
|
||||
try {
|
||||
embedding = await this.generateEmbedding(trimmedMemory)
|
||||
Logger.info(
|
||||
`Generated embedding with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})`
|
||||
)
|
||||
|
||||
// Check for similar memories using vector similarity
|
||||
const similarMemories = await this.hybridSearch(trimmedMemory, embedding, {
|
||||
limit: 5,
|
||||
threshold: 0.1, // Lower threshold to get more candidates
|
||||
userId,
|
||||
agentId
|
||||
})
|
||||
|
||||
// Check if any similar memory exceeds the similarity threshold
|
||||
if (similarMemories.memories.length > 0) {
|
||||
const highestSimilarity = Math.max(...similarMemories.memories.map((m) => m.score || 0))
|
||||
if (highestSimilarity >= MemoryService.SIMILARITY_THRESHOLD) {
|
||||
Logger.info(
|
||||
`Skipping memory addition due to high similarity: ${highestSimilarity.toFixed(3)} >= ${MemoryService.SIMILARITY_THRESHOLD}`
|
||||
)
|
||||
Logger.info(`Similar memory found: "${similarMemories.memories[0].memory}"`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to generate embedding:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new memory
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await this.db.execute({
|
||||
sql: MemoryQueries.memory.insert,
|
||||
args: [
|
||||
id,
|
||||
trimmedMemory,
|
||||
hash,
|
||||
embedding ? this.embeddingToVector(embedding) : null,
|
||||
metadata ? JSON.stringify(metadata) : null,
|
||||
userId || null,
|
||||
agentId || null,
|
||||
runId || null,
|
||||
now,
|
||||
now
|
||||
]
|
||||
})
|
||||
|
||||
// Add to history
|
||||
await this.addHistory(id, null, trimmedMemory, 'ADD')
|
||||
|
||||
addedMemories.push({
|
||||
id,
|
||||
memory: trimmedMemory,
|
||||
hash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
memories: addedMemories,
|
||||
count: addedMemories.length
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to add memories:', error)
|
||||
return {
|
||||
memories: [],
|
||||
count: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search memories using text or vector similarity
|
||||
*/
|
||||
public async search(query: string, options: MemorySearchOptions = {}): Promise<SearchResult> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const { limit = 10, userId, agentId, filters = {} } = options
|
||||
|
||||
try {
|
||||
// If we have an embedder model configured, use vector search
|
||||
if (this.config?.embedderApiClient) {
|
||||
try {
|
||||
const queryEmbedding = await this.generateEmbedding(query)
|
||||
return await this.hybridSearch(query, queryEmbedding, { limit, userId, agentId, filters })
|
||||
} catch (error) {
|
||||
Logger.error('Vector search failed, falling back to text search:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to text search
|
||||
const conditions: string[] = ['m.is_deleted = 0']
|
||||
const params: any[] = []
|
||||
|
||||
// Add search conditions
|
||||
conditions.push('(m.memory LIKE ? OR m.memory LIKE ?)')
|
||||
params.push(`%${query}%`, `%${query.split(' ').join('%')}%`)
|
||||
|
||||
if (userId) {
|
||||
conditions.push('m.user_id = ?')
|
||||
params.push(userId)
|
||||
}
|
||||
|
||||
if (agentId) {
|
||||
conditions.push('m.agent_id = ?')
|
||||
params.push(agentId)
|
||||
}
|
||||
|
||||
// Add custom filters
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
conditions.push(`json_extract(m.metadata, '$.${key}') = ?`)
|
||||
params.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ')
|
||||
params.push(limit)
|
||||
|
||||
const result = await this.db.execute({
|
||||
sql: `${MemoryQueries.memory.list} ${whereClause}
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
args: params
|
||||
})
|
||||
|
||||
const memories: MemoryItem[] = result.rows.map((row: any) => ({
|
||||
id: row.id as string,
|
||||
memory: row.memory as string,
|
||||
hash: (row.hash as string) || undefined,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string
|
||||
}))
|
||||
|
||||
return {
|
||||
memories,
|
||||
count: memories.length
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Search failed:', error)
|
||||
return {
|
||||
memories: [],
|
||||
count: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all memories with optional filters
|
||||
*/
|
||||
public async list(options: MemoryListOptions = {}): Promise<SearchResult> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const { userId, agentId, limit = 100, offset = 0 } = options
|
||||
|
||||
try {
|
||||
const conditions: string[] = ['m.is_deleted = 0']
|
||||
const params: any[] = []
|
||||
|
||||
if (userId) {
|
||||
conditions.push('m.user_id = ?')
|
||||
params.push(userId)
|
||||
}
|
||||
|
||||
if (agentId) {
|
||||
conditions.push('m.agent_id = ?')
|
||||
params.push(agentId)
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ')
|
||||
|
||||
// Get total count
|
||||
const countResult = await this.db.execute({
|
||||
sql: `${MemoryQueries.memory.count} ${whereClause}`,
|
||||
args: params
|
||||
})
|
||||
const totalCount = (countResult.rows[0] as any).total as number
|
||||
|
||||
// Get paginated results
|
||||
params.push(limit, offset)
|
||||
const result = await this.db.execute({
|
||||
sql: `${MemoryQueries.memory.list} ${whereClause}
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`,
|
||||
args: params
|
||||
})
|
||||
|
||||
const memories: MemoryItem[] = result.rows.map((row: any) => ({
|
||||
id: row.id as string,
|
||||
memory: row.memory as string,
|
||||
hash: (row.hash as string) || undefined,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string
|
||||
}))
|
||||
|
||||
return {
|
||||
memories,
|
||||
count: totalCount
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('List failed:', error)
|
||||
return {
|
||||
memories: [],
|
||||
count: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a memory (soft delete)
|
||||
*/
|
||||
public async delete(id: string): Promise<void> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
try {
|
||||
// Get current memory value for history
|
||||
const current = await this.db.execute({
|
||||
sql: MemoryQueries.memory.getForDelete,
|
||||
args: [id]
|
||||
})
|
||||
|
||||
if (current.rows.length === 0) {
|
||||
throw new Error('Memory not found')
|
||||
}
|
||||
|
||||
const currentMemory = (current.rows[0] as any).memory as string
|
||||
|
||||
// Soft delete
|
||||
await this.db.execute({
|
||||
sql: MemoryQueries.memory.softDelete,
|
||||
args: [new Date().toISOString(), id]
|
||||
})
|
||||
|
||||
// Add to history
|
||||
await this.addHistory(id, currentMemory, null, 'DELETE')
|
||||
|
||||
Logger.info(`Memory deleted: ${id}`)
|
||||
} catch (error) {
|
||||
Logger.error('Delete failed:', error)
|
||||
throw new Error(`Failed to delete memory: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a memory
|
||||
*/
|
||||
public async update(id: string, memory: string, metadata?: Record<string, any>): Promise<void> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
try {
|
||||
// Get current memory
|
||||
const current = await this.db.execute({
|
||||
sql: MemoryQueries.memory.getForUpdate,
|
||||
args: [id]
|
||||
})
|
||||
|
||||
if (current.rows.length === 0) {
|
||||
throw new Error('Memory not found')
|
||||
}
|
||||
|
||||
const row = current.rows[0] as any
|
||||
const previousMemory = row.memory as string
|
||||
const previousMetadata = row.metadata ? JSON.parse(row.metadata as string) : {}
|
||||
|
||||
// Generate new hash
|
||||
const hash = crypto.createHash('sha256').update(memory.trim()).digest('hex')
|
||||
|
||||
// Generate new embedding if model is configured
|
||||
let embedding: number[] | null = null
|
||||
if (this.config?.embedderApiClient) {
|
||||
try {
|
||||
embedding = await this.generateEmbedding(memory)
|
||||
Logger.info(
|
||||
`Updated embedding with dimension: ${embedding.length} (target: ${this.config?.embedderDimensions || MemoryService.UNIFIED_DIMENSION})`
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error('Failed to generate embedding for update:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge metadata
|
||||
const mergedMetadata = { ...previousMetadata, ...metadata }
|
||||
|
||||
// Update memory
|
||||
await this.db.execute({
|
||||
sql: MemoryQueries.memory.update,
|
||||
args: [
|
||||
memory.trim(),
|
||||
hash,
|
||||
embedding ? this.embeddingToVector(embedding) : null,
|
||||
JSON.stringify(mergedMetadata),
|
||||
new Date().toISOString(),
|
||||
id
|
||||
]
|
||||
})
|
||||
|
||||
// Add to history
|
||||
await this.addHistory(id, previousMemory, memory, 'UPDATE')
|
||||
|
||||
Logger.info(`Memory updated: ${id}`)
|
||||
} catch (error) {
|
||||
Logger.error('Update failed:', error)
|
||||
throw new Error(`Failed to update memory: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory history
|
||||
*/
|
||||
public async get(memoryId: string): Promise<MemoryHistoryItem[]> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
try {
|
||||
const result = await this.db.execute({
|
||||
sql: MemoryQueries.history.getByMemoryId,
|
||||
args: [memoryId]
|
||||
})
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
id: row.id as number,
|
||||
memoryId: row.memory_id as string,
|
||||
previousValue: row.previous_value as string | undefined,
|
||||
newValue: row.new_value as string,
|
||||
action: row.action as 'ADD' | 'UPDATE' | 'DELETE',
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
isDeleted: row.is_deleted === 1
|
||||
}))
|
||||
} catch (error) {
|
||||
Logger.error('Get history failed:', error)
|
||||
throw new Error(`Failed to get memory history: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all memories for a user without deleting the user (hard delete)
|
||||
*/
|
||||
public async deleteAllMemoriesForUser(userId: string): Promise<void> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User ID is required')
|
||||
}
|
||||
|
||||
try {
|
||||
// Get count of memories to be deleted
|
||||
const countResult = await this.db.execute({
|
||||
sql: MemoryQueries.users.countMemoriesForUser,
|
||||
args: [userId]
|
||||
})
|
||||
const totalCount = (countResult.rows[0] as any).total as number
|
||||
|
||||
// Delete history entries for this user's memories
|
||||
await this.db.execute({
|
||||
sql: MemoryQueries.users.deleteHistoryForUser,
|
||||
args: [userId]
|
||||
})
|
||||
|
||||
// Hard delete all memories for this user
|
||||
await this.db.execute({
|
||||
sql: MemoryQueries.users.deleteAllMemoriesForUser,
|
||||
args: [userId]
|
||||
})
|
||||
|
||||
Logger.info(`Reset all memories for user ${userId} (${totalCount} memories deleted)`)
|
||||
} catch (error) {
|
||||
Logger.error('Reset user memories failed:', error)
|
||||
throw new Error(`Failed to reset user memories: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user and all their memories (hard delete)
|
||||
*/
|
||||
public async deleteUser(userId: string): Promise<void> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('User ID is required')
|
||||
}
|
||||
|
||||
if (userId === 'default-user') {
|
||||
throw new Error('Cannot delete the default user')
|
||||
}
|
||||
|
||||
try {
|
||||
// Get count of memories to be deleted
|
||||
const countResult = await this.db.execute({
|
||||
sql: `SELECT COUNT(*) as total FROM memories WHERE user_id = ?`,
|
||||
args: [userId]
|
||||
})
|
||||
const totalCount = (countResult.rows[0] as any).total as number
|
||||
|
||||
// Delete history entries for this user's memories
|
||||
await this.db.execute({
|
||||
sql: `DELETE FROM memory_history WHERE memory_id IN (SELECT id FROM memories WHERE user_id = ?)`,
|
||||
args: [userId]
|
||||
})
|
||||
|
||||
// Delete all memories for this user (hard delete)
|
||||
await this.db.execute({
|
||||
sql: `DELETE FROM memories WHERE user_id = ?`,
|
||||
args: [userId]
|
||||
})
|
||||
|
||||
Logger.info(`Deleted user ${userId} and ${totalCount} memories`)
|
||||
} catch (error) {
|
||||
Logger.error('Delete user failed:', error)
|
||||
throw new Error(`Failed to delete user: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of unique user IDs with their memory counts
|
||||
*/
|
||||
public async getUsersList(): Promise<{ userId: string; memoryCount: number; lastMemoryDate: string }[]> {
|
||||
await this.init()
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
try {
|
||||
const result = await this.db.execute({
|
||||
sql: MemoryQueries.users.getUniqueUsers,
|
||||
args: []
|
||||
})
|
||||
|
||||
return result.rows.map((row: any) => ({
|
||||
userId: row.user_id as string,
|
||||
memoryCount: row.memory_count as number,
|
||||
lastMemoryDate: row.last_memory_date as string
|
||||
}))
|
||||
} catch (error) {
|
||||
Logger.error('Get users list failed:', error)
|
||||
throw new Error(`Failed to get users list: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
public setConfig(config: MemoryConfig): void {
|
||||
this.config = config
|
||||
// Reset embeddings instance when config changes
|
||||
this.embeddings = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Close database connection
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (this.db) {
|
||||
await this.db.close()
|
||||
this.db = null
|
||||
this.isInitialized = false
|
||||
}
|
||||
}
|
||||
|
||||
// ========== EMBEDDING OPERATIONS (Previously EmbeddingService) ==========
|
||||
|
||||
/**
|
||||
* Normalize embedding dimensions to unified size
|
||||
*/
|
||||
private normalizeEmbedding(embedding: number[]): number[] {
|
||||
if (embedding.length === MemoryService.UNIFIED_DIMENSION) {
|
||||
return embedding
|
||||
}
|
||||
|
||||
if (embedding.length < MemoryService.UNIFIED_DIMENSION) {
|
||||
// Pad with zeros
|
||||
return [...embedding, ...new Array(MemoryService.UNIFIED_DIMENSION - embedding.length).fill(0)]
|
||||
} else {
|
||||
// Truncate
|
||||
return embedding.slice(0, MemoryService.UNIFIED_DIMENSION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embedding for text
|
||||
*/
|
||||
private async generateEmbedding(text: string): Promise<number[]> {
|
||||
if (!this.config?.embedderApiClient) {
|
||||
throw new Error('Embedder model not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
// Initialize embeddings instance if needed
|
||||
if (!this.embeddings) {
|
||||
if (!this.config.embedderApiClient) {
|
||||
throw new Error('Embedder provider not configured')
|
||||
}
|
||||
|
||||
this.embeddings = new Embeddings({
|
||||
embedApiClient: this.config.embedderApiClient,
|
||||
dimensions: this.config.embedderDimensions
|
||||
})
|
||||
await this.embeddings.init()
|
||||
}
|
||||
|
||||
const embedding = await this.embeddings.embedQuery(text)
|
||||
|
||||
// Normalize to unified dimension
|
||||
return this.normalizeEmbedding(embedding)
|
||||
} catch (error) {
|
||||
Logger.error('Embedding generation failed:', error)
|
||||
throw new Error(`Failed to generate embedding: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== VECTOR SEARCH OPERATIONS (Previously VectorSearch) ==========
|
||||
|
||||
/**
|
||||
* Convert embedding array to libsql vector format
|
||||
*/
|
||||
private embeddingToVector(embedding: number[]): string {
|
||||
return `[${embedding.join(',')}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid search combining text and vector similarity (currently vector-only)
|
||||
*/
|
||||
private async hybridSearch(
|
||||
_: string,
|
||||
queryEmbedding: number[],
|
||||
options: VectorSearchOptions = {}
|
||||
): Promise<SearchResult> {
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const { limit = 10, threshold = 0.5, userId } = options
|
||||
|
||||
try {
|
||||
const queryVector = this.embeddingToVector(queryEmbedding)
|
||||
|
||||
const conditions: string[] = ['m.is_deleted = 0']
|
||||
const params: any[] = []
|
||||
|
||||
// Vector search only - three vector parameters for distance, vector_similarity, and combined_score
|
||||
params.push(queryVector, queryVector, queryVector)
|
||||
|
||||
if (userId) {
|
||||
conditions.push('m.user_id = ?')
|
||||
params.push(userId)
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(' AND ')
|
||||
|
||||
const hybridQuery = `${MemoryQueries.search.hybridSearch} ${whereClause}
|
||||
) AS results
|
||||
WHERE vector_similarity >= ?
|
||||
ORDER BY vector_similarity DESC
|
||||
LIMIT ?`
|
||||
|
||||
params.push(threshold, limit)
|
||||
|
||||
const result = await this.db.execute({
|
||||
sql: hybridQuery,
|
||||
args: params
|
||||
})
|
||||
|
||||
const memories: MemoryItem[] = result.rows.map((row: any) => ({
|
||||
id: row.id as string,
|
||||
memory: row.memory as string,
|
||||
hash: (row.hash as string) || undefined,
|
||||
metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined,
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string,
|
||||
score: row.vector_similarity as number
|
||||
}))
|
||||
|
||||
return {
|
||||
memories,
|
||||
count: memories.length
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Hybrid search failed:', error)
|
||||
throw new Error(`Hybrid search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== HELPER METHODS ==========
|
||||
|
||||
/**
|
||||
* Add entry to memory history
|
||||
*/
|
||||
private async addHistory(
|
||||
memoryId: string,
|
||||
previousValue: string | null,
|
||||
newValue: string | null,
|
||||
action: 'ADD' | 'UPDATE' | 'DELETE'
|
||||
): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized')
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await this.db.execute({
|
||||
sql: MemoryQueries.history.insert,
|
||||
args: [memoryId, previousValue, newValue, action, now, now]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default MemoryService
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* SQL queries for MemoryService
|
||||
* All SQL queries are centralized here for better maintainability
|
||||
*/
|
||||
|
||||
export const MemoryQueries = {
|
||||
// Table creation queries
|
||||
createTables: {
|
||||
memories: `
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
memory TEXT NOT NULL,
|
||||
hash TEXT UNIQUE,
|
||||
embedding F32_BLOB(1536), -- Native vector column (1536 dimensions for OpenAI embeddings)
|
||||
metadata TEXT, -- JSON string
|
||||
user_id TEXT,
|
||||
agent_id TEXT,
|
||||
run_id TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
`,
|
||||
|
||||
memoryHistory: `
|
||||
CREATE TABLE IF NOT EXISTS memory_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
memory_id TEXT NOT NULL,
|
||||
previous_value TEXT,
|
||||
new_value TEXT,
|
||||
action TEXT NOT NULL, -- ADD, UPDATE, DELETE
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (memory_id) REFERENCES memories (id)
|
||||
)
|
||||
`
|
||||
},
|
||||
|
||||
// Index creation queries
|
||||
createIndexes: {
|
||||
userId: 'CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id)',
|
||||
agentId: 'CREATE INDEX IF NOT EXISTS idx_memories_agent_id ON memories(agent_id)',
|
||||
createdAt: 'CREATE INDEX IF NOT EXISTS idx_memories_created_at ON memories(created_at)',
|
||||
hash: 'CREATE INDEX IF NOT EXISTS idx_memories_hash ON memories(hash)',
|
||||
memoryHistory: 'CREATE INDEX IF NOT EXISTS idx_memory_history_memory_id ON memory_history(memory_id)',
|
||||
vector: 'CREATE INDEX IF NOT EXISTS idx_memories_vector ON memories (libsql_vector_idx(embedding))'
|
||||
},
|
||||
|
||||
// Memory operations
|
||||
memory: {
|
||||
checkExists: 'SELECT id FROM memories WHERE hash = ? AND is_deleted = 0',
|
||||
|
||||
checkExistsIncludeDeleted: 'SELECT id, is_deleted FROM memories WHERE hash = ?',
|
||||
|
||||
restoreDeleted: `
|
||||
UPDATE memories
|
||||
SET is_deleted = 0, memory = ?, embedding = ?, metadata = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
|
||||
insert: `
|
||||
INSERT INTO memories (id, memory, hash, embedding, metadata, user_id, agent_id, run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
|
||||
getForDelete: 'SELECT memory FROM memories WHERE id = ? AND is_deleted = 0',
|
||||
|
||||
softDelete: 'UPDATE memories SET is_deleted = 1, updated_at = ? WHERE id = ?',
|
||||
|
||||
getForUpdate: 'SELECT memory, metadata FROM memories WHERE id = ? AND is_deleted = 0',
|
||||
|
||||
update: `
|
||||
UPDATE memories
|
||||
SET memory = ?, hash = ?, embedding = ?, metadata = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
|
||||
count: 'SELECT COUNT(*) as total FROM memories m WHERE',
|
||||
|
||||
list: `
|
||||
SELECT
|
||||
m.id,
|
||||
m.memory,
|
||||
m.hash,
|
||||
m.metadata,
|
||||
m.user_id,
|
||||
m.agent_id,
|
||||
m.run_id,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM memories m
|
||||
WHERE
|
||||
`
|
||||
},
|
||||
|
||||
// History operations
|
||||
history: {
|
||||
insert: `
|
||||
INSERT INTO memory_history (memory_id, previous_value, new_value, action, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
|
||||
getByMemoryId: `
|
||||
SELECT * FROM memory_history
|
||||
WHERE memory_id = ? AND is_deleted = 0
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
},
|
||||
|
||||
// Search operations
|
||||
search: {
|
||||
hybridSearch: `
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
m.id,
|
||||
m.memory,
|
||||
m.hash,
|
||||
m.metadata,
|
||||
m.user_id,
|
||||
m.agent_id,
|
||||
m.run_id,
|
||||
m.created_at,
|
||||
m.updated_at,
|
||||
CASE
|
||||
WHEN m.embedding IS NULL THEN 2.0
|
||||
ELSE vector_distance_cos(m.embedding, vector32(?))
|
||||
END as distance,
|
||||
CASE
|
||||
WHEN m.embedding IS NULL THEN 0.0
|
||||
ELSE (1 - vector_distance_cos(m.embedding, vector32(?)))
|
||||
END as vector_similarity,
|
||||
0.0 as text_similarity,
|
||||
(
|
||||
CASE
|
||||
WHEN m.embedding IS NULL THEN 0.0
|
||||
ELSE (1 - vector_distance_cos(m.embedding, vector32(?)))
|
||||
END
|
||||
) as combined_score
|
||||
FROM memories m
|
||||
WHERE
|
||||
`
|
||||
},
|
||||
|
||||
// User operations
|
||||
users: {
|
||||
getUniqueUsers: `
|
||||
SELECT DISTINCT
|
||||
user_id,
|
||||
COUNT(*) as memory_count,
|
||||
MAX(created_at) as last_memory_date
|
||||
FROM memories
|
||||
WHERE user_id IS NOT NULL AND is_deleted = 0
|
||||
GROUP BY user_id
|
||||
ORDER BY last_memory_date DESC
|
||||
`,
|
||||
|
||||
countMemoriesForUser: 'SELECT COUNT(*) as total FROM memories WHERE user_id = ?',
|
||||
|
||||
deleteAllMemoriesForUser: 'DELETE FROM memories WHERE user_id = ?',
|
||||
|
||||
deleteHistoryForUser: 'DELETE FROM memory_history WHERE memory_id IN (SELECT id FROM memories WHERE user_id = ?)'
|
||||
}
|
||||
} as const
|
||||
@@ -3,6 +3,17 @@ import Logger from 'electron-log'
|
||||
|
||||
import { windowService } from '../WindowService'
|
||||
|
||||
function ParseData(data: string) {
|
||||
try {
|
||||
const result = JSON.parse(Buffer.from(data, 'base64').toString('utf-8'))
|
||||
|
||||
return JSON.stringify(result)
|
||||
} catch (error) {
|
||||
Logger.error('ParseData error:', { error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleProvidersProtocolUrl(url: URL) {
|
||||
switch (url.pathname) {
|
||||
case '/api-keys': {
|
||||
@@ -19,7 +30,13 @@ export async function handleProvidersProtocolUrl(url: URL) {
|
||||
// replace + and / to _ and - because + and / are processed by URLSearchParams
|
||||
const processedSearch = url.search.replaceAll('+', '_').replaceAll('/', '-')
|
||||
const params = new URLSearchParams(processedSearch)
|
||||
const data = params.get('data')
|
||||
const data = ParseData(params.get('data')?.replaceAll('_', '+').replaceAll('-', '/') || '')
|
||||
|
||||
if (!data) {
|
||||
Logger.error('handleProvidersProtocolUrl data is null or invalid')
|
||||
return
|
||||
}
|
||||
|
||||
const mainWindow = windowService.getMainWindow()
|
||||
const version = params.get('v')
|
||||
if (version == '1') {
|
||||
@@ -33,7 +50,9 @@ export async function handleProvidersProtocolUrl(url: URL) {
|
||||
!mainWindow.isDestroyed() &&
|
||||
(await mainWindow.webContents.executeJavaScript(`typeof window.navigate === 'function'`))
|
||||
) {
|
||||
mainWindow.webContents.executeJavaScript(`window.navigate('/settings/provider?addProviderData=${data}')`)
|
||||
mainWindow.webContents.executeJavaScript(
|
||||
`window.navigate('/settings/provider?addProviderData=${encodeURIComponent(data)}')`
|
||||
)
|
||||
|
||||
if (isMac) {
|
||||
windowService.showMainWindow()
|
||||
|
||||
@@ -207,6 +207,10 @@ export function getAppConfigDir(name: string) {
|
||||
return path.join(getConfigDir(), name)
|
||||
}
|
||||
|
||||
export function getMcpDir() {
|
||||
return path.join(os.homedir(), '.cherrystudio', 'mcp')
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件内容并自动检测编码格式进行解码
|
||||
* @param filePath - 文件路径
|
||||
|
||||
+27
-1
@@ -3,12 +3,17 @@ import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import {
|
||||
AddMemoryOptions,
|
||||
AssistantMessage,
|
||||
FileListResponse,
|
||||
FileMetadata,
|
||||
FileUploadResponse,
|
||||
KnowledgeBaseParams,
|
||||
KnowledgeItem,
|
||||
MCPServer,
|
||||
MemoryConfig,
|
||||
MemoryListOptions,
|
||||
MemorySearchOptions,
|
||||
Provider,
|
||||
S3Config,
|
||||
Shortcut,
|
||||
@@ -184,6 +189,22 @@ const api = {
|
||||
checkQuota: ({ base, userId }: { base: KnowledgeBaseParams; userId: string }) =>
|
||||
ipcRenderer.invoke(IpcChannel.KnowledgeBase_Check_Quota, base, userId)
|
||||
},
|
||||
memory: {
|
||||
add: (messages: string | AssistantMessage[], options?: AddMemoryOptions) =>
|
||||
ipcRenderer.invoke(IpcChannel.Memory_Add, messages, options),
|
||||
search: (query: string, options: MemorySearchOptions) =>
|
||||
ipcRenderer.invoke(IpcChannel.Memory_Search, query, options),
|
||||
list: (options?: MemoryListOptions) => ipcRenderer.invoke(IpcChannel.Memory_List, options),
|
||||
delete: (id: string) => ipcRenderer.invoke(IpcChannel.Memory_Delete, id),
|
||||
update: (id: string, memory: string, metadata?: Record<string, any>) =>
|
||||
ipcRenderer.invoke(IpcChannel.Memory_Update, id, memory, metadata),
|
||||
get: (id: string) => ipcRenderer.invoke(IpcChannel.Memory_Get, id),
|
||||
setConfig: (config: MemoryConfig) => ipcRenderer.invoke(IpcChannel.Memory_SetConfig, config),
|
||||
deleteUser: (userId: string) => ipcRenderer.invoke(IpcChannel.Memory_DeleteUser, userId),
|
||||
deleteAllMemoriesForUser: (userId: string) =>
|
||||
ipcRenderer.invoke(IpcChannel.Memory_DeleteAllMemoriesForUser, userId),
|
||||
getUsersList: () => ipcRenderer.invoke(IpcChannel.Memory_GetUsersList)
|
||||
},
|
||||
window: {
|
||||
setMinimumSize: (width: number, height: number) =>
|
||||
ipcRenderer.invoke(IpcChannel.Windows_SetMinimumSize, width, height),
|
||||
@@ -240,8 +261,13 @@ const api = {
|
||||
ipcRenderer.invoke(IpcChannel.Mcp_GetResource, { server, uri }),
|
||||
getInstallInfo: () => ipcRenderer.invoke(IpcChannel.Mcp_GetInstallInfo),
|
||||
checkMcpConnectivity: (server: any) => ipcRenderer.invoke(IpcChannel.Mcp_CheckConnectivity, server),
|
||||
uploadDxt: async (file: File) => {
|
||||
const buffer = await file.arrayBuffer()
|
||||
return ipcRenderer.invoke(IpcChannel.Mcp_UploadDxt, buffer, file.name)
|
||||
},
|
||||
abortTool: (callId: string) => ipcRenderer.invoke(IpcChannel.Mcp_AbortTool, callId),
|
||||
setProgress: (progress: number) => ipcRenderer.invoke(IpcChannel.Mcp_SetProgress, progress)
|
||||
setProgress: (progress: number) => ipcRenderer.invoke(IpcChannel.Mcp_SetProgress, progress),
|
||||
getServerVersion: (server: MCPServer) => ipcRenderer.invoke(IpcChannel.Mcp_GetServerVersion, server)
|
||||
},
|
||||
python: {
|
||||
execute: (script: string, context?: Record<string, any>, timeout?: number) =>
|
||||
|
||||
@@ -103,7 +103,12 @@ export class AihubmixAPIClient extends BaseApiClient {
|
||||
}
|
||||
|
||||
// gemini开头 且不以-nothink、-search结尾
|
||||
if ((id.startsWith('gemini') || id.startsWith('imagen')) && !id.endsWith('-nothink') && !id.endsWith('-search')) {
|
||||
if (
|
||||
(id.startsWith('gemini') || id.startsWith('imagen')) &&
|
||||
!id.endsWith('-nothink') &&
|
||||
!id.endsWith('-search') &&
|
||||
!id.includes('embedding')
|
||||
) {
|
||||
const client = this.clients.get('gemini')
|
||||
if (!client || !this.isValidClient(client)) {
|
||||
throw new Error('Gemini client not properly initialized')
|
||||
|
||||
@@ -72,6 +72,7 @@ export class ApiClientFactory {
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenAIProvider(provider: Provider) {
|
||||
return !['anthropic', 'gemini'].includes(provider.type)
|
||||
}
|
||||
// 移除这个函数,它已经移动到 utils/index.ts
|
||||
// export function isOpenAIProvider(provider: Provider) {
|
||||
// return !['anthropic', 'gemini'].includes(provider.type)
|
||||
// }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
isFunctionCallingModel,
|
||||
isNotSupportTemperatureAndTopP,
|
||||
isOpenAIDeepResearchModel,
|
||||
isOpenAIModel,
|
||||
isSupportedFlexServiceTier
|
||||
} from '@renderer/config/models'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
MCPCallToolResponse,
|
||||
MCPTool,
|
||||
MCPToolResponse,
|
||||
MemoryItem,
|
||||
Model,
|
||||
OpenAIServiceTier,
|
||||
Provider,
|
||||
@@ -37,7 +39,7 @@ import {
|
||||
} from '@renderer/types/sdk'
|
||||
import { isJSON, parseJSON } from '@renderer/utils'
|
||||
import { addAbortController, removeAbortController } from '@renderer/utils/abortController'
|
||||
import { findFileBlocks, getContentWithTools, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { defaultTimeout } from '@shared/config/constant'
|
||||
import Logger from 'electron-log/renderer'
|
||||
import { isEmpty } from 'lodash'
|
||||
@@ -205,11 +207,14 @@ export abstract class BaseApiClient<
|
||||
if (isSupportedFlexServiceTier(model)) {
|
||||
return 15 * 1000 * 60
|
||||
}
|
||||
if (isOpenAIDeepResearchModel(model)) {
|
||||
return 60 * 1000 * 60
|
||||
}
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
public async getMessageContent(message: Message): Promise<string> {
|
||||
const content = getContentWithTools(message)
|
||||
const content = getMainTextContent(message)
|
||||
|
||||
if (isEmpty(content)) {
|
||||
return ''
|
||||
@@ -217,6 +222,7 @@ export abstract class BaseApiClient<
|
||||
|
||||
const webSearchReferences = await this.getWebSearchReferencesFromCache(message)
|
||||
const knowledgeReferences = await this.getKnowledgeBaseReferencesFromCache(message)
|
||||
const memoryReferences = this.getMemoryReferencesFromCache(message)
|
||||
|
||||
// 添加偏移量以避免ID冲突
|
||||
const reindexedKnowledgeReferences = knowledgeReferences.map((ref) => ({
|
||||
@@ -224,7 +230,7 @@ export abstract class BaseApiClient<
|
||||
id: ref.id + webSearchReferences.length // 为知识库引用的ID添加网络搜索引用的数量作为偏移量
|
||||
}))
|
||||
|
||||
const allReferences = [...webSearchReferences, ...reindexedKnowledgeReferences]
|
||||
const allReferences = [...webSearchReferences, ...reindexedKnowledgeReferences, ...memoryReferences]
|
||||
|
||||
Logger.log(`Found ${allReferences.length} references for ID: ${message.id}`, allReferences)
|
||||
|
||||
@@ -266,6 +272,20 @@ export abstract class BaseApiClient<
|
||||
return ''
|
||||
}
|
||||
|
||||
private getMemoryReferencesFromCache(message: Message) {
|
||||
const memories = window.keyv.get(`memory-search-${message.id}`) as MemoryItem[] | undefined
|
||||
if (memories) {
|
||||
const memoryReferences: KnowledgeReference[] = memories.map((mem, index) => ({
|
||||
id: index + 1,
|
||||
content: `${mem.memory} -- Created at: ${mem.createdAt}`,
|
||||
sourceUrl: '',
|
||||
type: 'memory'
|
||||
}))
|
||||
return memoryReferences
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private async getWebSearchReferencesFromCache(message: Message) {
|
||||
const content = getMainTextContent(message)
|
||||
if (isEmpty(content)) {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Provider } from '@renderer/types'
|
||||
import { isOpenAIProvider } from '@renderer/utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AihubmixAPIClient } from '../AihubmixAPIClient'
|
||||
import { AnthropicAPIClient } from '../anthropic/AnthropicAPIClient'
|
||||
import { ApiClientFactory } from '../ApiClientFactory'
|
||||
import { GeminiAPIClient } from '../gemini/GeminiAPIClient'
|
||||
import { VertexAPIClient } from '../gemini/VertexAPIClient'
|
||||
import { NewAPIClient } from '../NewAPIClient'
|
||||
import { OpenAIAPIClient } from '../openai/OpenAIApiClient'
|
||||
import { OpenAIResponseAPIClient } from '../openai/OpenAIResponseAPIClient'
|
||||
import { PPIOAPIClient } from '../ppio/PPIOAPIClient'
|
||||
|
||||
// 为工厂测试创建最小化 provider 的辅助函数
|
||||
// ApiClientFactory 只使用 'id' 和 'type' 字段来决定创建哪个客户端
|
||||
// 其他字段会传递给客户端构造函数,但不影响工厂逻辑
|
||||
const createTestProvider = (id: string, type: string): Provider => ({
|
||||
id,
|
||||
type: type as Provider['type'],
|
||||
name: '',
|
||||
apiKey: '',
|
||||
apiHost: '',
|
||||
models: []
|
||||
})
|
||||
|
||||
// Mock 所有客户端模块
|
||||
vi.mock('../AihubmixAPIClient', () => ({
|
||||
AihubmixAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
vi.mock('../anthropic/AnthropicAPIClient', () => ({
|
||||
AnthropicAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
vi.mock('../gemini/GeminiAPIClient', () => ({
|
||||
GeminiAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
vi.mock('../gemini/VertexAPIClient', () => ({
|
||||
VertexAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
vi.mock('../NewAPIClient', () => ({
|
||||
NewAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
vi.mock('../openai/OpenAIApiClient', () => ({
|
||||
OpenAIAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
vi.mock('../openai/OpenAIResponseAPIClient', () => ({
|
||||
OpenAIResponseAPIClient: vi.fn().mockImplementation(() => ({
|
||||
getClient: vi.fn().mockReturnThis()
|
||||
}))
|
||||
}))
|
||||
vi.mock('../ppio/PPIOAPIClient', () => ({
|
||||
PPIOAPIClient: vi.fn().mockImplementation(() => ({}))
|
||||
}))
|
||||
|
||||
describe('ApiClientFactory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
// 测试特殊 ID 的客户端创建
|
||||
it('should create AihubmixAPIClient for aihubmix provider', () => {
|
||||
const provider = createTestProvider('aihubmix', 'openai')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(AihubmixAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create NewAPIClient for new-api provider', () => {
|
||||
const provider = createTestProvider('new-api', 'openai')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(NewAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create PPIOAPIClient for ppio provider', () => {
|
||||
const provider = createTestProvider('ppio', 'openai')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(PPIOAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
// 测试标准类型的客户端创建
|
||||
it('should create OpenAIAPIClient for openai type', () => {
|
||||
const provider = createTestProvider('custom-openai', 'openai')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(OpenAIAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create OpenAIResponseAPIClient for azure-openai type', () => {
|
||||
const provider = createTestProvider('azure-openai', 'azure-openai')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(OpenAIResponseAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create OpenAIResponseAPIClient for openai-response type', () => {
|
||||
const provider = createTestProvider('response', 'openai-response')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(OpenAIResponseAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create GeminiAPIClient for gemini type', () => {
|
||||
const provider = createTestProvider('gemini', 'gemini')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(GeminiAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create VertexAPIClient for vertexai type', () => {
|
||||
const provider = createTestProvider('vertex', 'vertexai')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(VertexAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create AnthropicAPIClient for anthropic type', () => {
|
||||
const provider = createTestProvider('anthropic', 'anthropic')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(AnthropicAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
// 测试默认情况
|
||||
it('should create OpenAIAPIClient as default for unknown type', () => {
|
||||
const provider = createTestProvider('unknown', 'unknown-type')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(OpenAIAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
// 测试边界条件
|
||||
it('should handle provider with minimal configuration', () => {
|
||||
const provider = createTestProvider('minimal', 'openai')
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
expect(OpenAIAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
|
||||
// 测试特殊 ID 优先级高于类型
|
||||
it('should prioritize special ID over type', () => {
|
||||
const provider = createTestProvider('aihubmix', 'anthropic') // 即使类型是 anthropic
|
||||
|
||||
const client = ApiClientFactory.create(provider)
|
||||
|
||||
// 应该创建 AihubmixAPIClient 而不是 AnthropicAPIClient
|
||||
expect(AihubmixAPIClient).toHaveBeenCalledWith(provider)
|
||||
expect(AnthropicAPIClient).not.toHaveBeenCalled()
|
||||
expect(client).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenAIProvider', () => {
|
||||
it('should return true for openai type', () => {
|
||||
const provider = createTestProvider('openai', 'openai')
|
||||
expect(isOpenAIProvider(provider)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for azure-openai type', () => {
|
||||
const provider = createTestProvider('azure-openai', 'azure-openai')
|
||||
expect(isOpenAIProvider(provider)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for unknown type (fallback to OpenAI)', () => {
|
||||
const provider = createTestProvider('unknown', 'unknown')
|
||||
expect(isOpenAIProvider(provider)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for vertexai type', () => {
|
||||
const provider = createTestProvider('vertex', 'vertexai')
|
||||
expect(isOpenAIProvider(provider)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for anthropic type', () => {
|
||||
const provider = createTestProvider('anthropic', 'anthropic')
|
||||
expect(isOpenAIProvider(provider)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for gemini type', () => {
|
||||
const provider = createTestProvider('gemini', 'gemini')
|
||||
expect(isOpenAIProvider(provider)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -524,9 +524,18 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
switch (rawChunk.type) {
|
||||
case 'message': {
|
||||
let i = 0
|
||||
let hasTextContent = false
|
||||
let hasThinkingContent = false
|
||||
|
||||
for (const content of rawChunk.content) {
|
||||
switch (content.type) {
|
||||
case 'text': {
|
||||
if (!hasTextContent) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_START
|
||||
} as TextStartChunk)
|
||||
hasTextContent = true
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_DELTA,
|
||||
text: content.text
|
||||
@@ -539,6 +548,12 @@ export class AnthropicAPIClient extends BaseApiClient<
|
||||
break
|
||||
}
|
||||
case 'thinking': {
|
||||
if (!hasThinkingContent) {
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_START
|
||||
} as ThinkingStartChunk)
|
||||
hasThinkingContent = true
|
||||
}
|
||||
controller.enqueue({
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: content.thinking
|
||||
|
||||
@@ -443,7 +443,7 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
messages: GeminiSdkMessageParam[]
|
||||
metadata: Record<string, any>
|
||||
}> => {
|
||||
const { messages, mcpTools, maxTokens, enableWebSearch, enableGenerateImage } = coreRequest
|
||||
const { messages, mcpTools, maxTokens, enableWebSearch, enableUrlContext, enableGenerateImage } = coreRequest
|
||||
// 1. 处理系统消息
|
||||
let systemInstruction = assistant.prompt
|
||||
|
||||
@@ -483,6 +483,12 @@ export class GeminiAPIClient extends BaseApiClient<
|
||||
})
|
||||
}
|
||||
|
||||
if (enableUrlContext) {
|
||||
tools.push({
|
||||
urlContext: {}
|
||||
})
|
||||
}
|
||||
|
||||
if (isGemmaModel(model) && assistant.prompt) {
|
||||
const isFirstMessage = history.length === 0
|
||||
if (isFirstMessage && messageContents) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GEMINI_FLASH_MODEL_REGEX,
|
||||
getOpenAIWebSearchParams,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isQwenReasoningModel,
|
||||
isReasoningModel,
|
||||
isSupportedReasoningEffortGrokModel,
|
||||
isSupportedReasoningEffortModel,
|
||||
@@ -114,7 +115,11 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
if (!reasoningEffort) {
|
||||
if (model.provider === 'openrouter') {
|
||||
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
if (
|
||||
isSupportedThinkingTokenGeminiModel(model) &&
|
||||
!GEMINI_FLASH_MODEL_REGEX.test(model.id) &&
|
||||
model.id.includes('grok-4')
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
@@ -166,10 +171,17 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// Qwen models
|
||||
if (isSupportedThinkingTokenQwenModel(model)) {
|
||||
return {
|
||||
const thinkConfig = {
|
||||
enable_thinking: true,
|
||||
thinking_budget: budgetTokens
|
||||
}
|
||||
if (this.provider.id === 'dashscope') {
|
||||
return {
|
||||
...thinkConfig,
|
||||
incremental_output: true
|
||||
}
|
||||
}
|
||||
return thinkConfig
|
||||
}
|
||||
|
||||
// Grok models
|
||||
@@ -359,7 +371,12 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
if ('toolUseId' in mcpToolResponse && mcpToolResponse.toolUseId) {
|
||||
// This case is for Anthropic/Claude like tool usage, OpenAI uses tool_call_id
|
||||
// For OpenAI, we primarily expect toolCallId. This might need adjustment if mixing provider concepts.
|
||||
return mcpToolCallResponseToOpenAICompatibleMessage(mcpToolResponse, resp, isVisionModel(model))
|
||||
return mcpToolCallResponseToOpenAICompatibleMessage(
|
||||
mcpToolResponse,
|
||||
resp,
|
||||
isVisionModel(model),
|
||||
this.provider.isNotSupportArrayContent ?? false
|
||||
)
|
||||
} else if ('toolCallId' in mcpToolResponse && mcpToolResponse.toolCallId) {
|
||||
return {
|
||||
role: 'tool',
|
||||
@@ -436,7 +453,14 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
messages: OpenAISdkMessageParam[]
|
||||
metadata: Record<string, any>
|
||||
}> => {
|
||||
const { messages, mcpTools, maxTokens, streamOutput, enableWebSearch } = coreRequest
|
||||
const { messages, mcpTools, maxTokens, enableWebSearch } = coreRequest
|
||||
let { streamOutput } = coreRequest
|
||||
|
||||
// Qwen3商业版(思考模式)、Qwen3开源版、QwQ、QVQ只支持流式输出。
|
||||
if (isQwenReasoningModel(model)) {
|
||||
streamOutput = true
|
||||
}
|
||||
|
||||
// 1. 处理系统消息
|
||||
let systemMessage = { role: 'system', content: assistant.prompt || '' }
|
||||
|
||||
@@ -679,15 +703,29 @@ export class OpenAIAPIClient extends OpenAIBaseClient<
|
||||
|
||||
// 对于流式响应,使用 delta;对于非流式响应,使用 message。
|
||||
// 然而某些 OpenAI 兼容平台在非流式请求时会错误地返回一个空对象的 delta 字段。
|
||||
// 如果 delta 为空对象,应当忽略它并回退到 message,避免造成内容缺失。
|
||||
// 如果 delta 为空对象或content为空,应当忽略它并回退到 message,避免造成内容缺失。
|
||||
let contentSource: OpenAISdkRawContentSource | null = null
|
||||
if ('delta' in choice && choice.delta && Object.keys(choice.delta).length > 0) {
|
||||
if (
|
||||
'delta' in choice &&
|
||||
choice.delta &&
|
||||
Object.keys(choice.delta).length > 0 &&
|
||||
(!('content' in choice.delta) ||
|
||||
(typeof choice.delta.content === 'string' && choice.delta.content !== '') ||
|
||||
(typeof (choice.delta as any).reasoning_content === 'string' &&
|
||||
(choice.delta as any).reasoning_content !== '') ||
|
||||
(typeof (choice.delta as any).reasoning === 'string' && (choice.delta as any).reasoning !== ''))
|
||||
) {
|
||||
contentSource = choice.delta
|
||||
} else if ('message' in choice) {
|
||||
contentSource = choice.message
|
||||
}
|
||||
|
||||
if (!contentSource) continue
|
||||
if (!contentSource) {
|
||||
if ('finish_reason' in choice && choice.finish_reason) {
|
||||
emitCompletionSignals(controller)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const webSearchData = collectWebSearchData(chunk, contentSource, context)
|
||||
if (webSearchData) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GenericChunk } from '@renderer/aiCore/middleware/schemas'
|
||||
import { CompletionsContext } from '@renderer/aiCore/middleware/types'
|
||||
import {
|
||||
isOpenAIChatCompletionOnlyModel,
|
||||
isOpenAIDeepResearchModel,
|
||||
isOpenAILLMModel,
|
||||
isSupportedReasoningEffortOpenAIModel,
|
||||
isVisionModel
|
||||
@@ -61,13 +62,34 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
this.client = new OpenAIAPIClient(provider)
|
||||
}
|
||||
|
||||
private formatApiHost() {
|
||||
const host = this.provider.apiHost
|
||||
if (host.endsWith('/openai/v1')) {
|
||||
return host
|
||||
} else {
|
||||
if (host.endsWith('/')) {
|
||||
return host + 'openai/v1'
|
||||
} else {
|
||||
return host + '/openai/v1'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据模型特征选择合适的客户端
|
||||
*/
|
||||
public getClient(model: Model) {
|
||||
if (this.provider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
return this
|
||||
}
|
||||
if (isOpenAILLMModel(model) && !isOpenAIChatCompletionOnlyModel(model)) {
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiVersion: 'preview' }
|
||||
this.provider = { ...this.provider, apiHost: this.formatApiHost() }
|
||||
if (this.provider.apiVersion === 'preview') {
|
||||
return this
|
||||
} else {
|
||||
return this.client
|
||||
}
|
||||
}
|
||||
return this
|
||||
} else {
|
||||
@@ -81,7 +103,6 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
}
|
||||
|
||||
if (this.provider.id === 'azure-openai' || this.provider.type === 'azure-openai') {
|
||||
this.provider = { ...this.provider, apiHost: `${this.provider.apiHost}/openai/v1` }
|
||||
return new AzureOpenAI({
|
||||
dangerouslyAllowBrowser: true,
|
||||
apiKey: this.apiKey,
|
||||
@@ -386,7 +407,7 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient<
|
||||
reqMessages = [systemMessage, ...userMessage].filter(Boolean) as OpenAI.Responses.EasyInputMessage[]
|
||||
}
|
||||
|
||||
if (enableWebSearch) {
|
||||
if (enableWebSearch || isOpenAIDeepResearchModel(model)) {
|
||||
tools.push({
|
||||
type: 'web_search_preview'
|
||||
})
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface ResponseChunkTransformerContext {
|
||||
isStreaming: boolean
|
||||
isEnabledToolCalling: boolean
|
||||
isEnabledWebSearch: boolean
|
||||
isEnabledUrlContext: boolean
|
||||
isEnabledReasoning: boolean
|
||||
mcpTools: MCPTool[]
|
||||
provider: Provider
|
||||
|
||||
@@ -99,6 +99,10 @@ export default class AiProvider {
|
||||
if (params.callType !== 'chat') {
|
||||
builder.remove(AbortHandlerMiddlewareName)
|
||||
}
|
||||
if (params.callType === 'test') {
|
||||
builder.remove(ErrorHandlerMiddlewareName)
|
||||
builder.remove(FinalChunkConsumerMiddlewareName)
|
||||
}
|
||||
}
|
||||
|
||||
const middlewares = builder.build()
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ResponseTransformMiddleware: CompletionsMiddleware =
|
||||
isStreaming: params.streamOutput || false,
|
||||
isEnabledToolCalling: (params.mcpTools && params.mcpTools.length > 0) || false,
|
||||
isEnabledWebSearch: params.enableWebSearch || false,
|
||||
isEnabledUrlContext: params.enableUrlContext || false,
|
||||
isEnabledReasoning: params.enableReasoning || false,
|
||||
mcpTools: params.mcpTools || [],
|
||||
provider: ctx.apiClientInstance?.provider
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Logger from '@renderer/config/logger'
|
||||
import { ChunkType, TextDeltaChunk } from '@renderer/types/chunk'
|
||||
import { ChunkType } from '@renderer/types/chunk'
|
||||
|
||||
import { CompletionsParams, CompletionsResult, GenericChunk } from '../schemas'
|
||||
import { CompletionsContext, CompletionsMiddleware } from '../types'
|
||||
@@ -42,32 +42,32 @@ export const TextChunkMiddleware: CompletionsMiddleware =
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
if (chunk.type === ChunkType.TEXT_DELTA) {
|
||||
const textChunk = chunk as TextDeltaChunk
|
||||
accumulatedTextContent += textChunk.text
|
||||
accumulatedTextContent += chunk.text
|
||||
|
||||
// 处理 onResponse 回调 - 发送增量文本更新
|
||||
if (params.onResponse) {
|
||||
params.onResponse(accumulatedTextContent, false)
|
||||
}
|
||||
|
||||
// 创建新的chunk,包含处理后的文本
|
||||
controller.enqueue(chunk)
|
||||
controller.enqueue({
|
||||
...chunk,
|
||||
text: accumulatedTextContent // 增量更新
|
||||
})
|
||||
} else if (accumulatedTextContent && chunk.type !== ChunkType.TEXT_START) {
|
||||
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
const finalText = accumulatedTextContent
|
||||
ctx._internal.customState!.accumulatedText = finalText
|
||||
if (ctx._internal.toolProcessingState && !ctx._internal.toolProcessingState?.output) {
|
||||
ctx._internal.toolProcessingState.output = finalText
|
||||
}
|
||||
ctx._internal.customState!.accumulatedText = accumulatedTextContent
|
||||
if (ctx._internal.toolProcessingState && !ctx._internal.toolProcessingState?.output) {
|
||||
ctx._internal.toolProcessingState.output = accumulatedTextContent
|
||||
}
|
||||
|
||||
if (chunk.type === ChunkType.LLM_RESPONSE_COMPLETE) {
|
||||
// 处理 onResponse 回调 - 发送最终完整文本
|
||||
if (params.onResponse) {
|
||||
params.onResponse(finalText, true)
|
||||
params.onResponse(accumulatedTextContent, true)
|
||||
}
|
||||
|
||||
controller.enqueue({
|
||||
type: ChunkType.TEXT_COMPLETE,
|
||||
text: finalText
|
||||
text: accumulatedTextContent
|
||||
})
|
||||
controller.enqueue(chunk)
|
||||
} else {
|
||||
|
||||
@@ -62,6 +62,7 @@ export const ThinkChunkMiddleware: CompletionsMiddleware =
|
||||
// 更新思考时间并传递
|
||||
const enhancedChunk: ThinkingDeltaChunk = {
|
||||
...thinkingChunk,
|
||||
text: accumulatedThinkingContent,
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
}
|
||||
controller.enqueue(enhancedChunk)
|
||||
|
||||
@@ -66,7 +66,7 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
let thinkingStartTime = 0
|
||||
|
||||
let isFirstTextChunk = true
|
||||
|
||||
let accumulatedThinkingContent = ''
|
||||
const processedStream = resultFromUpstream.pipeThrough(
|
||||
new TransformStream<GenericChunk, GenericChunk>({
|
||||
transform(chunk: GenericChunk, controller) {
|
||||
@@ -101,9 +101,10 @@ export const ThinkingTagExtractionMiddleware: CompletionsMiddleware =
|
||||
}
|
||||
|
||||
if (extractionResult.content?.trim()) {
|
||||
accumulatedThinkingContent += extractionResult.content
|
||||
const thinkingDeltaChunk: ThinkingDeltaChunk = {
|
||||
type: ChunkType.THINKING_DELTA,
|
||||
text: extractionResult.content,
|
||||
text: accumulatedThinkingContent,
|
||||
thinking_millsec: thinkingStartTime > 0 ? Date.now() - thinkingStartTime : 0
|
||||
}
|
||||
controller.enqueue(thinkingDeltaChunk)
|
||||
|
||||
@@ -79,7 +79,6 @@ function createToolUseExtractionTransform(
|
||||
toolCounter += toolUseResponses.length
|
||||
|
||||
if (toolUseResponses.length > 0) {
|
||||
controller.enqueue({ type: ChunkType.TEXT_COMPLETE, text: '' })
|
||||
// 生成 MCP_TOOL_CREATED chunk
|
||||
const mcpToolCreatedChunk: MCPToolCreatedChunk = {
|
||||
type: ChunkType.MCP_TOOL_CREATED,
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface CompletionsParams {
|
||||
* 'generate': 生成
|
||||
* 'check': API检查
|
||||
*/
|
||||
callType?: 'chat' | 'translate' | 'summary' | 'search' | 'generate' | 'check'
|
||||
callType?: 'chat' | 'translate' | 'summary' | 'search' | 'generate' | 'check' | 'test'
|
||||
|
||||
// 基础对话数据
|
||||
messages: Message[] | string // 联合类型方便判断是否为空
|
||||
@@ -49,6 +49,7 @@ export interface CompletionsParams {
|
||||
// 功能开关
|
||||
streamOutput: boolean
|
||||
enableWebSearch?: boolean
|
||||
enableUrlContext?: boolean
|
||||
enableReasoning?: boolean
|
||||
enableGenerateImage?: boolean
|
||||
|
||||
|
||||
@@ -136,17 +136,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse {
|
||||
.ant-collapse:not(.ant-collapse-ghost) {
|
||||
border: 1px solid var(--color-border);
|
||||
.ant-color-picker & {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: 0.5px solid var(--color-border) !important;
|
||||
.ant-color-picker & {
|
||||
border-top: none !important;
|
||||
.ant-collapse-content {
|
||||
border-top: 0.5px solid var(--color-border) !important;
|
||||
.ant-color-picker & {
|
||||
border-top: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
margin: 1.5em 0 1em 0;
|
||||
line-height: 1.3;
|
||||
font-weight: bold;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -124,7 +123,7 @@
|
||||
pre {
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Fira Code', 'Courier New', Courier, monospace;
|
||||
font-family: var(--code-font-family);
|
||||
background-color: var(--color-background-mute);
|
||||
&:has(.special-preview) {
|
||||
background-color: transparent;
|
||||
@@ -188,7 +187,6 @@
|
||||
th {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-family);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,100 @@ import styled, { keyframes } from 'styled-components'
|
||||
|
||||
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
|
||||
|
||||
const HTML_VOID_ELEMENTS = new Set([
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
])
|
||||
|
||||
const HTML_COMPLETION_PATTERNS = [
|
||||
/<\/html\s*>/i,
|
||||
/<!DOCTYPE\s+html/i,
|
||||
/<\/body\s*>/i,
|
||||
/<\/div\s*>/i,
|
||||
/<\/script\s*>/i,
|
||||
/<\/style\s*>/i
|
||||
]
|
||||
|
||||
interface Props {
|
||||
html: string
|
||||
}
|
||||
|
||||
function hasUnmatchedTags(html: string): boolean {
|
||||
const stack: string[] = []
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
let match
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const [fullTag, tagName] = match
|
||||
const isClosing = fullTag.startsWith('</')
|
||||
const isSelfClosing = fullTag.endsWith('/>') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
|
||||
|
||||
if (isSelfClosing) continue
|
||||
|
||||
if (isClosing) {
|
||||
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
stack.push(tagName.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length > 0
|
||||
}
|
||||
|
||||
function checkIsStreaming(html: string): boolean {
|
||||
if (!html?.trim()) return false
|
||||
|
||||
const trimmed = html.trim()
|
||||
|
||||
// 快速检查:如果有明显的完成标志,直接返回false
|
||||
for (const pattern of HTML_COMPLETION_PATTERNS) {
|
||||
if (pattern.test(trimmed)) {
|
||||
// 特殊情况:同时有DOCTYPE和</body>
|
||||
if (trimmed.includes('<!DOCTYPE') && /<\/body\s*>/i.test(trimmed)) {
|
||||
return false
|
||||
}
|
||||
// 如果只是以</html>结尾,也认为是完成的
|
||||
if (/<\/html\s*>$/i.test(trimmed)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查未完成的标志
|
||||
const hasIncompleteTag = /<[^>]*$/.test(trimmed)
|
||||
const hasUnmatched = hasUnmatchedTags(trimmed)
|
||||
|
||||
if (hasIncompleteTag || hasUnmatched) return true
|
||||
|
||||
// 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成
|
||||
const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed)
|
||||
if (!hasStructureTags && trimmed.length < 500) {
|
||||
return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const getTerminalStyles = (theme: ThemeMode) => ({
|
||||
background: theme === 'dark' ? '#1e1e1e' : '#f0f0f0',
|
||||
color: theme === 'dark' ? '#cccccc' : '#333333',
|
||||
promptColor: theme === 'dark' ? '#00ff00' : '#007700'
|
||||
})
|
||||
|
||||
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'HTML Artifacts'
|
||||
@@ -23,151 +113,20 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
|
||||
const htmlContent = html || ''
|
||||
const hasContent = htmlContent.trim().length > 0
|
||||
const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent])
|
||||
|
||||
// 判断是否正在流式生成的逻辑
|
||||
const isStreaming = useMemo(() => {
|
||||
if (!hasContent) return false
|
||||
|
||||
const trimmedHtml = htmlContent.trim()
|
||||
|
||||
// 提前检查:如果包含关键的结束标签,直接判断为完整文档
|
||||
if (/<\/html\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果同时包含 DOCTYPE 和 </body>,通常也是完整文档
|
||||
if (/<!DOCTYPE\s+html/i.test(trimmedHtml) && /<\/body\s*>/i.test(trimmedHtml)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 HTML 是否看起来是完整的
|
||||
const indicators = {
|
||||
// 1. 检查常见的 HTML 结构完整性
|
||||
hasHtmlTag: /<html[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
|
||||
|
||||
// 2. 检查 body 标签完整性
|
||||
hasBodyTag: /<body[^>]*>/i.test(trimmedHtml),
|
||||
hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml),
|
||||
|
||||
// 3. 检查是否以未闭合的标签结尾
|
||||
endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml),
|
||||
|
||||
// 4. 检查是否有未配对的标签
|
||||
hasUnmatchedTags: checkUnmatchedTags(trimmedHtml),
|
||||
|
||||
// 5. 检查是否以常见的"流式结束"模式结尾
|
||||
endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml)
|
||||
}
|
||||
|
||||
// 如果有明显的未完成标志,则认为正在生成
|
||||
if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 HTML 结构但不完整
|
||||
if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果有 body 结构但不完整
|
||||
if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 对于简单的 HTML 片段,检查是否看起来是完整的
|
||||
if (!indicators.hasHtmlTag && !indicators.hasBodyTag) {
|
||||
// 如果是简单片段且没有明显的结束标志,可能还在生成
|
||||
return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500
|
||||
}
|
||||
|
||||
return false
|
||||
}, [htmlContent, hasContent])
|
||||
|
||||
// 检查未配对标签的辅助函数
|
||||
function checkUnmatchedTags(html: string): boolean {
|
||||
const stack: string[] = []
|
||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||
|
||||
// HTML5 void 元素(自闭合元素)的完整列表
|
||||
const voidElements = [
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
]
|
||||
|
||||
let match
|
||||
|
||||
while ((match = tagRegex.exec(html)) !== null) {
|
||||
const [fullTag, tagName] = match
|
||||
const isClosing = fullTag.startsWith('</')
|
||||
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
|
||||
|
||||
if (isSelfClosing) continue
|
||||
|
||||
if (isClosing) {
|
||||
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
||||
return true // 找到不匹配的闭合标签
|
||||
}
|
||||
} else {
|
||||
stack.push(tagName.toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length > 0 // 还有未闭合的标签
|
||||
}
|
||||
|
||||
// 获取格式化的代码预览
|
||||
function getFormattedCodePreview(html: string): string {
|
||||
const trimmed = html.trim()
|
||||
const lines = trimmed.split('\n')
|
||||
const lastFewLines = lines.slice(-3) // 显示最后3行
|
||||
return lastFewLines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 在编辑器中打开
|
||||
*/
|
||||
const handleOpenInEditor = () => {
|
||||
setIsPopupOpen(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
const handleClosePopup = () => {
|
||||
setIsPopupOpen(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 外部链接打开
|
||||
*/
|
||||
const handleOpenExternal = async () => {
|
||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||
await window.api.file.write(path, htmlContent)
|
||||
const filePath = `file://${path}`
|
||||
|
||||
if (window.api.shell && window.api.shell.openExternal) {
|
||||
if (window.api.shell?.openExternal) {
|
||||
window.api.shell.openExternal(filePath)
|
||||
} else {
|
||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载到本地
|
||||
*/
|
||||
const handleDownload = async () => {
|
||||
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
||||
await window.api.file.save(fileName, htmlContent)
|
||||
@@ -202,27 +161,27 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
<TerminalLine>
|
||||
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
|
||||
<TerminalCodeLine $theme={theme}>
|
||||
{getFormattedCodePreview(htmlContent)}
|
||||
{htmlContent.trim().split('\n').slice(-3).join('\n')}
|
||||
<TerminalCursor $theme={theme} />
|
||||
</TerminalCodeLine>
|
||||
</TerminalLine>
|
||||
</TerminalContent>
|
||||
</TerminalPreview>
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
|
||||
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="primary">
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
</>
|
||||
) : (
|
||||
<ButtonContainer>
|
||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
|
||||
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="text" disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.preview')}
|
||||
</Button>
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
|
||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
|
||||
{t('chat.artifacts.button.openExternal')}
|
||||
</Button>
|
||||
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
|
||||
<Button icon={<Download size={16} />} onClick={handleDownload} type="text" disabled={!hasContent}>
|
||||
{t('code_block.download')}
|
||||
</Button>
|
||||
</ButtonContainer>
|
||||
@@ -230,21 +189,11 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||
</Content>
|
||||
</Container>
|
||||
|
||||
{/* 弹窗组件 */}
|
||||
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
|
||||
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={() => setIsPopupOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const shimmer = keyframes`
|
||||
0% {
|
||||
background-position: -200px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: calc(200px + 100%) 0;
|
||||
}
|
||||
`
|
||||
|
||||
const Container = styled.div<{ $isStreaming: boolean }>`
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -274,21 +223,7 @@ const Header = styled.div`
|
||||
padding: 20px 24px 16px;
|
||||
background: var(--color-background-soft);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
border-radius: 8px 8px 0 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
|
||||
background-size: 200% 100%;
|
||||
animation: ${shimmer} 3s ease-in-out infinite;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||
@@ -297,18 +232,15 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||
background: ${(props) =>
|
||||
props.$isStreaming
|
||||
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
|
||||
: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'};
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
||||
box-shadow: ${(props) =>
|
||||
props.$isStreaming ? '0 4px 6px -1px rgba(245, 158, 11, 0.3)' : '0 4px 6px -1px rgba(59, 130, 246, 0.3)'};
|
||||
transition: background 0.3s ease;
|
||||
|
||||
${(props) =>
|
||||
props.$isStreaming &&
|
||||
`
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */
|
||||
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
|
||||
`}
|
||||
`
|
||||
|
||||
const TitleSection = styled.div`
|
||||
@@ -346,7 +278,7 @@ const Content = styled.div`
|
||||
`
|
||||
|
||||
const ButtonContainer = styled.div`
|
||||
margin: 16px !important;
|
||||
margin: 10px 16px !important;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
@@ -354,7 +286,7 @@ const ButtonContainer = styled.div`
|
||||
|
||||
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||
margin: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
background: ${(props) => getTerminalStyles(props.$theme).background};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||
@@ -362,8 +294,8 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||
|
||||
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
|
||||
padding: 12px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
background: ${(props) => getTerminalStyles(props.$theme).background};
|
||||
color: ${(props) => getTerminalStyles(props.$theme).color};
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
min-height: 80px;
|
||||
@@ -379,25 +311,27 @@ const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
||||
color: ${(props) => getTerminalStyles(props.$theme).color};
|
||||
background-color: transparent !important;
|
||||
`
|
||||
|
||||
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
|
||||
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
color: ${(props) => getTerminalStyles(props.$theme).promptColor};
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
const blinkAnimation = keyframes`
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
`
|
||||
|
||||
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
||||
animation: ${keyframes`
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
`} 1s infinite;
|
||||
background: ${(props) => getTerminalStyles(props.$theme).promptColor};
|
||||
animation: ${blinkAnimation} 1s infinite;
|
||||
margin-left: 2px;
|
||||
`
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import CodeEditor from '@renderer/components/CodeEditor'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { Button, Modal } from 'antd'
|
||||
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -16,140 +16,41 @@ interface HtmlArtifactsPopupProps {
|
||||
|
||||
type ViewMode = 'split' | 'code' | 'preview'
|
||||
|
||||
// 视图模式配置
|
||||
const VIEW_MODE_CONFIG = {
|
||||
split: {
|
||||
key: 'split' as const,
|
||||
icon: MonitorSpeaker,
|
||||
i18nKey: 'html_artifacts.split'
|
||||
},
|
||||
code: {
|
||||
key: 'code' as const,
|
||||
icon: Code,
|
||||
i18nKey: 'html_artifacts.code'
|
||||
},
|
||||
preview: {
|
||||
key: 'preview' as const,
|
||||
icon: Monitor,
|
||||
i18nKey: 'html_artifacts.preview'
|
||||
}
|
||||
} as const
|
||||
|
||||
// 抽取头部组件
|
||||
interface ModalHeaderProps {
|
||||
title: string
|
||||
isFullscreen: boolean
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onToggleFullscreen: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
|
||||
title,
|
||||
isFullscreen,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
onToggleFullscreen,
|
||||
onCancel
|
||||
}) => {
|
||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
||||
const { t } = useTranslation()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [currentHtml, setCurrentHtml] = useState(html)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
const viewButtons = useMemo(() => {
|
||||
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
|
||||
<ViewButton
|
||||
key={key}
|
||||
size="small"
|
||||
type={viewMode === key ? 'primary' : 'default'}
|
||||
icon={<Icon size={14} />}
|
||||
onClick={() => onViewModeChange(key)}>
|
||||
{t(i18nKey)}
|
||||
</ViewButton>
|
||||
))
|
||||
}, [viewMode, onViewModeChange, t])
|
||||
|
||||
return (
|
||||
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
<HeaderCenter>
|
||||
<ViewControls>{viewButtons}</ViewControls>
|
||||
</HeaderCenter>
|
||||
<HeaderRight>
|
||||
<Button
|
||||
onClick={onToggleFullscreen}
|
||||
type="text"
|
||||
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
className="nodrag"
|
||||
/>
|
||||
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
|
||||
</HeaderRight>
|
||||
</ModalHeader>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取代码编辑器组件
|
||||
interface CodeSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
onCodeChange: (code: string) => void
|
||||
}
|
||||
|
||||
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<CodeSection $visible={visible}>
|
||||
<CodeEditorWrapper>
|
||||
<CodeEditor
|
||||
value={html}
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={onCodeChange}
|
||||
style={{ height: '100%' }}
|
||||
options={{
|
||||
stream: false,
|
||||
collapsible: false
|
||||
}}
|
||||
/>
|
||||
</CodeEditorWrapper>
|
||||
</CodeSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 抽取预览组件
|
||||
interface PreviewSectionProps {
|
||||
html: string
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
|
||||
const htmlContent = html || ''
|
||||
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
|
||||
// 预览刷新相关状态
|
||||
const [previewHtml, setPreviewHtml] = useState(html)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const latestHtmlRef = useRef(htmlContent)
|
||||
const currentRenderedHtmlRef = useRef(htmlContent)
|
||||
const { t } = useTranslation()
|
||||
const latestHtmlRef = useRef(html)
|
||||
|
||||
// 更新最新的HTML内容引用
|
||||
// 当外部html更新时,同步更新内部状态
|
||||
useEffect(() => {
|
||||
latestHtmlRef.current = htmlContent
|
||||
}, [htmlContent])
|
||||
setCurrentHtml(html)
|
||||
latestHtmlRef.current = html
|
||||
}, [html])
|
||||
|
||||
// 固定频率渲染 HTML 内容,每2秒钟检查并更新一次
|
||||
// 当内部编辑的html更新时,更新引用
|
||||
useEffect(() => {
|
||||
// 立即设置初始内容
|
||||
setDebouncedHtml(htmlContent)
|
||||
currentRenderedHtmlRef.current = htmlContent
|
||||
latestHtmlRef.current = currentHtml
|
||||
}, [currentHtml])
|
||||
|
||||
// 2秒定时检查并刷新预览(仅在内容变化时)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
// 立即设置初始预览内容
|
||||
setPreviewHtml(currentHtml)
|
||||
|
||||
// 设置定时器,每2秒检查一次内容是否有变化
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
|
||||
setDebouncedHtml(latestHtmlRef.current)
|
||||
currentRenderedHtmlRef.current = latestHtmlRef.current
|
||||
if (latestHtmlRef.current !== previewHtml) {
|
||||
setPreviewHtml(latestHtmlRef.current)
|
||||
}
|
||||
}, 2000) // 2秒固定频率
|
||||
}, 2000)
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
@@ -157,150 +58,164 @@ const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
}
|
||||
}, []) // 只在组件挂载时执行一次
|
||||
}, [open, previewHtml])
|
||||
|
||||
if (!visible) return null
|
||||
const isHtmlEmpty = !debouncedHtml.trim()
|
||||
|
||||
return (
|
||||
<PreviewSection $visible={visible}>
|
||||
{isHtmlEmpty ? (
|
||||
<EmptyPreview>
|
||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||
</EmptyPreview>
|
||||
) : (
|
||||
<PreviewFrame
|
||||
key={debouncedHtml} // 强制重新创建iframe当内容变化时
|
||||
srcDoc={debouncedHtml}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
)}
|
||||
</PreviewSection>
|
||||
)
|
||||
}
|
||||
|
||||
// 主弹窗组件
|
||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||
const [currentHtml, setCurrentHtml] = useState(html)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
|
||||
// 当外部html更新时,同步更新内部状态
|
||||
// 全屏时防止 body 滚动
|
||||
useEffect(() => {
|
||||
setCurrentHtml(html)
|
||||
}, [html])
|
||||
if (!open || !isFullscreen) return
|
||||
|
||||
// 计算视图可见性
|
||||
const viewVisibility = useMemo(
|
||||
() => ({
|
||||
code: viewMode === 'split' || viewMode === 'code',
|
||||
preview: viewMode === 'split' || viewMode === 'preview'
|
||||
}),
|
||||
[viewMode]
|
||||
const body = document.body
|
||||
const originalOverflow = body.style.overflow
|
||||
body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
body.style.overflow = originalOverflow
|
||||
}
|
||||
}, [isFullscreen, open])
|
||||
|
||||
const showCode = viewMode === 'split' || viewMode === 'code'
|
||||
const showPreview = viewMode === 'split' || viewMode === 'preview'
|
||||
|
||||
const renderHeader = () => (
|
||||
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
|
||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||
<TitleText>{title}</TitleText>
|
||||
</HeaderLeft>
|
||||
|
||||
<HeaderCenter>
|
||||
<ViewControls>
|
||||
<ViewButton
|
||||
size="small"
|
||||
type={viewMode === 'split' ? 'primary' : 'default'}
|
||||
icon={<MonitorSpeaker size={14} />}
|
||||
onClick={() => setViewMode('split')}>
|
||||
{t('html_artifacts.split')}
|
||||
</ViewButton>
|
||||
<ViewButton
|
||||
size="small"
|
||||
type={viewMode === 'code' ? 'primary' : 'default'}
|
||||
icon={<Code size={14} />}
|
||||
onClick={() => setViewMode('code')}>
|
||||
{t('html_artifacts.code')}
|
||||
</ViewButton>
|
||||
<ViewButton
|
||||
size="small"
|
||||
type={viewMode === 'preview' ? 'primary' : 'default'}
|
||||
icon={<Monitor size={14} />}
|
||||
onClick={() => setViewMode('preview')}>
|
||||
{t('html_artifacts.preview')}
|
||||
</ViewButton>
|
||||
</ViewControls>
|
||||
</HeaderCenter>
|
||||
|
||||
<HeaderRight $isFullscreen={isFullscreen}>
|
||||
<Button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
type="text"
|
||||
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||
className="nodrag"
|
||||
/>
|
||||
<Button onClick={onClose} type="text" icon={<X size={16} />} className="nodrag" />
|
||||
</HeaderRight>
|
||||
</ModalHeader>
|
||||
)
|
||||
|
||||
// 计算Modal属性
|
||||
const modalProps = useMemo(
|
||||
() => ({
|
||||
width: isFullscreen ? '100vw' : '90vw',
|
||||
height: isFullscreen ? '100vh' : 'auto',
|
||||
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
|
||||
}),
|
||||
[isFullscreen]
|
||||
)
|
||||
|
||||
const handleOk = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleCodeChange = useCallback((newCode: string) => {
|
||||
setCurrentHtml(newCode)
|
||||
}, [])
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
setIsFullscreen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
setViewMode(mode)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
$isFullscreen={isFullscreen}
|
||||
title={
|
||||
<ModalHeaderComponent
|
||||
title={title}
|
||||
isFullscreen={isFullscreen}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
}
|
||||
title={renderHeader()}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
afterClose={handleClose}
|
||||
centered
|
||||
afterClose={onClose}
|
||||
centered={!isFullscreen}
|
||||
destroyOnClose
|
||||
{...modalProps}
|
||||
mask={!isFullscreen}
|
||||
maskClosable={false}
|
||||
width={isFullscreen ? '100vw' : '90vw'}
|
||||
style={{
|
||||
maxWidth: isFullscreen ? '100vw' : '1400px',
|
||||
height: isFullscreen ? '100vh' : 'auto'
|
||||
}}
|
||||
zIndex={isFullscreen ? 10000 : 1000}
|
||||
footer={null}
|
||||
closable={false}>
|
||||
<Container>
|
||||
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
|
||||
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
|
||||
{showCode && (
|
||||
<CodeSection>
|
||||
<CodeEditor
|
||||
value={currentHtml}
|
||||
language="html"
|
||||
editable={true}
|
||||
onSave={setCurrentHtml}
|
||||
style={{ height: '100%' }}
|
||||
options={{
|
||||
stream: false,
|
||||
collapsible: false
|
||||
}}
|
||||
/>
|
||||
</CodeSection>
|
||||
)}
|
||||
|
||||
{showPreview && (
|
||||
<PreviewSection>
|
||||
{previewHtml.trim() ? (
|
||||
<PreviewFrame
|
||||
key={previewHtml} // 强制重新创建iframe当预览内容变化时
|
||||
srcDoc={previewHtml}
|
||||
title="HTML Preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
) : (
|
||||
<EmptyPreview>
|
||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||
</EmptyPreview>
|
||||
)}
|
||||
</PreviewSection>
|
||||
)}
|
||||
</Container>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
||||
// 样式组件保持不变
|
||||
const commonModalBodyStyles = `
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
`
|
||||
|
||||
// 简化的样式组件
|
||||
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
${(props) =>
|
||||
props.$isFullscreen
|
||||
? `
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
z-index: 10000 !important;
|
||||
|
||||
.ant-modal-wrap {
|
||||
padding: 0 !important;
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
max-width: none !important;
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
height: calc(100vh - 45px) !important;
|
||||
${commonModalBodyStyles}
|
||||
max-height: initial !important;
|
||||
}
|
||||
`
|
||||
: `
|
||||
.ant-modal-body {
|
||||
height: 80vh !important;
|
||||
${commonModalBodyStyles}
|
||||
min-height: 600px !important;
|
||||
}
|
||||
`}
|
||||
|
||||
.ant-modal-body {
|
||||
${commonModalBodyStyles}
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
max-height: initial !important;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
@@ -311,16 +226,11 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 10px 12px !important;
|
||||
padding: 10px !important;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
`
|
||||
|
||||
@@ -343,17 +253,15 @@ const HeaderCenter = styled.div`
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
`
|
||||
|
||||
const HeaderRight = styled.div`
|
||||
const HeaderRight = styled.div<{ $isFullscreen?: boolean }>`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
|
||||
`
|
||||
|
||||
const TitleText = styled.span`
|
||||
@@ -367,7 +275,6 @@ const TitleText = styled.span`
|
||||
|
||||
const ViewControls = styled.div`
|
||||
display: flex;
|
||||
width: auto;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
background: var(--color-background-mute);
|
||||
@@ -404,39 +311,24 @@ const Container = styled.div`
|
||||
background: var(--color-background);
|
||||
`
|
||||
|
||||
const CodeSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'flex' : 'none')};
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const CodeEditorWrapper = styled.div`
|
||||
const CodeSection = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 300px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
|
||||
.monaco-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.monaco-editor,
|
||||
.cm-editor,
|
||||
.cm-scroller {
|
||||
height: 100% !important;
|
||||
}
|
||||
`
|
||||
|
||||
const PreviewSection = styled.div<{ $visible: boolean }>`
|
||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
||||
const PreviewSection = styled.div`
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
display: ${(props) => (props.$visible ? 'block' : 'none')};
|
||||
`
|
||||
|
||||
const PreviewFrame = styled.iframe`
|
||||
@@ -445,6 +337,7 @@ const PreviewFrame = styled.iframe`
|
||||
border: none;
|
||||
background: white;
|
||||
`
|
||||
|
||||
const EmptyPreview = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { getTopicByMessageId } from '@renderer/hooks/useMessageOperations'
|
||||
import Markdown from '@renderer/pages/home/Markdown/Markdown'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { retryDeepResearchClarificationThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import { DeepResearchMessageBlock, MessageBlockStatus } from '@renderer/types/newMessage'
|
||||
import { deepResearchConfirmation } from '@renderer/utils/deepResearchConfirmation'
|
||||
import { Button, Input } from 'antd'
|
||||
import { Brain, RotateCcw } from 'lucide-react'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SvgSpinners180Ring from './Icons/SvgSpinners180Ring'
|
||||
|
||||
const { TextArea } = Input
|
||||
|
||||
interface DeepResearchCardProps {
|
||||
block: DeepResearchMessageBlock
|
||||
}
|
||||
|
||||
const DeepResearchCard: FC<DeepResearchCardProps> = ({ block }) => {
|
||||
const { t } = useTranslation()
|
||||
const dispatch = useAppDispatch()
|
||||
const [isRetrying, setIsRetrying] = useState(false)
|
||||
const [userSupplementInfo, setUserSupplementInfo] = useState('')
|
||||
|
||||
const {
|
||||
metadata: { deepResearchState }
|
||||
} = block
|
||||
const isWaitingForContinue = deepResearchState.phase === 'waiting_confirmation'
|
||||
|
||||
const onContinueResearch = () => {
|
||||
try {
|
||||
const success = deepResearchConfirmation.triggerResolver(block.id, userSupplementInfo)
|
||||
if (!success) {
|
||||
console.error('[continueDeepResearchThunk] No continue resolver found for message', block.id)
|
||||
return
|
||||
}
|
||||
// resolver会在fetchDeepResearch的onResearchStarted中处理后续的研究阶段逻辑
|
||||
} catch (error) {
|
||||
console.error('[continueDeepResearchThunk] Error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const onRetryResearch = async () => {
|
||||
try {
|
||||
setIsRetrying(true)
|
||||
const topic = await getTopicByMessageId(block.messageId)
|
||||
if (!topic) {
|
||||
console.error('[onRetryResearch] Topic not found for message', block.messageId)
|
||||
return
|
||||
}
|
||||
// 重试时清空补全信息
|
||||
setUserSupplementInfo('')
|
||||
dispatch(retryDeepResearchClarificationThunk(topic.id, block.messageId))
|
||||
} catch (error) {
|
||||
console.error('[onRetryResearch] Error:', error)
|
||||
} finally {
|
||||
setIsRetrying(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{block.status === MessageBlockStatus.PENDING ? (
|
||||
<SvgSpinners180Ring color="var(--color-text-2)" style={{ marginBottom: 15 }} />
|
||||
) : (
|
||||
<CardContainer>
|
||||
<ClarificationSection>
|
||||
<SectionTitle>
|
||||
<Brain size={16} />
|
||||
{t('research.clarification.title')}
|
||||
</SectionTitle>
|
||||
{block.content ? (
|
||||
<Markdown block={block} />
|
||||
) : deepResearchState.phase === 'clarification' && block.status === MessageBlockStatus.STREAMING ? (
|
||||
<SvgSpinners180Ring color="var(--color-text-2)" style={{ marginBottom: 15 }} />
|
||||
) : null}
|
||||
</ClarificationSection>
|
||||
|
||||
{isWaitingForContinue && (
|
||||
<ActionSection>
|
||||
<ActionTitle>{t('research.ready_to_start')}</ActionTitle>
|
||||
|
||||
<SupplementSection>
|
||||
<SupplementLabel>{t('research.supplement_info_label')}</SupplementLabel>
|
||||
<StyledTextArea
|
||||
value={userSupplementInfo}
|
||||
onChange={(e) => setUserSupplementInfo(e.target.value)}
|
||||
placeholder={t('research.supplement_info_placeholder')}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</SupplementSection>
|
||||
|
||||
<ButtonGroup>
|
||||
<RetryButton
|
||||
type="default"
|
||||
icon={<RotateCcw size={16} />}
|
||||
onClick={onRetryResearch}
|
||||
loading={isRetrying}
|
||||
disabled={isRetrying}>
|
||||
{t('research.retry')}
|
||||
</RetryButton>
|
||||
<ContinueButton type="primary" icon={<Brain size={16} />} onClick={onContinueResearch}>
|
||||
{t('research.continue_research')}
|
||||
</ContinueButton>
|
||||
</ButtonGroup>
|
||||
</ActionSection>
|
||||
)}
|
||||
</CardContainer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CardContainer = styled.div`
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
margin: 12px 0;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const ClarificationSection = styled.div`
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--color-border-soft);
|
||||
`
|
||||
|
||||
const SectionTitle = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
|
||||
const ActionSection = styled.div`
|
||||
padding: 16px;
|
||||
background: var(--color-background-soft);
|
||||
`
|
||||
|
||||
const ActionTitle = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const RetryButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const ContinueButton = styled(Button)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
`
|
||||
|
||||
const SupplementSection = styled.div`
|
||||
margin-bottom: 12px;
|
||||
`
|
||||
|
||||
const SupplementLabel = styled.div`
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
`
|
||||
|
||||
const StyledTextArea = styled(TextArea)`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export default DeepResearchCard
|
||||
@@ -82,7 +82,7 @@ function DraggableVirtualList<T>({
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: list.length,
|
||||
count: list?.length ?? 0,
|
||||
getScrollElement: useCallback(() => parentRef.current, []),
|
||||
getItemKey: itemKey,
|
||||
estimateSize: useCallback(() => 50, []),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { restoreFromLocalBackup } from '@renderer/services/BackupService'
|
||||
import { restoreFromLocal } from '@renderer/services/BackupService'
|
||||
import { formatFileSize } from '@renderer/utils'
|
||||
import { Button, message, Modal, Table, Tooltip } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -46,7 +46,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
total: files.length
|
||||
}))
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.local.backup.manager.fetch.error')}: ${error.message}`)
|
||||
window.message.error(`${t('settings.data.local.backup.manager.fetch.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -91,13 +91,13 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
for (const key of selectedRowKeys) {
|
||||
await window.api.backup.deleteLocalBackupFile(key.toString(), localBackupDir)
|
||||
}
|
||||
message.success(
|
||||
window.message.success(
|
||||
t('settings.data.local.backup.manager.delete.success.multiple', { count: selectedRowKeys.length })
|
||||
)
|
||||
setSelectedRowKeys([])
|
||||
await fetchBackupFiles()
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
|
||||
window.message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
message.success(t('settings.data.local.backup.manager.delete.success.single'))
|
||||
await fetchBackupFiles()
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
|
||||
window.message.error(`${t('settings.data.local.backup.manager.delete.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -147,11 +147,11 @@ export function LocalBackupManager({ visible, onClose, localBackupDir, restoreMe
|
||||
onOk: async () => {
|
||||
setRestoring(true)
|
||||
try {
|
||||
await (restoreMethod || restoreFromLocalBackup)(fileName)
|
||||
await (restoreMethod || restoreFromLocal)(fileName)
|
||||
message.success(t('settings.data.local.backup.manager.restore.success'))
|
||||
onClose() // Close the modal
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.local.backup.manager.restore.error')}: ${error.message}`)
|
||||
window.message.error(`${t('settings.data.local.backup.manager.restore.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { backupToLocalDir } from '@renderer/services/BackupService'
|
||||
import { backupToLocal } from '@renderer/services/BackupService'
|
||||
import { Button, Input, Modal } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useState } from 'react'
|
||||
@@ -74,9 +74,9 @@ export function useLocalBackupModal(localBackupDir: string | undefined) {
|
||||
|
||||
setBackuping(true)
|
||||
try {
|
||||
await backupToLocalDir({
|
||||
await backupToLocal({
|
||||
showMessage: true,
|
||||
customFileName
|
||||
customFileName: customFileName || undefined
|
||||
})
|
||||
setIsModalVisible(false)
|
||||
} catch (error) {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { setMinappsOpenLinkExternal } from '@renderer/store/settings'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { delay } from '@renderer/utils'
|
||||
import { Avatar, Drawer, Tooltip } from 'antd'
|
||||
import { Alert, Avatar, Button, Drawer, Tooltip } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -39,6 +39,100 @@ interface AppExtraInfo {
|
||||
|
||||
type AppInfo = MinAppType & AppExtraInfo
|
||||
|
||||
/** Google login tip component */
|
||||
const GoogleLoginTip = ({
|
||||
isReady,
|
||||
currentUrl,
|
||||
currentAppId
|
||||
}: {
|
||||
appId?: string | null
|
||||
isReady: boolean
|
||||
currentUrl: string | null
|
||||
currentAppId: string | null
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [visible, setVisible] = useState(false)
|
||||
const { openMinappById } = useMinappPopup()
|
||||
|
||||
// 判断当前URL是否涉及Google登录
|
||||
const needsGoogleLogin = useMemo(() => {
|
||||
// 如果当前已经在Google小程序中,不需要显示提示
|
||||
if (currentAppId === 'google') return false
|
||||
|
||||
if (!currentUrl) return false
|
||||
|
||||
const googleLoginPatterns = [
|
||||
'accounts.google.com',
|
||||
'signin/oauth',
|
||||
'auth/google',
|
||||
'login/google',
|
||||
'sign-in/google',
|
||||
'google.com/signin',
|
||||
'gsi/client'
|
||||
]
|
||||
|
||||
return googleLoginPatterns.some((pattern) => currentUrl.toLowerCase().includes(pattern.toLowerCase()))
|
||||
}, [currentUrl, currentAppId])
|
||||
|
||||
// 在URL更新时检查是否需要显示提示
|
||||
useEffect(() => {
|
||||
let showTimer: NodeJS.Timeout | null = null
|
||||
let hideTimer: NodeJS.Timeout | null = null
|
||||
|
||||
// 如果是Google登录相关URL且小程序已加载完成,则延迟显示提示
|
||||
if (needsGoogleLogin && isReady) {
|
||||
showTimer = setTimeout(() => {
|
||||
setVisible(true)
|
||||
hideTimer = setTimeout(() => {
|
||||
setVisible(false)
|
||||
}, 30000)
|
||||
}, 500)
|
||||
} else {
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (showTimer) clearTimeout(showTimer)
|
||||
if (hideTimer) clearTimeout(hideTimer)
|
||||
}
|
||||
}, [needsGoogleLogin, isReady, currentUrl])
|
||||
|
||||
// 处理关闭提示
|
||||
const handleClose = () => {
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
// 跳转到Google小程序
|
||||
const openGoogleMinApp = () => {
|
||||
// 使用openMinappById方法打开Google小程序
|
||||
openMinappById('google', true)
|
||||
// 关闭提示
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
// 只在需要Google登录时显示提示
|
||||
if (!needsGoogleLogin || !visible) return null
|
||||
|
||||
// 使用直接的消息文本
|
||||
const message = t('miniwindow.alert.google_login')
|
||||
|
||||
return (
|
||||
<Alert
|
||||
message={message}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
onClose={handleClose}
|
||||
action={
|
||||
<Button type="primary" size="small" onClick={openGoogleMinApp}>
|
||||
{t('common.open')} Google
|
||||
</Button>
|
||||
}
|
||||
style={{ zIndex: 10, animation: 'fadeIn 0.3s ease-in-out' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** The main container for MinApp popup */
|
||||
const MinappPopupContainer: React.FC = () => {
|
||||
const { openedKeepAliveMinapps, openedOneOffMinapp, currentMinappId, minappShow } = useRuntime()
|
||||
@@ -198,9 +292,11 @@ const MinappPopupContainer: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** the callback function to handle the webview navigate to new url */
|
||||
/** the callback function to handle webview navigation */
|
||||
const handleWebviewNavigate = (appid: string, url: string) => {
|
||||
// 记录当前URL,用于GoogleLoginTip判断
|
||||
if (appid === currentMinappId) {
|
||||
console.log('URL changed:', url)
|
||||
setCurrentUrl(url)
|
||||
}
|
||||
}
|
||||
@@ -297,36 +393,36 @@ const MinappPopupContainer: React.FC = () => {
|
||||
</Tooltip>
|
||||
{appInfo.canOpenExternalLink && (
|
||||
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleOpenLink(url ?? appInfo.url)}>
|
||||
<TitleButton onClick={() => handleOpenLink(url ?? appInfo.url)}>
|
||||
<ExportOutlined />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Spacer />
|
||||
<ButtonsGroup className={isWin || isLinux ? 'windows' : ''}>
|
||||
<Tooltip title={t('minapp.popup.goBack')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleGoBack(appInfo.id)}>
|
||||
<TitleButton onClick={() => handleGoBack(appInfo.id)}>
|
||||
<ArrowLeftOutlined />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('minapp.popup.goForward')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleGoForward(appInfo.id)}>
|
||||
<TitleButton onClick={() => handleGoForward(appInfo.id)}>
|
||||
<ArrowRightOutlined />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleReload(appInfo.id)}>
|
||||
<TitleButton onClick={() => handleReload(appInfo.id)}>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
{appInfo.canPinned && (
|
||||
<Tooltip
|
||||
title={appInfo.isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title')}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="bottom">
|
||||
<Button onClick={() => handleTogglePin(appInfo.id)} className={appInfo.isPinned ? 'pinned' : ''}>
|
||||
<TitleButton onClick={() => handleTogglePin(appInfo.id)} className={appInfo.isPinned ? 'pinned' : ''}>
|
||||
<PushpinOutlined style={{ fontSize: 16 }} />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
@@ -337,28 +433,28 @@ const MinappPopupContainer: React.FC = () => {
|
||||
}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="bottom">
|
||||
<Button onClick={handleToggleOpenExternal} className={minappsOpenLinkExternal ? 'open-external' : ''}>
|
||||
<TitleButton onClick={handleToggleOpenExternal} className={minappsOpenLinkExternal ? 'open-external' : ''}>
|
||||
<LinkOutlined />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
{isInDevelopment && (
|
||||
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleOpenDevTools(appInfo.id)}>
|
||||
<TitleButton onClick={() => handleOpenDevTools(appInfo.id)}>
|
||||
<CodeOutlined />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canMinimize && (
|
||||
<Tooltip title={t('minapp.popup.minimize')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handlePopupMinimize()}>
|
||||
<TitleButton onClick={() => handlePopupMinimize()}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('minapp.popup.close')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handlePopupClose(appInfo.id)}>
|
||||
<TitleButton onClick={() => handlePopupClose(appInfo.id)}>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
</TitleButton>
|
||||
</Tooltip>
|
||||
</ButtonsGroup>
|
||||
</TitleContainer>
|
||||
@@ -399,6 +495,8 @@ const MinappPopupContainer: React.FC = () => {
|
||||
marginLeft: 'var(--sidebar-width)',
|
||||
backgroundColor: window.root.style.background
|
||||
}}>
|
||||
{/* 在所有小程序中显示GoogleLoginTip */}
|
||||
<GoogleLoginTip isReady={isReady} currentUrl={currentUrl} currentAppId={currentMinappId} />
|
||||
{!isReady && (
|
||||
<EmptyView>
|
||||
<Avatar
|
||||
@@ -460,7 +558,7 @@ const ButtonsGroup = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const Button = styled.div`
|
||||
const TitleButton = styled.div`
|
||||
cursor: pointer;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
@@ -611,7 +611,7 @@ const QuickPanelContainer = styled.div<{
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding: 0 30px 0 30px;
|
||||
padding: 0 35px 0 35px;
|
||||
transform: translateY(-100%);
|
||||
transform-origin: bottom;
|
||||
transition: max-height 0.2s ease;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { lightbulbVariants } from '@renderer/utils/motionVariants'
|
||||
import { isEqual } from 'lodash'
|
||||
import { ChevronRight, Lightbulb } from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
isThinking: boolean
|
||||
thinkingTimeText: React.ReactNode
|
||||
content: string
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
const ThinkingEffect: React.FC<Props> = ({ isThinking, thinkingTimeText, content, expanded }) => {
|
||||
const [messages, setMessages] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const allLines = (content || '').split('\n')
|
||||
const newMessages = isThinking ? allLines.slice(0, -1) : allLines
|
||||
const validMessages = newMessages.filter((line) => line.trim() !== '')
|
||||
|
||||
if (!isEqual(messages, validMessages)) {
|
||||
setMessages(validMessages)
|
||||
}
|
||||
}, [content, isThinking, messages])
|
||||
|
||||
const showThinking = useMemo(() => {
|
||||
return isThinking && !expanded
|
||||
}, [expanded, isThinking])
|
||||
|
||||
const LINE_HEIGHT = 14
|
||||
|
||||
const containerHeight = useMemo(() => {
|
||||
if (!showThinking || messages.length < 1) return 38
|
||||
return Math.min(75, Math.max(messages.length + 1, 2) * LINE_HEIGHT + 25)
|
||||
}, [showThinking, messages.length])
|
||||
|
||||
return (
|
||||
<ThinkingContainer style={{ height: containerHeight }} className={expanded ? 'expanded' : ''}>
|
||||
<LoadingContainer>
|
||||
<motion.div variants={lightbulbVariants} animate={isThinking ? 'active' : 'idle'} initial="idle">
|
||||
<Lightbulb
|
||||
size={!showThinking || messages.length < 2 ? 20 : 30}
|
||||
style={{ transition: 'width,height, 150ms' }}
|
||||
/>
|
||||
</motion.div>
|
||||
</LoadingContainer>
|
||||
|
||||
<TextContainer>
|
||||
<Title className={!showThinking || !messages.length ? 'showThinking' : ''}>{thinkingTimeText}</Title>
|
||||
|
||||
{showThinking && (
|
||||
<Content>
|
||||
<Messages
|
||||
style={{
|
||||
height: messages.length * LINE_HEIGHT
|
||||
}}
|
||||
initial={{
|
||||
y: -2
|
||||
}}
|
||||
animate={{
|
||||
y: -messages.length * LINE_HEIGHT - 2
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.15,
|
||||
ease: 'linear'
|
||||
}}>
|
||||
{messages.map((message, index) => {
|
||||
if (index < messages.length - 5) return null
|
||||
|
||||
return <Message key={index}>{message}</Message>
|
||||
})}
|
||||
</Messages>
|
||||
</Content>
|
||||
)}
|
||||
</TextContainer>
|
||||
<ArrowContainer className={expanded ? 'expanded' : ''}>
|
||||
<ChevronRight size={20} color="var(--color-text-3)" strokeWidth={1} />
|
||||
</ArrowContainer>
|
||||
</ThinkingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ThinkingContainer = styled.div`
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: height, border-radius, 150ms;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
&.expanded {
|
||||
border-radius: 10px 10px 0 0;
|
||||
}
|
||||
`
|
||||
|
||||
const Title = styled.div`
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
font-size: 14px;
|
||||
line-height: 14px;
|
||||
font-weight: 500;
|
||||
padding: 10px 0;
|
||||
z-index: 99;
|
||||
transition: padding-top 150ms;
|
||||
&.showThinking {
|
||||
padding-top: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
width: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
padding-left: 5px;
|
||||
transition: width 150ms;
|
||||
> div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
`
|
||||
|
||||
const TextContainer = styled.div`
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding: 5px 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const Content = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mask: linear-gradient(
|
||||
to bottom,
|
||||
rgb(0 0 0 / 0%) 0%,
|
||||
rgb(0 0 0 / 0%) 35%,
|
||||
rgb(0 0 0 / 25%) 40%,
|
||||
rgb(0 0 0 / 100%) 90%,
|
||||
rgb(0 0 0 / 100%) 100%
|
||||
);
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const Messages = styled(motion.div)`
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const Message = styled.div`
|
||||
width: 100%;
|
||||
line-height: 14px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`
|
||||
|
||||
const ArrowContainer = styled.div`
|
||||
width: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
color: var(--color-border);
|
||||
transition: transform 150ms;
|
||||
&.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
`
|
||||
|
||||
export default ThinkingEffect
|
||||
@@ -27,6 +27,7 @@ interface WebdavBackupManagerProps {
|
||||
webdavUser?: string
|
||||
webdavPass?: string
|
||||
webdavPath?: string
|
||||
webdavDisableStream?: boolean
|
||||
}
|
||||
restoreMethod?: (fileName: string) => Promise<void>
|
||||
}
|
||||
@@ -48,7 +49,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
|
||||
const fetchBackupFiles = useCallback(async () => {
|
||||
if (!webdavHost) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
window.message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -66,7 +67,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
total: files.length
|
||||
}))
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.webdav.backup.manager.fetch.error')}: ${error.message}`)
|
||||
window.message.error(`${t('settings.data.webdav.backup.manager.fetch.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -94,7 +95,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
}
|
||||
|
||||
if (!webdavHost) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
window.message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,13 +118,13 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
webdavPath
|
||||
} as WebdavConfig)
|
||||
}
|
||||
message.success(
|
||||
window.message.success(
|
||||
t('settings.data.webdav.backup.manager.delete.success.multiple', { count: selectedRowKeys.length })
|
||||
)
|
||||
setSelectedRowKeys([])
|
||||
await fetchBackupFiles()
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
|
||||
window.message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -133,7 +134,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
|
||||
const handleDeleteSingle = async (fileName: string) => {
|
||||
if (!webdavHost) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
window.message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -153,10 +154,10 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
webdavPass,
|
||||
webdavPath
|
||||
} as WebdavConfig)
|
||||
message.success(t('settings.data.webdav.backup.manager.delete.success.single'))
|
||||
window.message.success(t('settings.data.webdav.backup.manager.delete.success.single'))
|
||||
await fetchBackupFiles()
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
|
||||
window.message.error(`${t('settings.data.webdav.backup.manager.delete.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
@@ -166,7 +167,7 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
|
||||
const handleRestore = async (fileName: string) => {
|
||||
if (!webdavHost) {
|
||||
message.error(t('message.error.invalid.webdav'))
|
||||
window.message.error(t('message.error.invalid.webdav'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,10 +182,10 @@ export function WebdavBackupManager({ visible, onClose, webdavConfig, restoreMet
|
||||
setRestoring(true)
|
||||
try {
|
||||
await (restoreMethod || restoreFromWebdav)(fileName)
|
||||
message.success(t('settings.data.webdav.backup.manager.restore.success'))
|
||||
window.message.success(t('settings.data.webdav.backup.manager.restore.success'))
|
||||
onClose() // 关闭模态框
|
||||
} catch (error: any) {
|
||||
message.error(`${t('settings.data.webdav.backup.manager.restore.error')}: ${error.message}`)
|
||||
window.message.error(`${t('settings.data.webdav.backup.manager.restore.error')}: ${error.message}`)
|
||||
} finally {
|
||||
setRestoring(false)
|
||||
}
|
||||
|
||||
@@ -184,7 +184,8 @@ const visionAllowedModels = [
|
||||
'deepseek-vl(?:[\\w-]+)?',
|
||||
'kimi-latest',
|
||||
'gemma-3(?:-[\\w-]+)',
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?'
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?',
|
||||
'kimi-thinking-preview'
|
||||
]
|
||||
|
||||
const visionExcludedModels = [
|
||||
@@ -239,7 +240,8 @@ export const FUNCTION_CALLING_MODELS = [
|
||||
'learnlm(?:-[\\w-]+)?',
|
||||
'gemini(?:-[\\w-]+)?', // 提前排除了gemini的嵌入模型
|
||||
'grok-3(?:-[\\w-]+)?',
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?'
|
||||
'doubao-seed-1[.-]6(?:-[\\w-]+)?',
|
||||
'kimi-k2(?:-[\\w-]+)?'
|
||||
]
|
||||
|
||||
const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||
@@ -247,7 +249,8 @@ const FUNCTION_CALLING_EXCLUDED_MODELS = [
|
||||
'imagen(?:-[\\w-]+)?',
|
||||
'o1-mini',
|
||||
'o1-preview',
|
||||
'AIDC-AI/Marco-o1'
|
||||
'AIDC-AI/Marco-o1',
|
||||
'gemini-1(?:\\.[\\w-]+)?'
|
||||
]
|
||||
|
||||
export const FUNCTION_CALLING_REGEX = new RegExp(
|
||||
@@ -260,7 +263,11 @@ export const CLAUDE_SUPPORTED_WEBSEARCH_REGEX = new RegExp(
|
||||
'i'
|
||||
)
|
||||
|
||||
export function isFunctionCallingModel(model: Model): boolean {
|
||||
export function isFunctionCallingModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (model.type?.includes('function_calling')) {
|
||||
return true
|
||||
}
|
||||
@@ -2459,6 +2466,16 @@ export function isOpenAIWebSearchModel(model: Model): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function isOpenAIDeepResearchModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
}
|
||||
if (!isOpenAIModel(model)) {
|
||||
return false
|
||||
}
|
||||
return model.id.includes('deep-research')
|
||||
}
|
||||
|
||||
export function isSupportedThinkingTokenModel(model?: Model): boolean {
|
||||
if (!model) {
|
||||
return false
|
||||
|
||||
@@ -410,6 +410,7 @@ export const REFERENCE_PROMPT = `Please answer the question based on the referen
|
||||
- Please cite the context at the end of sentences when appropriate.
|
||||
- Please use the format of citation number [number] to reference the context in corresponding parts of your answer.
|
||||
- If a sentence comes from multiple contexts, please list all relevant citation numbers, e.g., [1][2]. Remember not to group citations at the end but list them in the corresponding parts of your answer.
|
||||
- If all reference content is not relevant to the user's question, please answer based on your knowledge.
|
||||
|
||||
## My question is:
|
||||
|
||||
@@ -455,3 +456,82 @@ Example: [nytimes.com](https://nytimes.com/some-page).
|
||||
If have multiple citations, please directly list them like this:
|
||||
[www.nytimes.com](https://nytimes.com/some-page)[www.bbc.com](https://bbc.com/some-page)
|
||||
`
|
||||
|
||||
export const DEEP_RESEARCH_CLARIFICATION_PROMPT = `
|
||||
You are talking to a user who is asking for a research task to be conducted. Your job is to gather more information from the user to successfully complete the task.
|
||||
|
||||
GUIDELINES:
|
||||
- Be concise while gathering all necessary information**
|
||||
- Make sure to gather all the information needed to carry out the research task in a concise, well-structured manner.
|
||||
- Use bullet points or numbered lists if appropriate for clarity.
|
||||
- Don't ask for unnecessary information, or information that the user has already provided.
|
||||
- Use user's language to ask questions.
|
||||
|
||||
IMPORTANT: Do NOT conduct any research yourself, just gather information that will be given to a researcher to conduct the research task.
|
||||
`
|
||||
|
||||
export const DEEP_RESEARCH_PROMPT_REWRITE_PROMPT = `
|
||||
You will be given a research task by a user. Your job is to produce a set of
|
||||
instructions for a researcher that will complete the task. Do NOT complete the
|
||||
task yourself, just provide instructions on how to complete it.
|
||||
|
||||
GUIDELINES:
|
||||
1. **Maximize Specificity and Detail**
|
||||
- Include all known user preferences and explicitly list key attributes or
|
||||
dimensions to consider.
|
||||
- It is of utmost importance that all details from the user are included in
|
||||
the instructions.
|
||||
|
||||
2. **Fill in Unstated But Necessary Dimensions as Open-Ended**
|
||||
- If certain attributes are essential for a meaningful output but the user
|
||||
has not provided them, explicitly state that they are open-ended or default
|
||||
to no specific constraint.
|
||||
|
||||
3. **Avoid Unwarranted Assumptions**
|
||||
- If the user has not provided a particular detail, do not invent one.
|
||||
- Instead, state the lack of specification and guide the researcher to treat
|
||||
it as flexible or accept all possible options.
|
||||
|
||||
4. **Use the First Person**
|
||||
- Phrase the request from the perspective of the user.
|
||||
|
||||
5. **Tables**
|
||||
- If you determine that including a table will help illustrate, organize, or
|
||||
enhance the information in the research output, you must explicitly request
|
||||
that the researcher provide them.
|
||||
|
||||
Examples:
|
||||
- Product Comparison (Consumer): When comparing different smartphone models,
|
||||
request a table listing each model's features, price, and consumer ratings
|
||||
side-by-side.
|
||||
- Project Tracking (Work): When outlining project deliverables, create a table
|
||||
showing tasks, deadlines, responsible team members, and status updates.
|
||||
- Budget Planning (Consumer): When creating a personal or household budget,
|
||||
request a table detailing income sources, monthly expenses, and savings goals.
|
||||
- Competitor Analysis (Work): When evaluating competitor products, request a
|
||||
table with key metrics, such as market share, pricing, and main differentiators.
|
||||
|
||||
6. **Headers and Formatting**
|
||||
- You should include the expected output format in the prompt.
|
||||
- If the user is asking for content that would be best returned in a
|
||||
structured format (e.g. a report, plan, etc.), ask the researcher to format
|
||||
as a report with the appropriate headers and formatting that ensures clarity
|
||||
and structure.
|
||||
|
||||
7. **Language**
|
||||
- If the user input is in a language other than English, tell the researcher
|
||||
to respond in this language, unless the user query explicitly asks for the
|
||||
response in a different language.
|
||||
|
||||
8. **Sources**
|
||||
- If specific sources should be prioritized, specify them in the prompt.
|
||||
- For product and travel research, prefer linking directly to official or
|
||||
primary websites (e.g., official brand sites, manufacturer pages, or
|
||||
reputable e-commerce platforms like Amazon for user reviews) rather than
|
||||
aggregator sites or SEO-heavy blogs.
|
||||
- For academic or scientific queries, prefer linking directly to the original
|
||||
paper or official journal publication rather than survey papers or secondary
|
||||
summaries.
|
||||
- If the query is in a specific language, prioritize sources published in that
|
||||
language.
|
||||
`
|
||||
|
||||
@@ -178,11 +178,11 @@ export const PROVIDER_CONFIG = {
|
||||
url: 'https://api.ppinfra.com/v3/openai'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio&redirect=/',
|
||||
official: 'https://ppio.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio&redirect=/',
|
||||
apiKey:
|
||||
'https://ppio.cn/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio&redirect=/settings/key-management',
|
||||
'https://ppio.com/user/register?invited_by=JYT9GD&utm_source=github_cherry-studio&redirect=/settings/key-management',
|
||||
docs: 'https://docs.cherry-ai.com/pre-basic/providers/ppio?invited_by=JYT9GD&utm_source=github_cherry-studio',
|
||||
models: 'https://ppio.cn/model-api/product/llm-api?invited_by=JYT9GD&utm_source=github_cherry-studio'
|
||||
models: 'https://ppio.com/model-api/product/llm-api?invited_by=JYT9GD&utm_source=github_cherry-studio'
|
||||
}
|
||||
},
|
||||
gemini: {
|
||||
|
||||
@@ -39,3 +39,18 @@ export function getWebSearchTools(model: Model): ChatCompletionTool[] {
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function getUrlContextTools(model: Model): ChatCompletionTool[] {
|
||||
if (model.id.includes('gemini')) {
|
||||
return [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'urlContext'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import useUserTheme from '@renderer/hooks/useUserTheme'
|
||||
import { ThemeMode } from '@renderer/types'
|
||||
@@ -40,7 +40,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial theme and OS attributes on body
|
||||
document.body.setAttribute('os', isMac ? 'mac' : 'windows')
|
||||
document.body.setAttribute('os', isMac ? 'mac' : isWin ? 'windows' : 'linux')
|
||||
document.body.setAttribute('theme-mode', actualTheme)
|
||||
|
||||
// if theme is old auto, then set theme to system
|
||||
|
||||
@@ -4,7 +4,10 @@ import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import db from '@renderer/databases'
|
||||
import i18n from '@renderer/i18n'
|
||||
import KnowledgeQueue from '@renderer/queue/KnowledgeQueue'
|
||||
import MemoryService from '@renderer/services/MemoryService'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectMemoryConfig } from '@renderer/store/memory'
|
||||
import { setAvatar, setFilesPath, setResourcesPath, setUpdateState } from '@renderer/store/runtime'
|
||||
import { delay, runAsyncFunction } from '@renderer/utils'
|
||||
import { defaultLanguage } from '@shared/config/constant'
|
||||
@@ -24,10 +27,14 @@ export function useAppInit() {
|
||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
|
||||
const { theme } = useTheme()
|
||||
const memoryConfig = useAppSelector(selectMemoryConfig)
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById('spinner')?.remove()
|
||||
console.timeEnd('init')
|
||||
|
||||
// Initialize MemoryService after app is ready
|
||||
MemoryService.getInstance()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -121,4 +128,12 @@ export function useAppInit() {
|
||||
useEffect(() => {
|
||||
// TODO: init data collection
|
||||
}, [enableDataCollection])
|
||||
|
||||
// Update memory service configuration when it changes
|
||||
useEffect(() => {
|
||||
const memoryService = MemoryService.getInstance()
|
||||
memoryService.updateConfig().catch((error) => {
|
||||
console.error('Failed to update memory config:', error)
|
||||
})
|
||||
}, [memoryConfig])
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
removeBlocksThunk,
|
||||
resendMessageThunk,
|
||||
resendUserMessageWithEditThunk,
|
||||
updateMessageAndBlocksThunk,
|
||||
updateTranslationBlockThunk
|
||||
updateBlockThunk,
|
||||
updateMessageAndBlocksThunk
|
||||
} from '@renderer/store/thunk/messageThunk'
|
||||
import type { Assistant, LanguageCode, Model, Topic } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
@@ -26,6 +26,8 @@ import { abortCompletion } from '@renderer/utils/abortController'
|
||||
import { throttle } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { TopicManager } from './useTopic'
|
||||
|
||||
const selectMessagesState = (state: RootState) => state.messages
|
||||
|
||||
export const selectNewTopicLoading = createSelector(
|
||||
@@ -232,7 +234,7 @@ export function useMessageOperations(topic: Topic) {
|
||||
}
|
||||
}
|
||||
dispatch(updateOneBlock({ id: blockId, changes }))
|
||||
await dispatch(updateTranslationBlockThunk(blockId, '', false))
|
||||
await dispatch(updateBlockThunk(blockId, '', false))
|
||||
} else {
|
||||
blockId = await dispatch(
|
||||
initiateTranslationThunk(messageId, topic.id, targetLanguage, sourceBlockId, sourceLanguage)
|
||||
@@ -246,7 +248,7 @@ export function useMessageOperations(topic: Topic) {
|
||||
|
||||
return throttle(
|
||||
(accumulatedText: string, isComplete: boolean = false) => {
|
||||
dispatch(updateTranslationBlockThunk(blockId!, accumulatedText, isComplete))
|
||||
dispatch(updateBlockThunk(blockId!, accumulatedText, isComplete))
|
||||
},
|
||||
200,
|
||||
{ leading: true, trailing: true }
|
||||
@@ -452,3 +454,18 @@ export const useTopicMessages = (topicId: string) => {
|
||||
export const useTopicLoading = (topic: Topic) => {
|
||||
return useAppSelector((state) => selectNewTopicLoading(state, topic.id))
|
||||
}
|
||||
|
||||
export const getTopicByMessageId = async (messageId: string) => {
|
||||
const state = store.getState()
|
||||
const message = state.messages.entities[messageId]
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
const topicId = message.topicId
|
||||
console.log('[getTopicByMessageId] topicId', topicId)
|
||||
const topic = await TopicManager.getTopic(topicId)
|
||||
if (!topic) {
|
||||
return null
|
||||
}
|
||||
return topic
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import store from '@renderer/store'
|
||||
|
||||
import { useProviders } from './useProvider'
|
||||
|
||||
export function useModel(id?: string, providerId?: string) {
|
||||
@@ -11,3 +13,15 @@ export function useModel(id?: string, providerId?: string) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getModel(id?: string, providerId?: string) {
|
||||
const providers = store.getState().llm.providers
|
||||
const allModels = providers.map((p) => p.models).flat()
|
||||
return allModels.find((m) => {
|
||||
if (providerId) {
|
||||
return m.id === id && m.provider === providerId
|
||||
} else {
|
||||
return m.id === id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ import { getStoreSetting } from './useSettings'
|
||||
let _activeTopic: Topic
|
||||
let _setActiveTopic: (topic: Topic) => void
|
||||
|
||||
export function useActiveTopic(_assistant: Assistant, topic?: Topic) {
|
||||
const { assistant } = useAssistant(_assistant.id)
|
||||
export function useActiveTopic(assistantId: string, topic?: Topic) {
|
||||
const { assistant } = useAssistant(assistantId)
|
||||
const [activeTopic, setActiveTopic] = useState(topic || _activeTopic || assistant?.topics[0])
|
||||
|
||||
_activeTopic = activeTopic
|
||||
@@ -34,7 +34,14 @@ export function useActiveTopic(_assistant: Assistant, topic?: Topic) {
|
||||
|
||||
useEffect(() => {
|
||||
// activeTopic not in assistant.topics
|
||||
if (assistant && !find(assistant.topics, { id: activeTopic?.id })) {
|
||||
// 确保 assistant 和 assistant.topics 存在,避免在数据未完全加载时访问属性
|
||||
if (
|
||||
assistant &&
|
||||
assistant.topics &&
|
||||
Array.isArray(assistant.topics) &&
|
||||
assistant.topics.length > 0 &&
|
||||
!find(assistant.topics, { id: activeTopic?.id })
|
||||
) {
|
||||
setActiveTopic(assistant.topics[0])
|
||||
}
|
||||
}, [activeTopic?.id, assistant])
|
||||
|
||||
@@ -31,7 +31,8 @@ export default function useUpdateHandler() {
|
||||
title: t('button.update_available'),
|
||||
message: t('button.update_available', { version: releaseInfo.version }),
|
||||
timestamp: Date.now(),
|
||||
source: 'update'
|
||||
source: 'update',
|
||||
channel: 'system'
|
||||
})
|
||||
dispatch(
|
||||
setUpdateState({
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
"input.web_search.button.ok": "Go to Settings",
|
||||
"input.web_search.enable": "Enable web search",
|
||||
"input.web_search.enable_content": "Need to check web search connectivity in settings first",
|
||||
"input.url_context": "URL Context",
|
||||
"input.web_search.no_web_search": "Disable Web Search",
|
||||
"input.web_search.no_web_search.description": "Do not enable web search",
|
||||
"input.web_search.settings": "Web Search Settings",
|
||||
@@ -264,14 +265,6 @@
|
||||
"select.content.tip": "Selected {{count}} items, text types will be merged and saved as one note"
|
||||
},
|
||||
"settings.code.title": "Code Block Settings",
|
||||
"settings.code_cache_max_size": "Max cache size",
|
||||
"settings.code_cache_max_size.tip": "The maximum number of characters allowed to be cached (thousand characters), calculated according to the highlighted code. The length of the highlighted code is much longer than the pure text.",
|
||||
"settings.code_cache_threshold": "Cache threshold",
|
||||
"settings.code_cache_threshold.tip": "The minimum number of characters allowed to be cached (thousand characters), calculated according to the actual code. Only code blocks exceeding the threshold will be cached.",
|
||||
"settings.code_cache_ttl": "Cache TTL",
|
||||
"settings.code_cache_ttl.tip": "Cache expiration time (minutes)",
|
||||
"settings.code_cacheable": "Code block cache",
|
||||
"settings.code_cacheable.tip": "Caching code blocks can reduce the rendering time of long code blocks, but it will increase memory usage",
|
||||
"settings.code_collapsible": "Code block collapsible",
|
||||
"settings.code_editor": {
|
||||
"autocompletion": "Autocompletion",
|
||||
@@ -444,6 +437,7 @@
|
||||
"more": "More",
|
||||
"name": "Name",
|
||||
"no_results": "No results",
|
||||
"open": "Open",
|
||||
"paste": "Paste",
|
||||
"prompt": "Prompt",
|
||||
"provider": "Provider",
|
||||
@@ -467,7 +461,8 @@
|
||||
"swap": "Swap",
|
||||
"topics": "Topics",
|
||||
"warning": "Warning",
|
||||
"you": "You"
|
||||
"you": "You",
|
||||
"i_know": "I know"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Docs"
|
||||
@@ -764,7 +759,8 @@
|
||||
"invoking": "Invoking",
|
||||
"pending": "Pending",
|
||||
"preview": "Preview",
|
||||
"autoApproveEnabled": "Auto-approve enabled for this tool"
|
||||
"autoApproveEnabled": "Auto-approve enabled for this tool",
|
||||
"raw": "Raw"
|
||||
},
|
||||
"topic.added": "New topic added",
|
||||
"upgrade.success.button": "Restart",
|
||||
@@ -818,6 +814,9 @@
|
||||
"title": "MinApp"
|
||||
},
|
||||
"miniwindow": {
|
||||
"alert": {
|
||||
"google_login": "Tip: If you see a 'browser not trusted' message when logging into Google, please first login through the Google mini app in the mini app list, then use Google login in other mini apps"
|
||||
},
|
||||
"clipboard": {
|
||||
"empty": "Clipboard is empty"
|
||||
},
|
||||
@@ -904,7 +903,8 @@
|
||||
"notification": {
|
||||
"assistant": "Assistant Response",
|
||||
"knowledge.error": "{{error}}",
|
||||
"knowledge.success": "Successfully added {{type}} to the knowledge base"
|
||||
"knowledge.success": "Successfully added {{type}} to the knowledge base",
|
||||
"tip": "If the response is successful, then only messages exceeding 30 seconds will trigger a reminder"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
|
||||
@@ -1029,7 +1029,7 @@
|
||||
"turbo": "Turbo"
|
||||
},
|
||||
"req_error_no_balance": "Please check the validity of the token",
|
||||
"req_error_text": "Operation failed. Please try again. Avoid using 'copyrighted' or 'sensitive' words in your prompt.",
|
||||
"req_error_text": "The server is busy or the prompt contains \"copyrighted\" or \"sensitive\" terms. Please try again.",
|
||||
"req_error_token": "Please check the validity of the token",
|
||||
"required_field": "Required field",
|
||||
"seed": "Seed",
|
||||
@@ -1673,7 +1673,11 @@
|
||||
"syncError": "Backup Error",
|
||||
"syncStatus": "Backup Status",
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV User"
|
||||
"user": "WebDAV User",
|
||||
"disableStream": {
|
||||
"title": "Disable Stream Upload",
|
||||
"help": "When enabled, loads the file into memory before uploading. This can solve incompatibility issues with some WebDAV servers that do not support chunked uploads, but it will increase memory usage."
|
||||
}
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@@ -1763,6 +1767,13 @@
|
||||
"addServer.importFrom.invalid": "Invalid input, please check JSON format",
|
||||
"addServer.importFrom.nameExists": "Server already exists: {{name}}",
|
||||
"addServer.importFrom.oneServer": "Only one MCP server configuration at a time",
|
||||
"addServer.importFrom.method": "Import Method",
|
||||
"addServer.importFrom.dxtFile": "DXT Package File",
|
||||
"addServer.importFrom.dxtHelp": "Select a .dxt file containing an MCP server package",
|
||||
"addServer.importFrom.selectDxtFile": "Select DXT File",
|
||||
"addServer.importFrom.noDxtFile": "Please select a DXT file",
|
||||
"addServer.importFrom.dxtProcessFailed": "Failed to process DXT file",
|
||||
"addServer.importFrom.dxt": "Import DXT Package",
|
||||
"addServer.importFrom.placeholder": "Paste MCP server JSON config",
|
||||
"addServer.importFrom.tooltip": "Please copy the configuration JSON (prioritizing\n NPX or UVX configurations) from the MCP Servers introduction page and paste it into the input box.",
|
||||
"addSuccess": "Server added successfully",
|
||||
@@ -1887,6 +1898,7 @@
|
||||
"tools": {
|
||||
"availableTools": "Available Tools",
|
||||
"inputSchema": "Input Schema",
|
||||
"inputSchema.enum.allowedValues": "Allowed Values",
|
||||
"loadError": "Get tools Error",
|
||||
"noToolsAvailable": "No tools available",
|
||||
"enable": "Enable Tool",
|
||||
@@ -2114,6 +2126,7 @@
|
||||
"api_key": "API Key",
|
||||
"api_key.tip": "Multiple keys separated by commas or spaces",
|
||||
"api_version": "API Version",
|
||||
"azure.apiversion.tip": "The API version of Azure OpenAI, if you want to use Response API, please enter the preview version",
|
||||
"basic_auth": "HTTP authentication",
|
||||
"basic_auth.password": "Password",
|
||||
"basic_auth.password.tip": "",
|
||||
@@ -2219,9 +2232,8 @@
|
||||
"system": "System Proxy",
|
||||
"title": "Proxy Mode"
|
||||
},
|
||||
"title": "Proxy Settings"
|
||||
"address": "Proxy Address"
|
||||
},
|
||||
"proxy.title": "Proxy Address",
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "Click the tray icon to start",
|
||||
"enable_quick_assistant": "Enable Quick Assistant",
|
||||
@@ -2301,7 +2313,7 @@
|
||||
},
|
||||
"provider": "OCR Provider",
|
||||
"provider_placeholder": "Choose an OCR provider",
|
||||
"title": "OCR"
|
||||
"title": "OCR Settings"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "Pre Process Provider",
|
||||
@@ -2446,6 +2458,122 @@
|
||||
"quit": "Quit",
|
||||
"show_window": "Show Window",
|
||||
"visualization": "Visualization"
|
||||
},
|
||||
"research": {
|
||||
"clarification": {
|
||||
"title": "Research Clarification"
|
||||
},
|
||||
"ready_to_start": "Ready to start deep research",
|
||||
"retry": "Retry Clarification",
|
||||
"continue_research": "Start Research",
|
||||
"supplement_info_label": "Additional Information (Optional)",
|
||||
"supplement_info_placeholder": "You can provide additional information here to help us better understand your requirements..."
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memories",
|
||||
"actions": "Actions",
|
||||
"description": "Memory allows you to store and manage information about your interactions with the assistant. You can add, edit, and delete memories, as well as filter and search through them.",
|
||||
"add_memory": "Add Memory",
|
||||
"edit_memory": "Edit Memory",
|
||||
"memory_content": "Memory Content",
|
||||
"please_enter_memory": "Please enter memory content",
|
||||
"memory_placeholder": "Enter memory content...",
|
||||
"user_id": "User ID",
|
||||
"user_id_placeholder": "Enter user ID (optional)",
|
||||
"load_failed": "Failed to load memories",
|
||||
"add_success": "Memory added successfully",
|
||||
"add_failed": "Failed to add memory",
|
||||
"update_success": "Memory updated successfully",
|
||||
"update_failed": "Failed to update memory",
|
||||
"delete_success": "Memory deleted successfully",
|
||||
"delete_failed": "Failed to delete memory",
|
||||
"delete_confirm_title": "Delete Memories",
|
||||
"delete_confirm_content": "Are you sure you want to delete {{count}} memories?",
|
||||
"delete_confirm": "Are you sure you want to delete this memory?",
|
||||
"time": "Time",
|
||||
"user": "User",
|
||||
"content": "Content",
|
||||
"score": "Score",
|
||||
"memories_description": "Showing {{count}} of {{total}} memories",
|
||||
"search_placeholder": "Search memories...",
|
||||
"start_date": "Start Date",
|
||||
"end_date": "End Date",
|
||||
"all_users": "All Users",
|
||||
"users": "users",
|
||||
"delete_selected": "Delete Selected",
|
||||
"reset_filters": "Reset Filters",
|
||||
"pagination_total": "{{start}}-{{end}} of {{total}} items",
|
||||
"current_user": "Current User",
|
||||
"select_user": "Select User",
|
||||
"default_user": "Default User",
|
||||
"switch_user": "Switch User",
|
||||
"user_switched": "User context switched to {{user}}",
|
||||
"switch_user_confirm": "Switch user context to {{user}}?",
|
||||
"add_user": "Add User",
|
||||
"add_new_user": "Add New User",
|
||||
"new_user_id": "New User ID",
|
||||
"new_user_id_placeholder": "Enter a unique user ID",
|
||||
"user_id_required": "User ID is required",
|
||||
"user_id_reserved": "'default-user' is reserved, please use a different ID",
|
||||
"user_id_exists": "This user ID already exists",
|
||||
"user_id_too_long": "User ID cannot exceed 50 characters",
|
||||
"user_id_invalid_chars": "User ID can only contain letters, numbers, hyphens and underscores",
|
||||
"user_id_rules": "User ID must be unique and contain only letters, numbers, hyphens (-) and underscores (_)",
|
||||
"user_created": "User {{user}} created and switched successfully",
|
||||
"add_user_failed": "Failed to add user",
|
||||
"memory": "memory",
|
||||
"reset_user_memories": "Reset User Memories",
|
||||
"reset_memories": "Reset Memories",
|
||||
"delete_user": "Delete User",
|
||||
"loading_memories": "Loading memories...",
|
||||
"no_memories": "No memories yet",
|
||||
"no_matching_memories": "No matching memories found",
|
||||
"no_memories_description": "Start by adding your first memory to get started",
|
||||
"try_different_filters": "Try adjusting your search criteria",
|
||||
"add_first_memory": "Add Your First Memory",
|
||||
"user_switch_failed": "Failed to switch user",
|
||||
"cannot_delete_default_user": "Cannot delete the default user",
|
||||
"delete_user_confirm_title": "Delete User",
|
||||
"delete_user_confirm_content": "Are you sure you want to delete user {{user}} and all their memories?",
|
||||
"user_deleted": "User {{user}} deleted successfully",
|
||||
"delete_user_failed": "Failed to delete user",
|
||||
"reset_user_memories_confirm_title": "Reset User Memories",
|
||||
"reset_user_memories_confirm_content": "Are you sure you want to reset all memories for {{user}}?",
|
||||
"user_memories_reset": "All memories for {{user}} have been reset",
|
||||
"reset_user_memories_failed": "Failed to reset user memories",
|
||||
"reset_memories_confirm_title": "Reset All Memories",
|
||||
"reset_memories_confirm_content": "Are you sure you want to permanently delete all memories for {{user}}? This action cannot be undone.",
|
||||
"memories_reset_success": "All memories for {{user}} have been reset successfully",
|
||||
"reset_memories_failed": "Failed to reset memories",
|
||||
"delete_confirm_single": "Are you sure you want to delete this memory?",
|
||||
"total_memories": "total memories",
|
||||
"default": "Default",
|
||||
"custom": "Custom",
|
||||
"global_memory_enabled": "Global memory enabled",
|
||||
"global_memory": "Global Memory",
|
||||
"enable_global_memory_first": "Please enable global memory first",
|
||||
"configure_memory_first": "Please configure memory settings first",
|
||||
"global_memory_disabled_title": "Global Memory Disabled",
|
||||
"global_memory_disabled_desc": "To use memory features, please enable global memory in assistant settings first.",
|
||||
"not_configured_title": "Memory Not Configured",
|
||||
"not_configured_desc": "Please configure embedding and LLM models in memory settings to enable memory functionality.",
|
||||
"go_to_memory_page": "Go to Memory Page",
|
||||
"settings": "Settings",
|
||||
"user_management": "User Management",
|
||||
"statistics": "Statistics",
|
||||
"search": "Search",
|
||||
"initial_memory_content": "Welcome! This is your first memory.",
|
||||
"loading": "Loading memories...",
|
||||
"settings_title": "Memory Settings",
|
||||
"llm_model": "LLM Model",
|
||||
"please_select_llm_model": "Please select an LLM model",
|
||||
"select_llm_model_placeholder": "Select LLM Model",
|
||||
"embedding_model": "Embedding Model",
|
||||
"please_select_embedding_model": "Please select an embedding model",
|
||||
"select_embedding_model_placeholder": "Select Embedding Model",
|
||||
"embedding_dimensions": "Embedding Dimensions",
|
||||
"stored_memories": "Stored Memories",
|
||||
"global_memory_description": "To use memory features, please enable global memory in assistant settings."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
"input.web_search.no_web_search": "ウェブ検索を無効にする",
|
||||
"input.web_search.no_web_search.description": "ウェブ検索を無効にする",
|
||||
"input.web_search.settings": "ウェブ検索設定",
|
||||
"input.url_context": "URLコンテキスト",
|
||||
"message.new.branch": "新しいブランチ",
|
||||
"message.new.branch.created": "新しいブランチが作成されました",
|
||||
"message.new.context": "新しいコンテキスト",
|
||||
@@ -263,14 +264,6 @@
|
||||
"select.content.tip": "{{count}}項目が選択されました。テキストタイプは統合されて1つのノートとして保存されます"
|
||||
},
|
||||
"settings.code.title": "コード設定",
|
||||
"settings.code_cache_max_size": "キャッシュ上限",
|
||||
"settings.code_cache_max_size.tip": "キャッシュできる文字数の上限(千字符)。ハイライトされたコードの長さは純粋なテキストよりもはるかに長くなります。",
|
||||
"settings.code_cache_threshold": "キャッシュ閾値",
|
||||
"settings.code_cache_threshold.tip": "キャッシュできる最小のコード長(千字符)。キャッシュできる最小のコード長を超えたコードブロックのみがキャッシュされます。",
|
||||
"settings.code_cache_ttl": "キャッシュ期限",
|
||||
"settings.code_cache_ttl.tip": "キャッシュの有効期限(分単位)。",
|
||||
"settings.code_cacheable": "コードブロックキャッシュ",
|
||||
"settings.code_cacheable.tip": "コードブロックのキャッシュは長いコードブロックのレンダリング時間を短縮できますが、メモリ使用量が増加します",
|
||||
"settings.code_collapsible": "コードブロック折り畳み",
|
||||
"settings.code_editor": {
|
||||
"autocompletion": "自動補完",
|
||||
@@ -444,6 +437,7 @@
|
||||
"more": "もっと",
|
||||
"name": "名前",
|
||||
"no_results": "検索結果なし",
|
||||
"open": "開く",
|
||||
"paste": "貼り付け",
|
||||
"prompt": "プロンプト",
|
||||
"provider": "プロバイダー",
|
||||
@@ -467,7 +461,8 @@
|
||||
"swap": "交換",
|
||||
"topics": "トピック",
|
||||
"warning": "警告",
|
||||
"you": "あなた"
|
||||
"you": "あなた",
|
||||
"i_know": "わかりました"
|
||||
},
|
||||
"docs": {
|
||||
"title": "ドキュメント"
|
||||
@@ -764,7 +759,8 @@
|
||||
"invoking": "呼び出し中",
|
||||
"pending": "保留中",
|
||||
"preview": "プレビュー",
|
||||
"autoApproveEnabled": "このツールは自動承認が有効になっています"
|
||||
"autoApproveEnabled": "このツールは自動承認が有効になっています",
|
||||
"raw": "生データ"
|
||||
},
|
||||
"topic.added": "新しいトピックが追加されました",
|
||||
"upgrade.success.button": "再起動",
|
||||
@@ -818,6 +814,9 @@
|
||||
"title": "ミニアプリ"
|
||||
},
|
||||
"miniwindow": {
|
||||
"alert": {
|
||||
"google_login": "ヒント:Googleログイン時に「信頼できないブラウザ」というメッセージが表示された場合は、先にミニアプリリストのGoogleミニアプリでアカウントログインを完了してから、他のミニアプリでGoogleログインを使用してください"
|
||||
},
|
||||
"clipboard": {
|
||||
"empty": "クリップボードが空です"
|
||||
},
|
||||
@@ -904,7 +903,8 @@
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
"knowledge.error": "{{error}}",
|
||||
"knowledge.success": "ナレッジベースに{{type}}を正常に追加しました"
|
||||
"knowledge.success": "ナレッジベースに{{type}}を正常に追加しました",
|
||||
"tip": "応答が成功した場合、30秒を超えるメッセージのみに通知を行います"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "モデルがメモリに保持される時間(デフォルト:5分)",
|
||||
@@ -1029,7 +1029,7 @@
|
||||
"turbo": "高速"
|
||||
},
|
||||
"req_error_no_balance": "トークンの有効性を確認してください",
|
||||
"req_error_text": "実行に失敗しました。もう一度お試しください。プロンプトに「著作権用語」や「センシティブな用語」を含めないでください。",
|
||||
"req_error_text": "サーバーが混雑しているか、プロンプトに「著作権用語」または「敏感な用語」が含まれています。もう一度お試しください。",
|
||||
"req_error_token": "トークンの有効性を確認してください",
|
||||
"required_field": "必須項目",
|
||||
"seed": "シード",
|
||||
@@ -1673,7 +1673,11 @@
|
||||
"syncError": "バックアップエラー",
|
||||
"syncStatus": "バックアップ状態",
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAVユーザー"
|
||||
"user": "WebDAVユーザー",
|
||||
"disableStream": {
|
||||
"title": "ストリーミングアップロードを無効にする",
|
||||
"help": "有効にすると、アップロード前にファイルがメモリに読み込まれます。これにより、チャンクアップロードをサポートしていない一部のWebDAVサーバーとの互換性の問題を解決できますが、メモリ使用量が増加します。"
|
||||
}
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@@ -1763,6 +1767,13 @@
|
||||
"addServer.importFrom.invalid": "無効な入力です。JSON形式を確認してください。",
|
||||
"addServer.importFrom.nameExists": "サーバーはすでに存在します: {{name}}",
|
||||
"addServer.importFrom.oneServer": "一度に1つのMCPサーバー設定のみを保存できます",
|
||||
"addServer.importFrom.method": "インポート方法",
|
||||
"addServer.importFrom.dxtFile": "DXTパッケージファイル",
|
||||
"addServer.importFrom.dxtHelp": "MCPサーバーパッケージを含む.dxtファイルを選択",
|
||||
"addServer.importFrom.selectDxtFile": "DXTファイルを選択",
|
||||
"addServer.importFrom.noDxtFile": "DXTファイルを選択してください",
|
||||
"addServer.importFrom.dxtProcessFailed": "DXTファイルの処理に失敗しました",
|
||||
"addServer.importFrom.dxt": "DXTパッケージをインポート",
|
||||
"addServer.importFrom.placeholder": "MCPサーバーJSON設定を貼り付け",
|
||||
"addServer.importFrom.tooltip": "MCPサーバー紹介ページから設定JSON(NPXまたはUVX設定を優先)をコピーし、入力ボックスに貼り付けてください。",
|
||||
"addSuccess": "サーバーが正常に追加されました",
|
||||
@@ -1887,6 +1898,7 @@
|
||||
"tools": {
|
||||
"availableTools": "利用可能なツール",
|
||||
"inputSchema": "入力スキーマ",
|
||||
"inputSchema.enum.allowedValues": "許可された値",
|
||||
"loadError": "ツール取得エラー",
|
||||
"noToolsAvailable": "利用可能なツールなし",
|
||||
"enable": "ツールを有効にする",
|
||||
@@ -2210,7 +2222,8 @@
|
||||
"private_key_placeholder": "サービスアカウントの秘密鍵を入力してください",
|
||||
"title": "サービスアカウント設定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"azure.apiversion.tip": "Azure OpenAIのAPIバージョン。Response APIを使用する場合は、previewバージョンを入力してください"
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@@ -2219,9 +2232,8 @@
|
||||
"system": "システムプロキシ",
|
||||
"title": "プロキシモード"
|
||||
},
|
||||
"title": "プロキシ設定"
|
||||
"address": "プロキシアドレス"
|
||||
},
|
||||
"proxy.title": "プロキシアドレス",
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "トレイアイコンをクリックして起動",
|
||||
"enable_quick_assistant": "クイックアシスタントを有効にする",
|
||||
@@ -2446,6 +2458,122 @@
|
||||
"quit": "終了",
|
||||
"show_window": "ウィンドウを表示",
|
||||
"visualization": "可視化"
|
||||
},
|
||||
"research": {
|
||||
"clarification": {
|
||||
"title": "研究の明確化"
|
||||
},
|
||||
"ready_to_start": "深い研究を開始する準備ができました",
|
||||
"retry": "再明確化",
|
||||
"continue_research": "研究を続ける",
|
||||
"supplement_info_label": "補足情報 (任意)",
|
||||
"supplement_info_placeholder": "ここに補足情報を提供して、より良く理解してください..."
|
||||
},
|
||||
"memory": {
|
||||
"title": "グローバルメモリ",
|
||||
"add_memory": "メモリーを追加",
|
||||
"edit_memory": "メモリーを編集",
|
||||
"memory_content": "メモリー内容",
|
||||
"please_enter_memory": "メモリー内容を入力してください",
|
||||
"memory_placeholder": "メモリー内容を入力...",
|
||||
"user_id": "ユーザーID",
|
||||
"user_id_placeholder": "ユーザーIDを入力(オプション)",
|
||||
"load_failed": "メモリーの読み込みに失敗しました",
|
||||
"add_success": "メモリーが正常に追加されました",
|
||||
"add_failed": "メモリーの追加に失敗しました",
|
||||
"update_success": "メモリーが正常に更新されました",
|
||||
"update_failed": "メモリーの更新に失敗しました",
|
||||
"delete_success": "メモリーが正常に削除されました",
|
||||
"delete_failed": "メモリーの削除に失敗しました",
|
||||
"delete_confirm_title": "メモリーを削除",
|
||||
"delete_confirm_content": "{{count}}件のメモリーを削除してもよろしいですか?",
|
||||
"delete_confirm": "このメモリーを削除してもよろしいですか?",
|
||||
"time": "時間",
|
||||
"user": "ユーザー",
|
||||
"content": "内容",
|
||||
"score": "スコア",
|
||||
"memories_description": "{{total}}件中{{count}}件のメモリーを表示",
|
||||
"search_placeholder": "メモリーを検索...",
|
||||
"start_date": "開始日",
|
||||
"end_date": "終了日",
|
||||
"all_users": "すべてのユーザー",
|
||||
"users": "ユーザー",
|
||||
"delete_selected": "選択したものを削除",
|
||||
"reset_filters": "フィルターをリセット",
|
||||
"pagination_total": "{{total}}件中{{start}}-{{end}}件",
|
||||
"current_user": "現在のユーザー",
|
||||
"select_user": "ユーザーを選択",
|
||||
"default_user": "デフォルトユーザー",
|
||||
"switch_user": "ユーザーを切り替え",
|
||||
"user_switched": "ユーザーコンテキストが{{user}}に切り替わりました",
|
||||
"switch_user_confirm": "ユーザーコンテキストを{{user}}に切り替えますか?",
|
||||
"add_user": "ユーザーを追加",
|
||||
"add_new_user": "新しいユーザーを追加",
|
||||
"new_user_id": "新しいユーザーID",
|
||||
"new_user_id_placeholder": "一意のユーザーIDを入力",
|
||||
"user_id_required": "ユーザーIDは必須です",
|
||||
"user_id_reserved": "'default-user'は予約済みです。別のIDを使用してください",
|
||||
"user_id_exists": "このユーザーIDはすでに存在します",
|
||||
"user_id_too_long": "ユーザーIDは50文字を超えられません",
|
||||
"user_id_invalid_chars": "ユーザーIDには文字、数字、ハイフン、アンダースコアのみ使用できます",
|
||||
"user_id_rules": "ユーザーIDは一意であり、文字、数字、ハイフン(-)、アンダースコア(_)のみ含む必要があります",
|
||||
"user_created": "ユーザー{{user}}が作成され、切り替えが成功しました",
|
||||
"add_user_failed": "ユーザーの追加に失敗しました",
|
||||
"memory": "個のメモリ",
|
||||
"reset_user_memories": "ユーザーメモリをリセット",
|
||||
"reset_memories": "メモリをリセット",
|
||||
"delete_user": "ユーザーを削除",
|
||||
"loading_memories": "メモリを読み込み中...",
|
||||
"no_memories": "メモリがありません",
|
||||
"no_matching_memories": "一致するメモリが見つかりません",
|
||||
"no_memories_description": "最初のメモリを追加してください",
|
||||
"try_different_filters": "検索条件を調整してください",
|
||||
"add_first_memory": "最初のメモリを追加",
|
||||
"user_switch_failed": "ユーザーの切り替えに失敗しました",
|
||||
"cannot_delete_default_user": "デフォルトユーザーは削除できません",
|
||||
"delete_user_confirm_title": "ユーザーを削除",
|
||||
"delete_user_confirm_content": "ユーザー{{user}}とそのすべてのメモリを削除してもよろしいですか?",
|
||||
"user_deleted": "ユーザー{{user}}が正常に削除されました",
|
||||
"delete_user_failed": "ユーザーの削除に失敗しました",
|
||||
"reset_user_memories_confirm_title": "ユーザーメモリをリセット",
|
||||
"reset_user_memories_confirm_content": "{{user}}のすべてのメモリをリセットしてもよろしいですか?",
|
||||
"user_memories_reset": "{{user}}のすべてのメモリがリセットされました",
|
||||
"reset_user_memories_failed": "ユーザーメモリのリセットに失敗しました",
|
||||
"reset_memories_confirm_title": "すべてのメモリをリセット",
|
||||
"reset_memories_confirm_content": "{{user}}のすべてのメモリを完全に削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"memories_reset_success": "{{user}}のすべてのメモリが正常にリセットされました",
|
||||
"reset_memories_failed": "メモリのリセットに失敗しました",
|
||||
"delete_confirm_single": "このメモリを削除してもよろしいですか?",
|
||||
"total_memories": "個のメモリ",
|
||||
"default": "デフォルト",
|
||||
"custom": "カスタム",
|
||||
"description": "メモリは、アシスタントとのやりとりに関する情報を保存・管理する機能です。メモリの追加、編集、削除のほか、フィルタリングや検索を行うことができます。",
|
||||
"global_memory_enabled": "グローバルメモリが有効化されました",
|
||||
"global_memory": "グローバルメモリ",
|
||||
"enable_global_memory_first": "最初にグローバルメモリを有効にしてください",
|
||||
"configure_memory_first": "最初にメモリ設定を構成してください",
|
||||
"global_memory_disabled_title": "グローバルメモリが無効です",
|
||||
"global_memory_disabled_desc": "メモリ機能を使用するには、まずアシスタント設定でグローバルメモリを有効にしてください。",
|
||||
"not_configured_title": "メモリが設定されていません",
|
||||
"not_configured_desc": "メモリ機能を有効にするには、メモリ設定で埋め込みとLLMモデルを設定してください。",
|
||||
"go_to_memory_page": "メモリページに移動",
|
||||
"settings": "設定",
|
||||
"statistics": "統計",
|
||||
"search": "検索",
|
||||
"actions": "アクション",
|
||||
"user_management": "ユーザー管理",
|
||||
"initial_memory_content": "ようこそ!これはあなたの最初の記憶です。",
|
||||
"loading": "思い出を読み込み中...",
|
||||
"settings_title": "メモリ設定",
|
||||
"llm_model": "LLMモデル",
|
||||
"please_select_llm_model": "LLMモデルを選択してください",
|
||||
"select_llm_model_placeholder": "LLMモデルを選択",
|
||||
"embedding_model": "埋め込みモデル",
|
||||
"please_select_embedding_model": "埋め込みモデルを選択してください",
|
||||
"select_embedding_model_placeholder": "埋め込みモデルを選択",
|
||||
"embedding_dimensions": "埋め込み次元",
|
||||
"stored_memories": "保存された記憶",
|
||||
"global_memory_description": "メモリ機能を使用するには、アシスタント設定でグローバルメモリを有効にしてください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
"input.web_search.no_web_search": "Отключить веб-поиск",
|
||||
"input.web_search.no_web_search.description": "Отключить веб-поиск",
|
||||
"input.web_search.settings": "Настройки веб-поиска",
|
||||
"input.url_context": "Контекст страницы",
|
||||
"message.new.branch": "Новая ветка",
|
||||
"message.new.branch.created": "Новая ветка создана",
|
||||
"message.new.context": "Новый контекст",
|
||||
@@ -263,14 +264,6 @@
|
||||
"select.content.tip": "Выбрано {{count}} элементов, текстовые типы будут объединены и сохранены как одна заметка"
|
||||
},
|
||||
"settings.code.title": "Настройки кода",
|
||||
"settings.code_cache_max_size": "Максимальный размер кэша",
|
||||
"settings.code_cache_max_size.tip": "Максимальное количество символов, которое может быть кэшировано (тысяч символов), рассчитывается по кэшированному коду. Длина кэшированного кода значительно превышает длину чистого текста.",
|
||||
"settings.code_cache_threshold": "Пороговое значение кэша",
|
||||
"settings.code_cache_threshold.tip": "Минимальное количество символов для кэширования (тысяч символов), рассчитывается по фактическому коду. Будут кэшированы только те блоки кода, которые превышают пороговое значение",
|
||||
"settings.code_cache_ttl": "Время жизни кэша",
|
||||
"settings.code_cache_ttl.tip": "Время жизни кэша (минуты)",
|
||||
"settings.code_cacheable": "Кэш блока кода",
|
||||
"settings.code_cacheable.tip": "Кэширование блока кода может уменьшить время рендеринга длинных блоков кода, но увеличит использование памяти",
|
||||
"settings.code_collapsible": "Блок кода свернут",
|
||||
"settings.code_editor": {
|
||||
"autocompletion": "Автодополнение",
|
||||
@@ -444,6 +437,7 @@
|
||||
"more": "Ещё",
|
||||
"name": "Имя",
|
||||
"no_results": "Результатов не найдено",
|
||||
"open": "Открыть",
|
||||
"paste": "Вставить",
|
||||
"prompt": "Промпт",
|
||||
"provider": "Провайдер",
|
||||
@@ -467,7 +461,8 @@
|
||||
"swap": "Поменять местами",
|
||||
"topics": "Топики",
|
||||
"warning": "Предупреждение",
|
||||
"you": "Вы"
|
||||
"you": "Вы",
|
||||
"i_know": "Я понял"
|
||||
},
|
||||
"docs": {
|
||||
"title": "Документация"
|
||||
@@ -764,7 +759,8 @@
|
||||
"invoking": "Вызов",
|
||||
"pending": "Ожидание",
|
||||
"preview": "Предпросмотр",
|
||||
"autoApproveEnabled": "Для этого инструмента включен автоматический одобрен"
|
||||
"autoApproveEnabled": "Для этого инструмента включен автоматический одобрен",
|
||||
"raw": "Исходный"
|
||||
},
|
||||
"topic.added": "Новый топик добавлен",
|
||||
"upgrade.success.button": "Перезапустить",
|
||||
@@ -818,6 +814,9 @@
|
||||
"title": "Встроенные приложения"
|
||||
},
|
||||
"miniwindow": {
|
||||
"alert": {
|
||||
"google_login": "Совет: Если при входе в Google вы видите сообщение 'ненадежный браузер', сначала войдите в аккаунт через мини-приложение Google в списке мини-приложений, а затем используйте вход через Google в других мини-приложениях"
|
||||
},
|
||||
"clipboard": {
|
||||
"empty": "Буфер обмена пуст"
|
||||
},
|
||||
@@ -904,7 +903,8 @@
|
||||
"notification": {
|
||||
"assistant": "Ответ ассистента",
|
||||
"knowledge.error": "{{error}}",
|
||||
"knowledge.success": "Успешно добавлено {{type}} в базу знаний"
|
||||
"knowledge.success": "Успешно добавлено {{type}} в базу знаний",
|
||||
"tip": "Если ответ успешен, уведомление выдается только по сообщениям, превышающим 30 секунд"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
|
||||
@@ -1029,7 +1029,7 @@
|
||||
"turbo": "Быстро"
|
||||
},
|
||||
"req_error_no_balance": "Пожалуйста, проверьте действительность токена",
|
||||
"req_error_text": "Операция не удалась, повторите попытку. Пожалуйста, избегайте защищенных авторским правом терминов и конфиденциальных слов в запросах.",
|
||||
"req_error_text": "Сервер перегружен или в запросе обнаружены «авторские» либо «чувствительные» слова. Пожалуйста, повторите попытку.",
|
||||
"req_error_token": "Пожалуйста, проверьте действительность токена",
|
||||
"required_field": "Обязательное поле",
|
||||
"seed": "Ключ генерации",
|
||||
@@ -1673,7 +1673,11 @@
|
||||
"syncError": "Ошибка резервного копирования",
|
||||
"syncStatus": "Статус резервного копирования",
|
||||
"title": "WebDAV",
|
||||
"user": "Пользователь WebDAV"
|
||||
"user": "Пользователь WebDAV",
|
||||
"disableStream": {
|
||||
"title": "Отключить потоковую загрузку",
|
||||
"help": "При включении файл загружается в память перед отправкой. Это может решить проблемы совместимости с некоторыми серверами WebDAV, не поддерживающими фрагментированную (chunked) загрузку, но увеличит потребление памяти."
|
||||
}
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@@ -1763,6 +1767,13 @@
|
||||
"addServer.importFrom.invalid": "Неверный ввод, проверьте формат JSON",
|
||||
"addServer.importFrom.nameExists": "Сервер уже существует: {{name}}",
|
||||
"addServer.importFrom.oneServer": "Можно сохранить только один конфигурационный файл MCP",
|
||||
"addServer.importFrom.method": "Метод импорта",
|
||||
"addServer.importFrom.dxtFile": "DXT-пакет",
|
||||
"addServer.importFrom.dxtHelp": "Выберите .dxt файл, содержащий MCP сервер",
|
||||
"addServer.importFrom.selectDxtFile": "Выбрать DXT-файл",
|
||||
"addServer.importFrom.noDxtFile": "Пожалуйста, выберите DXT-файл",
|
||||
"addServer.importFrom.dxtProcessFailed": "Не удалось обработать DXT-файл",
|
||||
"addServer.importFrom.dxt": "Импорт DXT-пакета",
|
||||
"addServer.importFrom.placeholder": "Вставьте JSON-конфигурацию сервера MCP",
|
||||
"addServer.importFrom.tooltip": "Скопируйте JSON-конфигурацию (приоритет NPX или UVX конфигураций) со страницы введения MCP Servers и вставьте ее в поле ввода.",
|
||||
"addSuccess": "Сервер успешно добавлен",
|
||||
@@ -1887,6 +1898,7 @@
|
||||
"tools": {
|
||||
"availableTools": "Доступные инструменты",
|
||||
"inputSchema": "Схема ввода",
|
||||
"inputSchema.enum.allowedValues": "Допустимые значения",
|
||||
"loadError": "Ошибка получения инструментов",
|
||||
"noToolsAvailable": "Нет доступных инструментов",
|
||||
"enable": "Включить инструмент",
|
||||
@@ -2210,7 +2222,8 @@
|
||||
"private_key_placeholder": "Введите приватный ключ Service Account",
|
||||
"title": "Конфигурация Service Account"
|
||||
}
|
||||
}
|
||||
},
|
||||
"azure.apiversion.tip": "Версия API Azure OpenAI. Если вы хотите использовать Response API, введите версию preview"
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@@ -2219,9 +2232,8 @@
|
||||
"system": "Системный прокси",
|
||||
"title": "Режим прокси"
|
||||
},
|
||||
"title": "Настройки прокси"
|
||||
"address": "Адрес прокси"
|
||||
},
|
||||
"proxy.title": "Адрес прокси",
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "Нажмите на иконку трея для запуска",
|
||||
"enable_quick_assistant": "Включить быстрый помощник",
|
||||
@@ -2446,6 +2458,122 @@
|
||||
"quit": "Выйти",
|
||||
"show_window": "Показать окно",
|
||||
"visualization": "Визуализация"
|
||||
},
|
||||
"research": {
|
||||
"clarification": {
|
||||
"title": "Уточнение исследования"
|
||||
},
|
||||
"ready_to_start": "Готов к началу глубокого исследования",
|
||||
"retry": "Повторное уточнение",
|
||||
"continue_research": "Продолжить исследование",
|
||||
"supplement_info_label": "Дополнительная информация (необязательно)",
|
||||
"supplement_info_placeholder": "Вы можете предоставить дополнительную информацию здесь, чтобы помочь нам лучше понять ваши требования..."
|
||||
},
|
||||
"memory": {
|
||||
"title": "Глобальная память",
|
||||
"add_memory": "Добавить память",
|
||||
"edit_memory": "Редактировать память",
|
||||
"memory_content": "Содержимое памяти",
|
||||
"please_enter_memory": "Пожалуйста, введите содержимое памяти",
|
||||
"memory_placeholder": "Введите содержимое памяти...",
|
||||
"user_id": "ID пользователя",
|
||||
"user_id_placeholder": "Введите ID пользователя (необязательно)",
|
||||
"load_failed": "Не удалось загрузить память",
|
||||
"add_success": "Память успешно добавлена",
|
||||
"add_failed": "Не удалось добавить память",
|
||||
"update_success": "Память успешно обновлена",
|
||||
"update_failed": "Не удалось обновить память",
|
||||
"delete_success": "Память успешно удалена",
|
||||
"delete_failed": "Не удалось удалить память",
|
||||
"delete_confirm_title": "Удалить память",
|
||||
"delete_confirm_content": "Вы уверены, что хотите удалить {{count}} записей памяти?",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить эту запись памяти?",
|
||||
"time": "Время",
|
||||
"user": "Пользователь",
|
||||
"content": "Содержимое",
|
||||
"score": "Оценка",
|
||||
"memories_description": "Показано {{count}} из {{total}} записей памяти",
|
||||
"search_placeholder": "Поиск памяти...",
|
||||
"start_date": "Дата начала",
|
||||
"end_date": "Дата окончания",
|
||||
"all_users": "Все пользователи",
|
||||
"users": "пользователи",
|
||||
"delete_selected": "Удалить выбранные",
|
||||
"reset_filters": "Сбросить фильтры",
|
||||
"pagination_total": "{{start}}-{{end}} из {{total}} элементов",
|
||||
"current_user": "Текущий пользователь",
|
||||
"select_user": "Выбрать пользователя",
|
||||
"default_user": "Пользователь по умолчанию",
|
||||
"switch_user": "Переключить пользователя",
|
||||
"user_switched": "Контекст пользователя переключен на {{user}}",
|
||||
"switch_user_confirm": "Переключить контекст пользователя на {{user}}?",
|
||||
"add_user": "Добавить пользователя",
|
||||
"add_new_user": "Добавить нового пользователя",
|
||||
"new_user_id": "Новый ID пользователя",
|
||||
"new_user_id_placeholder": "Введите уникальный ID пользователя",
|
||||
"user_id_required": "ID пользователя обязателен",
|
||||
"user_id_reserved": "'default-user' зарезервирован, используйте другой ID",
|
||||
"user_id_exists": "Этот ID пользователя уже существует",
|
||||
"user_id_too_long": "ID пользователя не может превышать 50 символов",
|
||||
"user_id_invalid_chars": "ID пользователя может содержать только буквы, цифры, дефисы и подчёркивания",
|
||||
"user_id_rules": "ID пользователя должен быть уникальным и содержать только буквы, цифры, дефисы (-) и подчёркивания (_)",
|
||||
"user_created": "Пользователь {{user}} создан и переключен успешно",
|
||||
"add_user_failed": "Не удалось добавить пользователя",
|
||||
"memory": "воспоминаний",
|
||||
"reset_user_memories": "Сбросить воспоминания пользователя",
|
||||
"reset_memories": "Сбросить воспоминания",
|
||||
"delete_user": "Удалить пользователя",
|
||||
"loading_memories": "Загрузка воспоминаний...",
|
||||
"no_memories": "Нет воспоминаний",
|
||||
"no_matching_memories": "Подходящие воспоминания не найдены",
|
||||
"no_memories_description": "Начните с добавления вашего первого воспоминания",
|
||||
"try_different_filters": "Попробуйте изменить критерии поиска",
|
||||
"add_first_memory": "Добавить первое воспоминание",
|
||||
"user_switch_failed": "Не удалось переключить пользователя",
|
||||
"cannot_delete_default_user": "Нельзя удалить пользователя по умолчанию",
|
||||
"delete_user_confirm_title": "Удалить пользователя",
|
||||
"delete_user_confirm_content": "Вы уверены, что хотите удалить пользователя {{user}} и все его воспоминания?",
|
||||
"user_deleted": "Пользователь {{user}} успешно удален",
|
||||
"delete_user_failed": "Не удалось удалить пользователя",
|
||||
"reset_user_memories_confirm_title": "Сбросить воспоминания пользователя",
|
||||
"reset_user_memories_confirm_content": "Вы уверены, что хотите сбросить все воспоминания пользователя {{user}}?",
|
||||
"user_memories_reset": "Все воспоминания пользователя {{user}} сброшены",
|
||||
"reset_user_memories_failed": "Не удалось сбросить воспоминания пользователя",
|
||||
"reset_memories_confirm_title": "Сбросить все воспоминания",
|
||||
"reset_memories_confirm_content": "Вы уверены, что хотите навсегда удалить все воспоминания пользователя {{user}}? Это действие нельзя отменить.",
|
||||
"memories_reset_success": "Все воспоминания пользователя {{user}} успешно сброшены",
|
||||
"reset_memories_failed": "Не удалось сбросить воспоминания",
|
||||
"delete_confirm_single": "Вы уверены, что хотите удалить это воспоминание?",
|
||||
"total_memories": "всего воспоминаний",
|
||||
"default": "По умолчанию",
|
||||
"custom": "Пользовательский",
|
||||
"description": "Память позволяет хранить и управлять информацией о ваших взаимодействиях с ассистентом. Вы можете добавлять, редактировать и удалять воспоминания, а также фильтровать и искать их.",
|
||||
"global_memory_enabled": "Глобальная память включена",
|
||||
"global_memory": "Глобальная память",
|
||||
"enable_global_memory_first": "Сначала включите глобальную память",
|
||||
"configure_memory_first": "Сначала настройте параметры памяти",
|
||||
"global_memory_disabled_title": "Глобальная память отключена",
|
||||
"global_memory_disabled_desc": "Чтобы использовать функции памяти, сначала включите глобальную память в настройках ассистента.",
|
||||
"not_configured_title": "Память не настроена",
|
||||
"not_configured_desc": "Пожалуйста, настройте модели встраивания и LLM в настройках памяти, чтобы включить функциональность памяти.",
|
||||
"go_to_memory_page": "Перейти на страницу памяти",
|
||||
"settings": "Настройки",
|
||||
"statistics": "Статистика",
|
||||
"search": "Поиск",
|
||||
"actions": "Действия",
|
||||
"user_management": "Управление пользователями",
|
||||
"initial_memory_content": "Добро пожаловать! Это ваше первое воспоминание.",
|
||||
"loading": "Загрузка воспоминаний...",
|
||||
"settings_title": "Настройки памяти",
|
||||
"llm_model": "Модель LLM",
|
||||
"please_select_llm_model": "Пожалуйста, выберите модель LLM",
|
||||
"select_llm_model_placeholder": "Выбор модели LLM",
|
||||
"embedding_model": "Модель встраивания",
|
||||
"please_select_embedding_model": "Пожалуйста, выберите модель для внедрения",
|
||||
"select_embedding_model_placeholder": "Выберите модель внедрения",
|
||||
"embedding_dimensions": "Размерность вложения",
|
||||
"stored_memories": "Запасённые воспоминания",
|
||||
"global_memory_description": "Для использования функций памяти необходимо включить глобальную память в настройках ассистента."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
"input.web_search.no_web_search": "不使用网络",
|
||||
"input.web_search.no_web_search.description": "不启用网络搜索功能",
|
||||
"input.web_search.settings": "网络搜索设置",
|
||||
"input.url_context": "网页上下文",
|
||||
"message.new.branch": "分支",
|
||||
"message.new.branch.created": "新分支已创建",
|
||||
"message.new.context": "清除上下文",
|
||||
@@ -264,14 +265,6 @@
|
||||
"select.content.tip": "已选择 {{count}} 项内容,文本类型将合并保存为一个笔记"
|
||||
},
|
||||
"settings.code.title": "代码块设置",
|
||||
"settings.code_cache_max_size": "缓存上限",
|
||||
"settings.code_cache_max_size.tip": "允许缓存的字符数上限(千字符),按照高亮后的代码计算。高亮后的代码长度相比于纯文本会长很多",
|
||||
"settings.code_cache_threshold": "缓存阈值",
|
||||
"settings.code_cache_threshold.tip": "允许缓存的最小代码长度(千字符),超过阈值的代码块才会被缓存",
|
||||
"settings.code_cache_ttl": "缓存期限",
|
||||
"settings.code_cache_ttl.tip": "缓存过期时间(分钟)",
|
||||
"settings.code_cacheable": "代码块缓存",
|
||||
"settings.code_cacheable.tip": "缓存代码块可以减少长代码块的渲染时间,但会增加内存占用",
|
||||
"settings.code_collapsible": "代码块可折叠",
|
||||
"settings.code_editor": {
|
||||
"autocompletion": "自动补全",
|
||||
@@ -444,6 +437,7 @@
|
||||
"more": "更多",
|
||||
"name": "名称",
|
||||
"no_results": "无结果",
|
||||
"open": "打开",
|
||||
"paste": "粘贴",
|
||||
"prompt": "提示词",
|
||||
"provider": "提供商",
|
||||
@@ -467,7 +461,8 @@
|
||||
"swap": "交换",
|
||||
"topics": "话题",
|
||||
"warning": "警告",
|
||||
"you": "用户"
|
||||
"you": "用户",
|
||||
"i_know": "我知道了"
|
||||
},
|
||||
"docs": {
|
||||
"title": "帮助文档"
|
||||
@@ -764,7 +759,8 @@
|
||||
"invoking": "调用中",
|
||||
"pending": "等待中",
|
||||
"preview": "预览",
|
||||
"autoApproveEnabled": "此工具已启用自动批准"
|
||||
"autoApproveEnabled": "此工具已启用自动批准",
|
||||
"raw": "原始"
|
||||
},
|
||||
"topic.added": "话题添加成功",
|
||||
"upgrade.success.button": "重启",
|
||||
@@ -818,6 +814,9 @@
|
||||
"title": "小程序"
|
||||
},
|
||||
"miniwindow": {
|
||||
"alert": {
|
||||
"google_login": "提示:如遇到Google登录提示\"不受信任的浏览器\",请先在小程序列表中的Google小程序中完成账号登录,再在其它小程序使用Google登录"
|
||||
},
|
||||
"clipboard": {
|
||||
"empty": "剪贴板为空"
|
||||
},
|
||||
@@ -904,7 +903,8 @@
|
||||
"notification": {
|
||||
"assistant": "助手响应",
|
||||
"knowledge.error": "{{error}}",
|
||||
"knowledge.success": "成功添加 {{type}} 到知识库"
|
||||
"knowledge.success": "成功添加 {{type}} 到知识库",
|
||||
"tip": "如果响应成功,则只针对超过30秒的消息进行提醒"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "对话后模型在内存中保持的时间(默认:5 分钟)",
|
||||
@@ -1029,7 +1029,7 @@
|
||||
"turbo": "快速"
|
||||
},
|
||||
"req_error_no_balance": "请检查令牌有效性",
|
||||
"req_error_text": "运行失败,请重试。提示词避免 \"版权词\" 和 \"敏感词\" 哦。",
|
||||
"req_error_text": "服务器繁忙或提示词出现 \"版权词\" 和 \"敏感词\" ,请重试。",
|
||||
"req_error_token": "请检查令牌有效性",
|
||||
"required_field": "必填项",
|
||||
"seed": "随机种子",
|
||||
@@ -1673,7 +1673,11 @@
|
||||
"syncError": "备份错误",
|
||||
"syncStatus": "备份状态",
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 用户名"
|
||||
"user": "WebDAV 用户名",
|
||||
"disableStream": {
|
||||
"title": "禁用流式上传",
|
||||
"help": "开启后,将文件加载到内存中再上传,可解决部分WebDAV服务不兼容chunked上传的问题,但会增加内存占用。"
|
||||
}
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@@ -1763,6 +1767,13 @@
|
||||
"addServer.importFrom.invalid": "无效输入,请检查 JSON 格式",
|
||||
"addServer.importFrom.nameExists": "服务器已存在:{{name}}",
|
||||
"addServer.importFrom.oneServer": "每次只能保存一個 MCP 伺服器配置",
|
||||
"addServer.importFrom.method": "导入方式",
|
||||
"addServer.importFrom.dxtFile": "DXT 包文件",
|
||||
"addServer.importFrom.dxtHelp": "选择包含 MCP 服务器的 .dxt 文件",
|
||||
"addServer.importFrom.selectDxtFile": "选择 DXT 文件",
|
||||
"addServer.importFrom.noDxtFile": "请选择一个 DXT 文件",
|
||||
"addServer.importFrom.dxtProcessFailed": "处理 DXT 文件失败",
|
||||
"addServer.importFrom.dxt": "导入 DXT 包",
|
||||
"addServer.importFrom.placeholder": "粘贴 MCP 服务器 JSON 配置",
|
||||
"addServer.importFrom.tooltip": "请从 MCP Servers 的介绍页面复制配置 JSON(优先使用\n NPX 或 UVX 配置),并粘贴到输入框中",
|
||||
"addSuccess": "服务器添加成功",
|
||||
@@ -1887,6 +1898,7 @@
|
||||
"tools": {
|
||||
"availableTools": "可用工具",
|
||||
"inputSchema": "输入模式",
|
||||
"inputSchema.enum.allowedValues": "允许的值",
|
||||
"loadError": "获取工具失败",
|
||||
"noToolsAvailable": "无可用工具",
|
||||
"enable": "启用工具",
|
||||
@@ -2114,6 +2126,7 @@
|
||||
"api_key": "API 密钥",
|
||||
"api_key.tip": "多个密钥使用逗号或空格分隔",
|
||||
"api_version": "API 版本",
|
||||
"azure.apiversion.tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,请输入 preview 版本",
|
||||
"basic_auth": "HTTP 认证",
|
||||
"basic_auth.password": "密码",
|
||||
"basic_auth.password.tip": "",
|
||||
@@ -2219,9 +2232,8 @@
|
||||
"system": "系统代理",
|
||||
"title": "代理模式"
|
||||
},
|
||||
"title": "代理设置"
|
||||
"address": "代理地址"
|
||||
},
|
||||
"proxy.title": "代理地址",
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "点击托盘图标启动",
|
||||
"enable_quick_assistant": "启用快捷助手",
|
||||
@@ -2301,7 +2313,7 @@
|
||||
},
|
||||
"provider": "OCR 服务商",
|
||||
"provider_placeholder": "选择一个 OCR 服务商",
|
||||
"title": "OCR"
|
||||
"title": "OCR 文字识别"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "文档预处理服务商",
|
||||
@@ -2446,6 +2458,122 @@
|
||||
"quit": "退出",
|
||||
"show_window": "显示窗口",
|
||||
"visualization": "可视化"
|
||||
},
|
||||
"research": {
|
||||
"clarification": {
|
||||
"title": "研究澄清"
|
||||
},
|
||||
"ready_to_start": "准备开始深度研究",
|
||||
"retry": "重新澄清",
|
||||
"continue_research": "开始研究",
|
||||
"supplement_info_label": "补充信息(可选)",
|
||||
"supplement_info_placeholder": "您可以在这里补充更多信息,帮助我们更好地理解您的需求..."
|
||||
},
|
||||
"memory": {
|
||||
"title": "全局记忆",
|
||||
"settings": "设置",
|
||||
"statistics": "统计",
|
||||
"search": "搜索",
|
||||
"actions": "操作",
|
||||
"add_memory": "添加记忆",
|
||||
"edit_memory": "编辑记忆",
|
||||
"memory_content": "记忆内容",
|
||||
"please_enter_memory": "请输入记忆内容",
|
||||
"memory_placeholder": "输入记忆内容...",
|
||||
"user_id": "用户 ID",
|
||||
"user_id_placeholder": "输入用户 ID(可选)",
|
||||
"load_failed": "加载记忆失败",
|
||||
"add_success": "记忆添加成功",
|
||||
"add_failed": "添加记忆失败",
|
||||
"update_success": "记忆更新成功",
|
||||
"update_failed": "更新记忆失败",
|
||||
"delete_success": "记忆删除成功",
|
||||
"delete_failed": "删除记忆失败",
|
||||
"delete_confirm_title": "删除记忆",
|
||||
"delete_confirm_content": "确定要删除 {{count}} 条记忆吗?",
|
||||
"delete_confirm": "确定要删除这条记忆吗?",
|
||||
"time": "时间",
|
||||
"user": "用户",
|
||||
"content": "内容",
|
||||
"score": "分数",
|
||||
"memories_description": "显示 {{count}} / {{total}} 条记忆",
|
||||
"search_placeholder": "搜索记忆...",
|
||||
"start_date": "开始日期",
|
||||
"end_date": "结束日期",
|
||||
"all_users": "所有用户",
|
||||
"users": "用户",
|
||||
"delete_selected": "删除选中",
|
||||
"reset_filters": "重置筛选",
|
||||
"pagination_total": "第 {{start}}-{{end}} 项,共 {{total}} 项",
|
||||
"current_user": "当前用户",
|
||||
"select_user": "选择用户",
|
||||
"default_user": "默认用户",
|
||||
"switch_user": "切换用户",
|
||||
"user_switched": "用户上下文已切换到 {{user}}",
|
||||
"switch_user_confirm": "将用户上下文切换到 {{user}}?",
|
||||
"add_user": "添加用户",
|
||||
"add_new_user": "添加新用户",
|
||||
"new_user_id": "新用户ID",
|
||||
"new_user_id_placeholder": "输入唯一的用户ID",
|
||||
"user_management": "用户管理",
|
||||
"user_id_required": "用户ID为必填项",
|
||||
"user_id_reserved": "'default-user' 为保留字,请使用其他ID",
|
||||
"user_id_exists": "该用户ID已存在",
|
||||
"user_id_too_long": "用户ID不能超过50个字符",
|
||||
"user_id_invalid_chars": "用户ID只能包含字母、数字、连字符和下划线",
|
||||
"user_id_rules": "用户ID必须唯一,只能包含字母、数字、连字符(-)和下划线(_)",
|
||||
"user_created": "用户 {{user}} 创建并切换成功",
|
||||
"add_user_failed": "添加用户失败",
|
||||
"memory": "条记忆",
|
||||
"reset_user_memories": "重置用户记忆",
|
||||
"reset_memories": "重置记忆",
|
||||
"delete_user": "删除用户",
|
||||
"loading_memories": "正在加载记忆...",
|
||||
"no_memories": "暂无记忆",
|
||||
"no_matching_memories": "未找到匹配的记忆",
|
||||
"no_memories_description": "开始添加您的第一条记忆吧",
|
||||
"try_different_filters": "尝试调整搜索条件",
|
||||
"add_first_memory": "添加您的第一条记忆",
|
||||
"user_switch_failed": "切换用户失败",
|
||||
"cannot_delete_default_user": "不能删除默认用户",
|
||||
"delete_user_confirm_title": "删除用户",
|
||||
"delete_user_confirm_content": "确定要删除用户 {{user}} 及其所有记忆吗?",
|
||||
"user_deleted": "用户 {{user}} 删除成功",
|
||||
"delete_user_failed": "删除用户失败",
|
||||
"reset_user_memories_confirm_title": "重置用户记忆",
|
||||
"reset_user_memories_confirm_content": "确定要重置 {{user}} 的所有记忆吗?",
|
||||
"user_memories_reset": "{{user}} 的所有记忆已重置",
|
||||
"reset_user_memories_failed": "重置用户记忆失败",
|
||||
"reset_memories_confirm_title": "重置所有记忆",
|
||||
"reset_memories_confirm_content": "确定要永久删除 {{user}} 的所有记忆吗?此操作无法撤销。",
|
||||
"memories_reset_success": "{{user}} 的所有记忆已成功重置",
|
||||
"reset_memories_failed": "重置记忆失败",
|
||||
"delete_confirm_single": "确定要删除这条记忆吗?",
|
||||
"total_memories": "条记忆",
|
||||
"default": "默认",
|
||||
"custom": "自定义",
|
||||
"description": "记忆功能允许您存储和管理与助手交互的信息。您可以添加、编辑和删除记忆,也可以对它们进行过滤和搜索。",
|
||||
"global_memory_enabled": "全局记忆已启用",
|
||||
"global_memory": "全局记忆",
|
||||
"enable_global_memory_first": "请先启用全局记忆",
|
||||
"configure_memory_first": "请先配置记忆设置",
|
||||
"global_memory_disabled_title": "全局记忆已禁用",
|
||||
"global_memory_disabled_desc": "要使用记忆功能,请先在助手设置中启用全局记忆。",
|
||||
"not_configured_title": "记忆未配置",
|
||||
"not_configured_desc": "请在记忆设置中配置嵌入和LLM模型以启用记忆功能。",
|
||||
"go_to_memory_page": "前往记忆页面",
|
||||
"initial_memory_content": "欢迎!这是您的第一条记忆。",
|
||||
"loading": "正在加载记忆...",
|
||||
"settings_title": "记忆设置",
|
||||
"llm_model": "LLM 模型",
|
||||
"please_select_llm_model": "请选择 LLM 模型",
|
||||
"select_llm_model_placeholder": "选择 LLM 模型",
|
||||
"embedding_model": "嵌入模型",
|
||||
"please_select_embedding_model": "请选择嵌入模型",
|
||||
"select_embedding_model_placeholder": "选择嵌入模型",
|
||||
"embedding_dimensions": "嵌入维度",
|
||||
"stored_memories": "已存储记忆",
|
||||
"global_memory_description": "需要开启助手设置中的全局记忆才能使用"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
"input.web_search.no_web_search": "關閉網路搜尋",
|
||||
"input.web_search.no_web_search.description": "關閉網路搜尋",
|
||||
"input.web_search.settings": "網路搜尋設定",
|
||||
"input.url_context": "網頁上下文",
|
||||
"message.new.branch": "分支",
|
||||
"message.new.branch.created": "新分支已建立",
|
||||
"message.new.context": "新上下文",
|
||||
@@ -263,14 +264,6 @@
|
||||
"select.content.tip": "已選擇 {{count}} 項內容,文本類型將合併儲存為一個筆記"
|
||||
},
|
||||
"settings.code.title": "程式碼區塊",
|
||||
"settings.code_cache_max_size": "快取上限",
|
||||
"settings.code_cache_max_size.tip": "允許快取的字元數上限(千字符),按照高亮後的程式碼計算。高亮後的程式碼長度相比純文字會長很多",
|
||||
"settings.code_cache_threshold": "快取門檻",
|
||||
"settings.code_cache_threshold.tip": "允許快取的最小程式碼長度(千字符),超過門檻的程式碼區塊才會被快取",
|
||||
"settings.code_cache_ttl": "快取期限",
|
||||
"settings.code_cache_ttl.tip": "快取的存活時間(分鐘)",
|
||||
"settings.code_cacheable": "程式碼區塊快取",
|
||||
"settings.code_cacheable.tip": "快取程式碼區塊可以減少長程式碼區塊的渲染時間,但會增加記憶體使用量",
|
||||
"settings.code_collapsible": "程式碼區塊可折疊",
|
||||
"settings.code_editor": {
|
||||
"autocompletion": "自動補全",
|
||||
@@ -444,6 +437,7 @@
|
||||
"more": "更多",
|
||||
"name": "名稱",
|
||||
"no_results": "沒有結果",
|
||||
"open": "開啟",
|
||||
"paste": "貼上",
|
||||
"prompt": "提示詞",
|
||||
"provider": "供應商",
|
||||
@@ -467,7 +461,8 @@
|
||||
"swap": "交換",
|
||||
"topics": "話題",
|
||||
"warning": "警告",
|
||||
"you": "您"
|
||||
"you": "您",
|
||||
"i_know": "我知道了"
|
||||
},
|
||||
"docs": {
|
||||
"title": "說明文件"
|
||||
@@ -764,7 +759,8 @@
|
||||
"invoking": "調用中",
|
||||
"pending": "等待中",
|
||||
"preview": "預覽",
|
||||
"autoApproveEnabled": "此工具已啟用自動批准"
|
||||
"autoApproveEnabled": "此工具已啟用自動批准",
|
||||
"raw": "原始碼"
|
||||
},
|
||||
"topic.added": "新話題已新增",
|
||||
"upgrade.success.button": "重新啟動",
|
||||
@@ -818,6 +814,9 @@
|
||||
"title": "小工具"
|
||||
},
|
||||
"miniwindow": {
|
||||
"alert": {
|
||||
"google_login": "提示:如遇到Google登入提示\"不受信任的瀏覽器\",請先在小程序列表中的Google小程序中完成帳號登入,再在其它小程序使用Google登入"
|
||||
},
|
||||
"clipboard": {
|
||||
"empty": "剪貼簿為空"
|
||||
},
|
||||
@@ -904,7 +903,8 @@
|
||||
"notification": {
|
||||
"assistant": "助手回應",
|
||||
"knowledge.error": "無法將 {{type}} 加入知識庫: {{error}}",
|
||||
"knowledge.success": "成功將 {{type}} 新增至知識庫"
|
||||
"knowledge.success": "成功將 {{type}} 新增至知識庫",
|
||||
"tip": "如果回應成功,則只針對超過30秒的訊息發出提醒"
|
||||
},
|
||||
"ollama": {
|
||||
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)",
|
||||
@@ -1029,7 +1029,7 @@
|
||||
"turbo": "快速"
|
||||
},
|
||||
"req_error_no_balance": "請檢查令牌的有效性",
|
||||
"req_error_text": "运行失败,请重试。提示词避免 “版权词” 和” 敏感词” 哦。",
|
||||
"req_error_text": "伺服器繁忙或提示詞中出現「版權詞」或「敏感詞」,請重試。",
|
||||
"req_error_token": "請檢查令牌的有效性",
|
||||
"required_field": "必填欄位",
|
||||
"seed": "隨機種子",
|
||||
@@ -1673,7 +1673,11 @@
|
||||
"syncError": "備份錯誤",
|
||||
"syncStatus": "備份狀態",
|
||||
"title": "WebDAV",
|
||||
"user": "WebDAV 使用者名稱"
|
||||
"user": "WebDAV 使用者名稱",
|
||||
"disableStream": {
|
||||
"title": "禁用串流上傳",
|
||||
"help": "開啟後,將檔案載入到記憶體中再上傳,可解決部分 WebDAV 服務不相容 chunked 上傳的問題,但會增加記憶體佔用。"
|
||||
}
|
||||
},
|
||||
"yuque": {
|
||||
"check": {
|
||||
@@ -1763,6 +1767,13 @@
|
||||
"addServer.importFrom.invalid": "無效的輸入,請檢查 JSON 格式",
|
||||
"addServer.importFrom.nameExists": "伺服器已存在:{{name}}",
|
||||
"addServer.importFrom.oneServer": "每次只能保存一個 MCP 伺服器配置",
|
||||
"addServer.importFrom.method": "導入方式",
|
||||
"addServer.importFrom.dxtFile": "DXT 包文件",
|
||||
"addServer.importFrom.dxtHelp": "選擇包含 MCP 服務器的 .dxt 文件",
|
||||
"addServer.importFrom.selectDxtFile": "選擇 DXT 文件",
|
||||
"addServer.importFrom.noDxtFile": "請選擇一個 DXT 文件",
|
||||
"addServer.importFrom.dxtProcessFailed": "處理 DXT 文件失敗",
|
||||
"addServer.importFrom.dxt": "導入 DXT 包",
|
||||
"addServer.importFrom.placeholder": "貼上 MCP 伺服器 JSON 設定",
|
||||
"addServer.importFrom.tooltip": "請從 MCP Servers 的介紹頁面複製配置 JSON(優先使用\n NPX 或 UVX 配置),並粘貼到輸入框中",
|
||||
"addSuccess": "伺服器新增成功",
|
||||
@@ -1887,6 +1898,7 @@
|
||||
"tools": {
|
||||
"availableTools": "可用工具",
|
||||
"inputSchema": "輸入模式",
|
||||
"inputSchema.enum.allowedValues": "允許的值",
|
||||
"loadError": "獲取工具失敗",
|
||||
"noToolsAvailable": "無可用工具",
|
||||
"enable": "啟用工具",
|
||||
@@ -2210,7 +2222,8 @@
|
||||
"private_key_placeholder": "輸入服務帳戶私密金鑰",
|
||||
"title": "服務帳戶設定"
|
||||
}
|
||||
}
|
||||
},
|
||||
"azure.apiversion.tip": "Azure OpenAI 的 API 版本,如果想要使用 Response API,請輸入 preview 版本"
|
||||
},
|
||||
"proxy": {
|
||||
"mode": {
|
||||
@@ -2219,9 +2232,8 @@
|
||||
"system": "系統代理伺服器",
|
||||
"title": "代理伺服器模式"
|
||||
},
|
||||
"title": "代理伺服器設定"
|
||||
"address": "代理伺服器位址"
|
||||
},
|
||||
"proxy.title": "代理伺服器地址",
|
||||
"quickAssistant": {
|
||||
"click_tray_to_show": "點選工具列圖示啟動",
|
||||
"enable_quick_assistant": "啟用快捷助手",
|
||||
@@ -2301,7 +2313,7 @@
|
||||
},
|
||||
"provider": "OCR 供應商",
|
||||
"provider_placeholder": "選擇一個OCR服務提供商",
|
||||
"title": "光學字符識別"
|
||||
"title": "OCR 文字識別"
|
||||
},
|
||||
"preprocess": {
|
||||
"provider": "前置處理供應商",
|
||||
@@ -2446,6 +2458,122 @@
|
||||
"quit": "結束",
|
||||
"show_window": "顯示視窗",
|
||||
"visualization": "視覺化"
|
||||
},
|
||||
"research": {
|
||||
"clarification": {
|
||||
"title": "研究澄清"
|
||||
},
|
||||
"ready_to_start": "準備開始深度研究",
|
||||
"retry": "重新澄清",
|
||||
"continue_research": "繼續研究",
|
||||
"supplement_info_label": "補充資訊 (可選)",
|
||||
"supplement_info_placeholder": "您可以在此處提供補充資訊,幫助我們更好地理解您的需求..."
|
||||
},
|
||||
"memory": {
|
||||
"title": "全域記憶",
|
||||
"add_memory": "新增記憶",
|
||||
"edit_memory": "編輯記憶",
|
||||
"memory_content": "記憶內容",
|
||||
"please_enter_memory": "請輸入記憶內容",
|
||||
"memory_placeholder": "輸入記憶內容...",
|
||||
"user_id": "使用者ID",
|
||||
"user_id_placeholder": "輸入使用者ID(可選)",
|
||||
"load_failed": "載入記憶失敗",
|
||||
"add_success": "記憶新增成功",
|
||||
"add_failed": "新增記憶失敗",
|
||||
"update_success": "記憶更新成功",
|
||||
"update_failed": "更新記憶失敗",
|
||||
"delete_success": "記憶刪除成功",
|
||||
"delete_failed": "刪除記憶失敗",
|
||||
"delete_confirm_title": "刪除記憶",
|
||||
"delete_confirm_content": "確定要刪除 {{count}} 條記憶嗎?",
|
||||
"delete_confirm": "確定要刪除這條記憶嗎?",
|
||||
"time": "時間",
|
||||
"user": "使用者",
|
||||
"content": "內容",
|
||||
"score": "分數",
|
||||
"memories_description": "顯示 {{count}} / {{total}} 條記憶",
|
||||
"search_placeholder": "搜尋記憶...",
|
||||
"start_date": "開始日期",
|
||||
"end_date": "結束日期",
|
||||
"all_users": "所有使用者",
|
||||
"users": "使用者",
|
||||
"delete_selected": "刪除選取",
|
||||
"reset_filters": "重設篩選",
|
||||
"pagination_total": "第 {{start}}-{{end}} 項,共 {{total}} 項",
|
||||
"current_user": "目前使用者",
|
||||
"select_user": "選擇使用者",
|
||||
"default_user": "預設使用者",
|
||||
"switch_user": "切換使用者",
|
||||
"user_switched": "使用者內容已切換至 {{user}}",
|
||||
"switch_user_confirm": "將使用者內容切換至 {{user}}?",
|
||||
"add_user": "新增使用者",
|
||||
"add_new_user": "新增新使用者",
|
||||
"new_user_id": "新使用者ID",
|
||||
"new_user_id_placeholder": "輸入唯一的使用者ID",
|
||||
"user_id_required": "使用者ID為必填欄位",
|
||||
"user_id_reserved": "'default-user' 為保留字,請使用其他ID",
|
||||
"user_id_exists": "此使用者ID已存在",
|
||||
"user_id_too_long": "使用者ID不能超過50個字元",
|
||||
"user_id_invalid_chars": "使用者ID只能包含字母、數字、連字符和底線",
|
||||
"user_id_rules": "使用者ID必须唯一,只能包含字母、數字、連字符(-)和底線(_)",
|
||||
"user_created": "使用者 {{user}} 建立並切換成功",
|
||||
"add_user_failed": "新增使用者失敗",
|
||||
"memory": "個記憶",
|
||||
"reset_user_memories": "重置使用者記憶",
|
||||
"reset_memories": "重置記憶",
|
||||
"delete_user": "刪除使用者",
|
||||
"loading_memories": "正在載入記憶...",
|
||||
"no_memories": "暫無記憶",
|
||||
"no_matching_memories": "未找到符合的記憶",
|
||||
"no_memories_description": "開始新增您的第一個記憶吧",
|
||||
"try_different_filters": "嘗試調整搜尋條件",
|
||||
"add_first_memory": "新增您的第一個記憶",
|
||||
"user_switch_failed": "切換使用者失敗",
|
||||
"cannot_delete_default_user": "不能刪除預設使用者",
|
||||
"delete_user_confirm_title": "刪除使用者",
|
||||
"delete_user_confirm_content": "確定要刪除使用者 {{user}} 及其所有記憶嗎?",
|
||||
"user_deleted": "使用者 {{user}} 刪除成功",
|
||||
"delete_user_failed": "刪除使用者失敗",
|
||||
"reset_user_memories_confirm_title": "重置使用者記憶",
|
||||
"reset_user_memories_confirm_content": "確定要重置 {{user}} 的所有記憶嗎?",
|
||||
"user_memories_reset": "{{user}} 的所有記憶已重置",
|
||||
"reset_user_memories_failed": "重置使用者記憶失敗",
|
||||
"reset_memories_confirm_title": "重置所有記憶",
|
||||
"reset_memories_confirm_content": "確定要永久刪除 {{user}} 的所有記憶嗎?此操作無法復原。",
|
||||
"memories_reset_success": "{{user}} 的所有記憶已成功重置",
|
||||
"reset_memories_failed": "重置記憶失敗",
|
||||
"delete_confirm_single": "確定要刪除這個記憶嗎?",
|
||||
"total_memories": "個記憶",
|
||||
"default": "預設",
|
||||
"custom": "自定義",
|
||||
"description": "記憶功能讓您儲存和管理與助手互動的資訊。您可以新增、編輯和刪除記憶,也可以對它們進行篩選和搜尋。",
|
||||
"global_memory_enabled": "全域記憶已啟用",
|
||||
"global_memory": "全域記憶",
|
||||
"enable_global_memory_first": "請先啟用全域記憶",
|
||||
"configure_memory_first": "請先配置記憶設定",
|
||||
"global_memory_disabled_title": "全域記憶已停用",
|
||||
"global_memory_disabled_desc": "要使用記憶功能,請先在助手設定中啟用全域記憶。",
|
||||
"not_configured_title": "記憶未配置",
|
||||
"not_configured_desc": "請在記憶設定中配置嵌入和LLM模型以啟用記憶功能。",
|
||||
"go_to_memory_page": "前往記憶頁面",
|
||||
"settings": "設定",
|
||||
"statistics": "統計",
|
||||
"search": "搜尋",
|
||||
"actions": "操作",
|
||||
"user_management": "使用者管理",
|
||||
"initial_memory_content": "歡迎!這是你的第一個記憶。",
|
||||
"loading": "載入記憶中...",
|
||||
"settings_title": "記憶體設定",
|
||||
"llm_model": "LLM 模型",
|
||||
"please_select_llm_model": "請選擇一個LLM模型",
|
||||
"select_llm_model_placeholder": "選擇LLM模型",
|
||||
"embedding_model": "嵌入模型",
|
||||
"please_select_embedding_model": "請選擇一個嵌入模型",
|
||||
"select_embedding_model_placeholder": "選擇嵌入模型",
|
||||
"embedding_dimensions": "嵌入維度",
|
||||
"stored_memories": "儲存的記憶",
|
||||
"global_memory_description": "需要開啟助手設定中的全域記憶才能使用"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import KeyvStorage from '@kangfenmao/keyv-storage'
|
||||
|
||||
import { startAutoSync, startLocalBackupAutoSync } from './services/BackupService'
|
||||
import { startAutoSync } from './services/BackupService'
|
||||
import { startNutstoreAutoSync } from './services/NutstoreService'
|
||||
import storeSyncService from './services/StoreSyncService'
|
||||
import store from './store'
|
||||
@@ -14,15 +14,12 @@ function initAutoSync() {
|
||||
setTimeout(() => {
|
||||
const { webdavAutoSync, localBackupAutoSync, s3 } = store.getState().settings
|
||||
const { nutstoreAutoSync } = store.getState().nutstore
|
||||
if (webdavAutoSync || (s3 && s3.autoSync)) {
|
||||
if (webdavAutoSync || (s3 && s3.autoSync) || localBackupAutoSync) {
|
||||
startAutoSync()
|
||||
}
|
||||
if (nutstoreAutoSync) {
|
||||
startNutstoreAutoSync()
|
||||
}
|
||||
if (localBackupAutoSync) {
|
||||
startLocalBackupAutoSync()
|
||||
}
|
||||
}, 8000)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Dropdown, message } from 'antd'
|
||||
import { Dropdown } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -61,14 +61,14 @@ const App: FC<Props> = ({ app, onClick, size = 60, isLast }) => {
|
||||
const customApps = JSON.parse(content)
|
||||
const updatedApps = customApps.filter((customApp: MinAppType) => customApp.id !== app.id)
|
||||
await window.api.file.writeWithId('custom-minapps.json', JSON.stringify(updatedApps, null, 2))
|
||||
message.success(t('settings.miniapps.custom.remove_success'))
|
||||
window.message.success(t('settings.miniapps.custom.remove_success'))
|
||||
const reloadedApps = [...ORIGIN_DEFAULT_MIN_APPS, ...(await loadCustomMiniApp())]
|
||||
updateDefaultMinApps(reloadedApps)
|
||||
updateMinapps(minapps.filter((item) => item.id !== app.id))
|
||||
updatePinnedMinapps(pinned.filter((item) => item.id !== app.id))
|
||||
updateDisabledMinapps(disabled.filter((item) => item.id !== app.id))
|
||||
} catch (error) {
|
||||
message.error(t('settings.miniapps.custom.remove_error'))
|
||||
window.message.error(t('settings.miniapps.custom.remove_error'))
|
||||
console.error('Failed to remove custom mini app:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PlusOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import { loadCustomMiniApp, ORIGIN_DEFAULT_MIN_APPS, updateDefaultMinApps } from '@renderer/config/minapps'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { Button, Form, Input, message, Modal, Radio, Upload } from 'antd'
|
||||
import { Button, Form, Input, Modal, Radio, Upload } from 'antd'
|
||||
import type { UploadFile } from 'antd/es/upload/interface'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -33,11 +33,11 @@ const NewAppButton: FC<Props> = ({ size = 60 }) => {
|
||||
|
||||
// Check for duplicate ID
|
||||
if (customApps.some((app: MinAppType) => app.id === values.id)) {
|
||||
message.error(t('settings.miniapps.custom.duplicate_ids', { ids: values.id }))
|
||||
window.message.error(t('settings.miniapps.custom.duplicate_ids', { ids: values.id }))
|
||||
return
|
||||
}
|
||||
if (ORIGIN_DEFAULT_MIN_APPS.some((app: MinAppType) => app.id === values.id)) {
|
||||
message.error(t('settings.miniapps.custom.conflicting_ids', { ids: values.id }))
|
||||
window.message.error(t('settings.miniapps.custom.conflicting_ids', { ids: values.id }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ const NewAppButton: FC<Props> = ({ size = 60 }) => {
|
||||
}
|
||||
customApps.push(newApp)
|
||||
await window.api.file.writeWithId('custom-minapps.json', JSON.stringify(customApps, null, 2))
|
||||
message.success(t('settings.miniapps.custom.save_success'))
|
||||
window.message.success(t('settings.miniapps.custom.save_success'))
|
||||
setIsModalVisible(false)
|
||||
form.resetFields()
|
||||
setFileList([])
|
||||
@@ -59,7 +59,7 @@ const NewAppButton: FC<Props> = ({ size = 60 }) => {
|
||||
updateDefaultMinApps(reloadedApps)
|
||||
updateMinapps([...minapps, newApp])
|
||||
} catch (error) {
|
||||
message.error(t('settings.miniapps.custom.save_error'))
|
||||
window.message.error(t('settings.miniapps.custom.save_error'))
|
||||
console.error('Failed to save custom mini app:', error)
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,14 @@ const NewAppButton: FC<Props> = ({ size = 60 }) => {
|
||||
reader.onload = (event) => {
|
||||
const base64Data = event.target?.result
|
||||
if (typeof base64Data === 'string') {
|
||||
message.success(t('settings.miniapps.custom.logo_upload_success'))
|
||||
window.message.success(t('settings.miniapps.custom.logo_upload_success'))
|
||||
form.setFieldValue('logo', base64Data)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
} catch (error) {
|
||||
console.error('Failed to read file:', error)
|
||||
message.error(t('settings.miniapps.custom.logo_upload_error'))
|
||||
window.message.error(t('settings.miniapps.custom.logo_upload_error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useActiveTopic } from '@renderer/hooks/useTopic'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import NavigationService from '@renderer/services/NavigationService'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { FC, startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -21,12 +21,32 @@ const HomePage: FC = () => {
|
||||
const location = useLocation()
|
||||
const state = location.state
|
||||
|
||||
const [activeAssistant, setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
|
||||
const { activeTopic, setActiveTopic } = useActiveTopic(activeAssistant, state?.topic)
|
||||
const [activeAssistant, _setActiveAssistant] = useState(state?.assistant || _activeAssistant || assistants[0])
|
||||
const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id, state?.topic)
|
||||
const { showAssistants, showTopics, topicPosition } = useSettings()
|
||||
|
||||
_activeAssistant = activeAssistant
|
||||
|
||||
const setActiveAssistant = useCallback(
|
||||
(newAssistant: Assistant) => {
|
||||
if (newAssistant.id === activeAssistant.id) return
|
||||
startTransition(() => {
|
||||
_setActiveAssistant(newAssistant)
|
||||
// 同步更新 active topic,避免不必要的重新渲染
|
||||
const newTopic = newAssistant.topics[0]
|
||||
_setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic))
|
||||
})
|
||||
},
|
||||
[_setActiveTopic, activeAssistant]
|
||||
)
|
||||
|
||||
const setActiveTopic = useCallback(
|
||||
(newTopic: Topic) => {
|
||||
startTransition(() => _setActiveTopic((prev) => (newTopic?.id === prev.id ? prev : newTopic)))
|
||||
},
|
||||
[_setActiveTopic]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
NavigationService.setNavigate(navigate)
|
||||
}, [navigate])
|
||||
|
||||
@@ -521,8 +521,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
async (event: ClipboardEvent) => {
|
||||
return await PasteService.handlePaste(
|
||||
event,
|
||||
isVisionModel(model),
|
||||
isGenerateImageModel(model),
|
||||
supportedExts,
|
||||
setFiles,
|
||||
setText,
|
||||
@@ -533,7 +531,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
t
|
||||
)
|
||||
},
|
||||
[model, pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportedExts, t, text]
|
||||
[pasteLongTextAsFile, pasteLongTextThreshold, resizeTextArea, supportedExts, t, text]
|
||||
)
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -962,7 +960,7 @@ const InputBarContainer = styled.div`
|
||||
border: 0.5px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
border-radius: 17px;
|
||||
padding-top: 8px; // 为拖动手柄留出空间
|
||||
background-color: var(--color-background-opacity);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FileSearch,
|
||||
Globe,
|
||||
Languages,
|
||||
Link,
|
||||
LucideSquareTerminal,
|
||||
Maximize,
|
||||
MessageSquareDiff,
|
||||
@@ -36,6 +37,7 @@ import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButt
|
||||
import NewContextButton from './NewContextButton'
|
||||
import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton'
|
||||
import ThinkingButton, { ThinkingButtonRef } from './ThinkingButton'
|
||||
import UrlContextButton, { UrlContextButtonRef } from './UrlContextbutton'
|
||||
import WebSearchButton, { WebSearchButtonRef } from './WebSearchButton'
|
||||
|
||||
export interface InputbarToolsRef {
|
||||
@@ -128,6 +130,7 @@ const InputbarTools = ({
|
||||
const attachmentButtonRef = useRef<AttachmentButtonRef>(null)
|
||||
const webSearchButtonRef = useRef<WebSearchButtonRef | null>(null)
|
||||
const thinkingButtonRef = useRef<ThinkingButtonRef | null>(null)
|
||||
const urlContextButtonRef = useRef<UrlContextButtonRef | null>(null)
|
||||
|
||||
const toolOrder = useAppSelector((state) => state.inputTools.toolOrder)
|
||||
const isCollapse = useAppSelector((state) => state.inputTools.isCollapsed)
|
||||
@@ -230,6 +233,15 @@ const InputbarTools = ({
|
||||
webSearchButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.input.url_context'),
|
||||
description: '',
|
||||
icon: <Link />,
|
||||
isMenu: true,
|
||||
action: () => {
|
||||
urlContextButtonRef.current?.openQuickPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: couldAddImageFile ? t('chat.input.upload') : t('chat.input.upload.document'),
|
||||
description: '',
|
||||
@@ -328,6 +340,12 @@ const InputbarTools = ({
|
||||
label: t('chat.input.web_search'),
|
||||
component: <WebSearchButton ref={webSearchButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />
|
||||
},
|
||||
{
|
||||
key: 'url_context',
|
||||
label: t('chat.input.url_context'),
|
||||
component: <UrlContextButton ref={urlContextButtonRef} assistant={assistant} ToolbarButton={ToolbarButton} />,
|
||||
condition: model.id.toLowerCase().includes('gemini')
|
||||
},
|
||||
{
|
||||
key: 'knowledge_base',
|
||||
label: t('chat.input.knowledge_base'),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useQuickPanel } from '@renderer/components/QuickPanel'
|
||||
import {
|
||||
GEMINI_FLASH_MODEL_REGEX,
|
||||
isDoubaoThinkingAutoModel,
|
||||
isOpenAIDeepResearchModel,
|
||||
isSupportedReasoningEffortGrokModel,
|
||||
isSupportedThinkingTokenDoubaoModel,
|
||||
isSupportedThinkingTokenGeminiModel,
|
||||
@@ -40,7 +41,8 @@ const MODEL_SUPPORTED_OPTIONS: Record<string, ThinkingOption[]> = {
|
||||
gemini: ['off', 'low', 'medium', 'high', 'auto'],
|
||||
gemini_pro: ['low', 'medium', 'high', 'auto'],
|
||||
qwen: ['off', 'low', 'medium', 'high'],
|
||||
doubao: ['off', 'auto', 'high']
|
||||
doubao: ['off', 'auto', 'high'],
|
||||
openai_deep_research: ['off', 'medium']
|
||||
}
|
||||
|
||||
// 选项转换映射表:当选项不支持时使用的替代选项
|
||||
@@ -48,7 +50,7 @@ const OPTION_FALLBACK: Record<ThinkingOption, ThinkingOption> = {
|
||||
off: 'low', // off -> low (for Gemini Pro models)
|
||||
low: 'high',
|
||||
medium: 'high', // medium -> high (for Grok models)
|
||||
high: 'high',
|
||||
high: 'medium',
|
||||
auto: 'high' // auto -> high (for non-Gemini models)
|
||||
}
|
||||
|
||||
@@ -62,6 +64,7 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
const isGeminiFlashModel = GEMINI_FLASH_MODEL_REGEX.test(model.id)
|
||||
const isQwenModel = isSupportedThinkingTokenQwenModel(model)
|
||||
const isDoubaoModel = isSupportedThinkingTokenDoubaoModel(model)
|
||||
const isDeepResearchModel = isOpenAIDeepResearchModel(model)
|
||||
|
||||
const currentReasoningEffort = useMemo(() => {
|
||||
return assistant.settings?.reasoning_effort || 'off'
|
||||
@@ -79,8 +82,9 @@ const ThinkingButton: FC<Props> = ({ ref, model, assistant, ToolbarButton }): Re
|
||||
if (isGrokModel) return 'grok'
|
||||
if (isQwenModel) return 'qwen'
|
||||
if (isDoubaoModel) return 'doubao'
|
||||
if (isDeepResearchModel) return 'openai_deep_research'
|
||||
return 'default'
|
||||
}, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel, isGeminiFlashModel])
|
||||
}, [isGeminiModel, isGrokModel, isQwenModel, isDoubaoModel, isDeepResearchModel, isGeminiFlashModel])
|
||||
|
||||
// 获取当前模型支持的选项
|
||||
const supportedOptions = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Link } from 'lucide-react'
|
||||
import { FC, memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface UrlContextButtonRef {
|
||||
openQuickPanel: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
ref?: React.RefObject<UrlContextButtonRef | null>
|
||||
assistant: Assistant
|
||||
ToolbarButton: any
|
||||
}
|
||||
|
||||
const UrlContextButton: FC<Props> = ({ assistant, ToolbarButton }) => {
|
||||
const { t } = useTranslation()
|
||||
const { updateAssistant } = useAssistant(assistant.id)
|
||||
|
||||
const urlContentNewState = !assistant.enableUrlContext
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
updateAssistant({ ...assistant, enableUrlContext: urlContentNewState })
|
||||
}, 100)
|
||||
}, [assistant, urlContentNewState, updateAssistant])
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" title={t('chat.input.url_context')} arrow>
|
||||
<ToolbarButton type="text" onClick={handleToggle}>
|
||||
<Link
|
||||
size={18}
|
||||
style={{
|
||||
color: assistant.enableUrlContext ? 'var(--color-link)' : 'var(--color-icon)'
|
||||
}}
|
||||
/>
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UrlContextButton)
|
||||
@@ -6,7 +6,12 @@ import ImageViewer from '@renderer/components/ImageViewer'
|
||||
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
|
||||
import type {
|
||||
DeepResearchMessageBlock,
|
||||
MainTextMessageBlock,
|
||||
ThinkingMessageBlock,
|
||||
TranslationMessageBlock
|
||||
} from '@renderer/types/newMessage'
|
||||
import { parseJSON } from '@renderer/utils'
|
||||
import { removeSvgEmptyLines } from '@renderer/utils/formats'
|
||||
import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown'
|
||||
@@ -21,6 +26,7 @@ import rehypeRaw from 'rehype-raw'
|
||||
import remarkCjkFriendly from 'remark-cjk-friendly'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { Pluggable } from 'unified'
|
||||
|
||||
import CodeBlock from './CodeBlock'
|
||||
import Link from './Link'
|
||||
@@ -33,7 +39,7 @@ const DISALLOWED_ELEMENTS = ['iframe']
|
||||
|
||||
interface Props {
|
||||
// message: Message & { content: string }
|
||||
block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock
|
||||
block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock | DeepResearchMessageBlock
|
||||
}
|
||||
|
||||
const Markdown: FC<Props> = ({ block }) => {
|
||||
@@ -41,7 +47,11 @@ const Markdown: FC<Props> = ({ block }) => {
|
||||
const { mathEngine } = useSettings()
|
||||
|
||||
const remarkPlugins = useMemo(() => {
|
||||
const plugins = [remarkGfm, remarkCjkFriendly, remarkDisableConstructs(['codeIndented'])]
|
||||
const plugins = [
|
||||
[remarkGfm, { singleTilde: false }] as Pluggable,
|
||||
remarkCjkFriendly,
|
||||
remarkDisableConstructs(['codeIndented'])
|
||||
]
|
||||
if (mathEngine !== 'none') {
|
||||
plugins.push(remarkMath)
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ function CitationBlock({ block }: { block: CitationMessageBlock }) {
|
||||
return (
|
||||
(formattedCitations && formattedCitations.length > 0) ||
|
||||
hasGeminiBlock ||
|
||||
(block.knowledge && block.knowledge.length > 0)
|
||||
(block.knowledge && block.knowledge.length > 0) ||
|
||||
(block.memories && block.memories.length > 0)
|
||||
)
|
||||
}, [formattedCitations, block.knowledge, hasGeminiBlock])
|
||||
}, [formattedCitations, block.knowledge, block.memories, hasGeminiBlock])
|
||||
|
||||
const getWebSearchStatusText = (requestId: string) => {
|
||||
const status = websearch.activeSearches[requestId] ?? { phase: 'default' }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import DeepResearchCard from '@renderer/components/DeepResearchCard'
|
||||
import type { DeepResearchMessageBlock } from '@renderer/types/newMessage'
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
block: DeepResearchMessageBlock
|
||||
}
|
||||
|
||||
const DeepResearchBlock: React.FC<Props> = ({ block }) => {
|
||||
return <DeepResearchCard block={block} />
|
||||
}
|
||||
|
||||
export default React.memo(DeepResearchBlock)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user