Merge branch 'kangfenmao:main' into develop

This commit is contained in:
牡丹凤凰 2024-12-07 12:21:15 +08:00 committed by GitHub
commit e35d928bcd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
155 changed files with 7860 additions and 3838 deletions

View File

@ -2,4 +2,4 @@ node_modules
dist
out
.gitignore
scripts/cloudflare-worker.js

View File

@ -0,0 +1,73 @@
name: 🐛 错误报告
description: 创建一个报告以帮助我们改进
title: '[错误]: '
labels: ['bug']
body:
- type: markdown
attributes:
value: |
感谢您花时间填写此错误报告!
- type: dropdown
id: platform
attributes:
label: 平台
description: 您正在使用哪个平台?
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: 版本
description: 您正在运行的 Cherry Studio 版本是什么?
placeholder: 例如 v1.0.0
validations:
required: true
- type: textarea
id: description
attributes:
label: 错误描述
description: 清晰简洁地描述错误是什么
placeholder: 告诉我们发生了什么...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: 重现步骤
description: 重现行为的步骤
placeholder: |
1. 转到 '...'
2. 点击 '....'
3. 向下滚动到 '....'
4. 看到错误
validations:
required: true
- type: textarea
id: expected
attributes:
label: 预期行为
description: 清晰简洁地描述您期望发生的事情
validations:
required: true
- type: textarea
id: logs
attributes:
label: 相关日志输出
description: 请复制并粘贴任何相关的日志输出
render: shell
- type: textarea
id: additional
attributes:
label: 附加信息
description: 在此添加有关问题的任何其他上下文

View File

@ -0,0 +1,38 @@
name: 💡 功能建议
description: 为项目提出新的想法
title: '[功能]: '
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
感谢您花时间提出新的功能建议!
- type: textarea
id: problem
attributes:
label: 您的功能建议是否与某个问题相关?
description: 请简明扼要地描述您遇到的问题
placeholder: 我总是感到沮丧,因为...
validations:
required: true
- type: textarea
id: solution
attributes:
label: 请描述您希望实现的解决方案
description: 请简明扼要地描述您希望发生的情况
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 请描述您考虑过的其他方案
description: 请简明扼要地描述您考虑过的任何其他解决方案或功能
- type: textarea
id: additional
attributes:
label: 其他补充信息
description: 在此添加任何其他与功能建议相关的上下文或截图

44
.github/ISSUE_TEMPLATE/#2_question.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: ❓ 提问
description: 提出一个问题或寻求帮助
title: '[问题]: '
labels: ['question']
body:
- type: markdown
attributes:
value: |
感谢您的提问!请尽可能详细地描述您的问题,这样我们才能更好地帮助您。
- type: textarea
id: question
attributes:
label: 您的问题
description: 请详细描述您的问题
placeholder: 请尽可能清楚地说明您的问题...
validations:
required: true
- type: textarea
id: context
attributes:
label: 相关背景
description: 请提供一些背景信息,帮助我们更好地理解您的问题
placeholder: 例如:使用场景、已尝试的解决方案等
- type: textarea
id: additional
attributes:
label: 补充信息
description: 任何其他相关的信息、截图或代码示例
render: shell
- type: dropdown
id: priority
attributes:
label: 优先级
description: 这个问题对您来说有多紧急?
options:
- 低 (有空再看)
- 中 (希望尽快得到答复)
- 高 (阻碍工作进行)
validations:
required: true

73
.github/ISSUE_TEMPLATE/0_bug_report.yml vendored Normal file
View File

@ -0,0 +1,73 @@
name: 🐛 Bug Report
description: Create a report to help us improve
title: '[Bug]: '
labels: ['bug']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: dropdown
id: platform
attributes:
label: Platform
description: What platform are you using?
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version of Cherry Studio are you running?
placeholder: e.g. v1.0.0
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is
placeholder: Tell us what happened...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps To Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Log Output
description: Please copy and paste any relevant log output
render: shell
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context about the problem here

View File

@ -0,0 +1,38 @@
name: 💡 Feature Request
description: Suggest an idea for this project
title: '[Feature]: '
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest a new feature!
- type: textarea
id: problem
attributes:
label: Is your feature request related to a problem?
description: A clear and concise description of what the problem is
placeholder: I'm always frustrated when...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here

44
.github/ISSUE_TEMPLATE/2_question.yml vendored Normal file
View File

@ -0,0 +1,44 @@
name: ❓ Question
description: Ask a question or seek help
title: '[Question]: '
labels: ['question']
body:
- type: markdown
attributes:
value: |
Thanks for asking a question! Please provide as much detail as possible so we can better assist you.
- type: textarea
id: question
attributes:
label: Your Question
description: Please describe your question in detail
placeholder: Please explain your question as clearly as possible...
validations:
required: true
- type: textarea
id: context
attributes:
label: Context
description: Please provide some background information to help us better understand your question
placeholder: "For example: use case, solutions you've tried, etc."
- type: textarea
id: additional
attributes:
label: Additional Information
description: Any other relevant information, screenshots, or code examples
render: shell
- type: dropdown
id: priority
attributes:
label: Priority
description: How urgent is this question for you?
options:
- Low (Can wait)
- Medium (Would like a response soon)
- High (Blocking progress)
validations:
required: true

View File

@ -1,6 +1,12 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version (e.g. v1.2.3)'
required: true
type: string
push:
tags:
- v*.*.*

View File

@ -0,0 +1,26 @@
diff --git a/core.js b/core.js
index 00b67a48b7b5cf0029413fc84abd0c01630c3d14..5550b58495b468060f775ca86e4d849d82573ea5 100644
--- a/core.js
+++ b/core.js
@@ -156,7 +156,7 @@ class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
...this.authHeaders(opts),
};
}
diff --git a/core.mjs b/core.mjs
index 8bc7a0ee10d61560d7113cf3f703355bb19f7ddd..5e4c8586ea6b13fe887a22af2de05eaa4700b5ec 100644
--- a/core.mjs
+++ b/core.mjs
@@ -149,7 +149,7 @@ export class APIClient {
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': this.getUserAgent(),
- ...getPlatformHeaders(),
+ // ...getPlatformHeaders(),
...this.authHeaders(opts),
};
}

BIN
build/tray_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
build/tray_icon_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
build/tray_icon_light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -133,7 +133,7 @@ Cherry Studioへの貢献を歓迎します以下の方法で貢献できま
# 📃 ライセンス
[LICENSE](./LICENSE)
[LICENSE](../LICENSE)
# ⭐️ スター履歴

View File

@ -134,7 +134,7 @@ $ yarn build:linux
# 📃 许可证
[LICENSE](./LICENSE)
[LICENSE](../LICENSE)
# ⭐️ Star 记录

View File

@ -63,6 +63,6 @@ electronDownload:
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
支持聊天气泡样式和简洁样式切换
支持导出对话为 Word 文档
错误修复
修复快捷键设置错误导致的无法启动问题
修复翻译按钮无法正常输出内容问题
修复检测更新按钮逻辑错误

View File

@ -7,8 +7,9 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@main': resolve('src/main'),
'@types': resolve('src/renderer/src/types'),
'@main': resolve('src/main')
'@shared': resolve('packages/shared')
}
}
},
@ -16,11 +17,15 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()]
},
renderer: {
plugins: [react()],
resolve: {
alias: {
'@renderer': resolve('src/renderer/src')
'@renderer': resolve('src/renderer/src'),
'@shared': resolve('packages/shared')
}
},
plugins: [react()]
optimizeDeps: {
exclude: []
}
}
})

View File

@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "0.8.9",
"version": "0.8.23",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@ -38,7 +38,7 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"archiver": "^7.0.1",
"adm-zip": "^0.5.16",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
@ -48,7 +48,6 @@
"html2canvas": "^1.4.1",
"markdown-it": "^14.1.0",
"officeparser": "^4.1.1",
"unzipper": "^0.12.3",
"webdav": "4.11.4"
},
"devDependencies": {
@ -60,6 +59,7 @@
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@reduxjs/toolkit": "^2.2.5",
"@types/adm-zip": "^0",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
@ -67,7 +67,6 @@
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/tinycolor2": "^1",
"@types/unzipper": "^0",
"@vitejs/plugin-react": "^4.2.1",
"antd": "^5.18.3",
"axios": "^1.7.3",
@ -90,20 +89,19 @@
"eslint-plugin-unused-imports": "^4.0.0",
"gpt-tokens": "^1.3.10",
"i18next": "^23.11.5",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mime": "^4.0.4",
"openai": "^4.52.1",
"openai": "patch:openai@npm%3A4.71.1#~/.yarn/patches/openai-npm-4.71.1-b5940d6401.patch",
"prettier": "^3.2.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-markdown": "^9.0.1",
"react-redux": "^9.1.2",
"react-router": "6",
"react-router-dom": "6",
"react-spinners": "^0.14.1",
"react-syntax-highlighter": "^15.5.0",
"redux": "^5.0.1",
"redux-persist": "^6.0.0",
"rehype-katex": "^7.0.1",
@ -112,6 +110,7 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"sass": "^1.77.2",
"shiki": "^1.22.2",
"styled-components": "^6.1.11",
"tinycolor2": "^1.6.0",
"typescript": "^5.6.2",

View File

@ -0,0 +1,112 @@
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const textExts = [
'.txt', // 普通文本文件
'.md', // Markdown 文件
'.mdx', // Markdown 文件
'.html', // HTML 文件
'.htm', // HTML 文件的另一种扩展名
'.xml', // XML 文件
'.json', // JSON 文件
'.yaml', // YAML 文件
'.yml', // YAML 文件的另一种扩展名
'.csv', // 逗号分隔值文件
'.tsv', // 制表符分隔值文件
'.ini', // 配置文件
'.log', // 日志文件
'.rtf', // 富文本格式文件
'.tex', // LaTeX 文件
'.srt', // 字幕文件
'.xhtml', // XHTML 文件
'.nfo', // 信息文件(主要用于场景发布)
'.conf', // 配置文件
'.config', // 配置文件
'.env', // 环境变量文件
'.rst', // reStructuredText 文件
'.php', // PHP 脚本文件,包含嵌入的 HTML
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
'.ts', // TypeScript 文件
'.jsp', // JavaServer Pages 文件
'.aspx', // ASP.NET 文件
'.bat', // Windows 批处理文件
'.sh', // Unix/Linux Shell 脚本文件
'.py', // Python 脚本文件
'.rb', // Ruby 脚本文件
'.pl', // Perl 脚本文件
'.sql', // SQL 脚本文件
'.css', // Cascading Style Sheets 文件
'.less', // Less CSS 预处理器文件
'.scss', // Sass CSS 预处理器文件
'.sass', // Sass 文件
'.styl', // Stylus CSS 预处理器文件
'.coffee', // CoffeeScript 文件
'.ino', // Arduino 代码文件
'.asm', // Assembly 语言文件
'.go', // Go 语言文件
'.scala', // Scala 语言文件
'.swift', // Swift 语言文件
'.kt', // Kotlin 语言文件
'.rs', // Rust 语言文件
'.lua', // Lua 语言文件
'.groovy', // Groovy 语言文件
'.dart', // Dart 语言文件
'.hs', // Haskell 语言文件
'.clj', // Clojure 语言文件
'.cljs', // ClojureScript 语言文件
'.elm', // Elm 语言文件
'.erl', // Erlang 语言文件
'.ex', // Elixir 语言文件
'.exs', // Elixir 脚本文件
'.pug', // Pug (formerly Jade) 模板文件
'.haml', // Haml 模板文件
'.slim', // Slim 模板文件
'.tpl', // 模板文件(通用)
'.ejs', // Embedded JavaScript 模板文件
'.hbs', // Handlebars 模板文件
'.mustache', // Mustache 模板文件
'.jade', // Jade 模板文件 (已重命名为 Pug)
'.twig', // Twig 模板文件
'.blade', // Blade 模板文件 (Laravel)
'.vue', // Vue.js 单文件组件
'.jsx', // React JSX 文件
'.tsx', // React TSX 文件
'.graphql', // GraphQL 查询语言文件
'.gql', // GraphQL 查询语言文件
'.proto', // Protocol Buffers 文件
'.thrift', // Thrift 文件
'.toml', // TOML 配置文件
'.edn', // Clojure 数据表示文件
'.cake', // CakePHP 配置文件
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.kts', // Kotlin Script 文件
'.java' // Java 代码文件
]
export const ZOOM_SHORTCUTS = [
{
key: 'zoom_in',
shortcut: ['CommandOrControl', '='],
editable: false,
enabled: true
},
{
key: 'zoom_out',
shortcut: ['CommandOrControl', '-'],
editable: false,
enabled: true
},
{
key: 'zoom_reset',
shortcut: ['CommandOrControl', '0'],
editable: false,
enabled: true
}
]

View File

@ -0,0 +1,595 @@
// 配置信息
const config = {
R2_CUSTOM_DOMAIN: 'cherrystudio.ocool.online',
R2_BUCKET_NAME: 'cherrystudio',
// 缓存键名
CACHE_KEY: 'cherry-studio-latest-release',
VERSION_DB: 'versions.json',
LOG_FILE: 'logs.json',
MAX_LOGS: 1000 // 最多保存多少条日志
};
// Worker 入口函数
const worker = {
// 定时器触发配置
scheduled: {
cron: '*/1 * * * *' // 每分钟执行一次
},
// 定时器执行函数 - 只负责检查和更新
async scheduled(event, env, ctx) {
try {
await initDataFiles(env);
console.log('开始定时检查新版本...');
// 注意这里使用新的函数
await checkNewRelease(env);
} catch (error) {
console.error('定时任务执行失败:', error);
}
},
// HTTP 请求处理函数 - 只负责返回数据
async fetch(request, env, ctx) {
if (!env || !env.R2_BUCKET) {
return new Response(JSON.stringify({
error: 'R2 存储桶未正确配置'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
const url = new URL(request.url);
const filename = url.pathname.slice(1);
try {
// 处理文件下载请求
if (filename) {
return await handleDownload(env, filename);
}
// 只返回缓存的版本信息
return await getCachedRelease(env);
} catch (error) {
return new Response(JSON.stringify({
error: error.message,
stack: error.stack
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
};
export default worker;
/**
* 添加日志记录函数
*/
async function addLog(env, type, event, details = null) {
try {
const logFile = await env.R2_BUCKET.get(config.LOG_FILE);
let logs = { logs: [] };
if (logFile) {
logs = JSON.parse(await logFile.text());
}
logs.logs.unshift({
timestamp: new Date().toISOString(),
type,
event,
details
});
// 保持日志数量在限制内
if (logs.logs.length > config.MAX_LOGS) {
logs.logs = logs.logs.slice(0, config.MAX_LOGS);
}
await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(logs, null, 2));
} catch (error) {
console.error('写入日志失败:', error);
}
}
/**
* 检查并更新发布版本
* 由定时器触发检查新版本并更新 R2 存储
*/
async function checkAndUpdateRelease(env) {
try {
// 获取版本数据库
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB);
let versions = { versions: {}, latestVersion: null, lastChecked: null };
if (versionDB) {
versions = JSON.parse(await versionDB.text());
}
// 获取 GitHub 最新版本
const githubResponse = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases/latest', {
headers: { 'User-Agent': 'CloudflareWorker' },
});
if (!githubResponse.ok) {
throw new Error('GitHub API 请求失败');
}
const releaseData = await githubResponse.json();
const version = releaseData.tag_name;
// 更新最后检查时间
versions.lastChecked = new Date().toISOString();
// 检查是否需要更新
if (versions.latestVersion !== version) {
await addLog(env, 'INFO', `发现新版本: ${version}`);
// 准备新版本记录
const versionRecord = {
version,
publishedAt: releaseData.published_at,
uploadedAt: null,
files: releaseData.assets.map(asset => ({
name: asset.name,
size: asset.size,
uploaded: false
})),
changelog: releaseData.body
};
// 上传文件
for (const asset of releaseData.assets) {
try {
const existingFile = await env.R2_BUCKET.get(asset.name);
if (existingFile) {
// 更新文件状态
const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name);
if (fileIndex !== -1) {
versionRecord.files[fileIndex].uploaded = true;
}
continue;
}
const response = await fetch(asset.browser_download_url);
if (!response.ok) {
throw new Error(`下载失败: HTTP ${response.status}`);
}
const file = await response.arrayBuffer();
await env.R2_BUCKET.put(asset.name, file, {
httpMetadata: { contentType: getContentType(asset.name) }
});
// 更新文件状态
const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name);
if (fileIndex !== -1) {
versionRecord.files[fileIndex].uploaded = true;
}
await addLog(env, 'INFO', `文件上传成功: ${asset.name}`);
} catch (error) {
await addLog(env, 'ERROR', `文件上传失败: ${asset.name}`, error.message);
}
}
// 更新版本记录
versionRecord.uploadedAt = new Date().toISOString();
versions.versions[version] = versionRecord;
versions.latestVersion = version;
// 保存版本数据库
await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2));
// 更新缓存
const cacheData = {
version,
publishedAt: releaseData.published_at,
changelog: releaseData.body,
downloads: versionRecord.files
.filter(file => file.uploaded)
.map(file => ({
name: file.name,
url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`,
size: formatFileSize(file.size)
}))
};
await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData));
// 清理旧版本
const versionList = Object.keys(versions.versions).sort((a, b) => compareVersions(b, a));
if (versionList.length > 2) {
const oldVersions = versionList.slice(2);
for (const oldVersion of oldVersions) {
const oldFiles = versions.versions[oldVersion].files;
for (const file of oldFiles) {
if (file.uploaded) {
await env.R2_BUCKET.delete(file.name);
await addLog(env, 'INFO', `删除旧文件: ${file.name}`);
}
}
delete versions.versions[oldVersion];
}
// 保存更新后的版本数据库
await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2));
}
return cacheData;
} else {
// 没有新版本,返回缓存数据
const cached = await env.R2_BUCKET.get(config.CACHE_KEY);
return cached ? JSON.parse(await cached.text()) : null;
}
} catch (error) {
await addLog(env, 'ERROR', '检查更新失败', error.message);
throw error;
}
}
/**
* 获取最新版本信息
*/
async function getLatestRelease(env) {
try {
const cached = await env.R2_BUCKET.get(config.CACHE_KEY);
if (!cached) {
// 如果缓存不存在,先检查版本数据库
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB);
if (versionDB) {
const versions = JSON.parse(await versionDB.text());
if (versions.latestVersion) {
// 从版本数据库重建缓存
const latestVersion = versions.versions[versions.latestVersion];
const cacheData = {
version: latestVersion.version,
publishedAt: latestVersion.publishedAt,
changelog: latestVersion.changelog,
downloads: latestVersion.files
.filter(file => file.uploaded)
.map(file => ({
name: file.name,
url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`,
size: formatFileSize(file.size)
}))
};
// 更新缓存
await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData));
return new Response(JSON.stringify(cacheData), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
}
// 如果版本数据库也没有数据,才执行检查更新
const data = await checkAndUpdateRelease(env);
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
const data = await cached.text();
return new Response(data, {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
await addLog(env, 'ERROR', '获取版本信息失败', error.message);
return new Response(JSON.stringify({
error: '获取版本信息失败: ' + error.message,
detail: '请稍后再试'
}), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
}
// 修改下载处理函数,直接接收 env
async function handleDownload(env, filename) {
try {
const object = await env.R2_BUCKET.get(filename);
if (!object) {
return new Response('文件未找到', { status: 404 });
}
// 设置响应头
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('Content-Disposition', `attachment; filename="${filename}"`);
return new Response(object.body, {
headers
});
} catch (error) {
console.error('下载文件时发生错误:', error);
return new Response('获取文件失败', { status: 500 });
}
}
/**
* 根据文件扩展名获取对应的 Content-Type
*/
function getContentType(filename) {
const ext = filename.split('.').pop().toLowerCase();
const types = {
'exe': 'application/x-msdownload', // Windows 可执行文件
'dmg': 'application/x-apple-diskimage', // macOS 安装包
'zip': 'application/zip', // 压缩包
'AppImage': 'application/x-executable', // Linux 可执行文件
'blockmap': 'application/octet-stream' // 更新文件
};
return types[ext] || 'application/octet-stream';
}
/**
* 格式化文件大小
* 将字节转换为人类可读的格式B, KB, MB, GB
*/
function formatFileSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
/**
* 版本号比较函数
* 用于对版本号进行排序
*/
function compareVersions(a, b) {
const partsA = a.replace('v', '').split('.');
const partsB = b.replace('v', '').split('.');
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const numA = parseInt(partsA[i] || 0);
const numB = parseInt(partsB[i] || 0);
if (numA !== numB) {
return numA - numB;
}
}
return 0;
}
/**
* 初始化数据文件
*/
async function initDataFiles(env) {
try {
// 检查并初始化版本数据库
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB);
if (!versionDB) {
const initialVersions = {
versions: {},
latestVersion: null,
lastChecked: new Date().toISOString()
};
await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(initialVersions, null, 2));
await addLog(env, 'INFO', 'versions.json 初始化成功');
}
// 检查并初始化日志文件
const logFile = await env.R2_BUCKET.get(config.LOG_FILE);
if (!logFile) {
const initialLogs = {
logs: [{
timestamp: new Date().toISOString(),
type: 'INFO',
event: '系统初始化'
}]
};
await env.R2_BUCKET.put(config.LOG_FILE, JSON.stringify(initialLogs, null, 2));
console.log('logs.json 初始化成功');
}
} catch (error) {
console.error('初始化数据文件失败:', error);
}
}
// 新增:只获取缓存的版本信息
async function getCachedRelease(env) {
try {
const cached = await env.R2_BUCKET.get(config.CACHE_KEY);
if (!cached) {
// 如果缓存不存在,从版本数据库获取
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB);
if (versionDB) {
const versions = JSON.parse(await versionDB.text());
if (versions.latestVersion) {
const latestVersion = versions.versions[versions.latestVersion];
const cacheData = {
version: latestVersion.version,
publishedAt: latestVersion.publishedAt,
changelog: latestVersion.changelog,
downloads: latestVersion.files
.filter(file => file.uploaded)
.map(file => ({
name: file.name,
url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`,
size: formatFileSize(file.size)
}))
};
// 重建缓存
await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData));
return new Response(JSON.stringify(cacheData), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
}
// 如果没有任何数据,返回错误
return new Response(JSON.stringify({
error: '没有可用的版本信息'
}), {
status: 404,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
// 返回缓存数据
return new Response(await cached.text(), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
await addLog(env, 'ERROR', '获取缓存版本信息失败', error.message);
throw error;
}
}
// 新增:只检查新版本并更新
async function checkNewRelease(env) {
try {
// 获取 GitHub 最新版本
const githubResponse = await fetch('https://api.github.com/repos/kangfenmao/cherry-studio/releases/latest', {
headers: { 'User-Agent': 'CloudflareWorker' },
});
if (!githubResponse.ok) {
throw new Error('GitHub API 请求失败');
}
const releaseData = await githubResponse.json();
const version = releaseData.tag_name;
// 获取版本数据库
const versionDB = await env.R2_BUCKET.get(config.VERSION_DB);
let versions = { versions: {}, latestVersion: null, lastChecked: new Date().toISOString() };
if (versionDB) {
versions = JSON.parse(await versionDB.text());
}
// 如果版本相同,不需要更新
if (versions.latestVersion === version) {
console.log('当前已是最新版本');
return;
}
await addLog(env, 'INFO', `发现新版本: ${version}`);
// 准备新版本记录
const versionRecord = {
version,
publishedAt: releaseData.published_at,
uploadedAt: null,
files: releaseData.assets.map(asset => ({
name: asset.name,
size: asset.size,
uploaded: false
})),
changelog: releaseData.body
};
// 上传文件
for (const asset of releaseData.assets) {
try {
const existingFile = await env.R2_BUCKET.get(asset.name);
if (existingFile) {
// 更新文件状态
const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name);
if (fileIndex !== -1) {
versionRecord.files[fileIndex].uploaded = true;
}
continue;
}
const response = await fetch(asset.browser_download_url);
if (!response.ok) {
throw new Error(`下载失败: HTTP ${response.status}`);
}
const file = await response.arrayBuffer();
await env.R2_BUCKET.put(asset.name, file, {
httpMetadata: { contentType: getContentType(asset.name) }
});
// 更新文件状态
const fileIndex = versionRecord.files.findIndex(f => f.name === asset.name);
if (fileIndex !== -1) {
versionRecord.files[fileIndex].uploaded = true;
}
await addLog(env, 'INFO', `文件上传成功: ${asset.name}`);
} catch (error) {
await addLog(env, 'ERROR', `文件上传失败: ${asset.name}`, error.message);
}
}
// 更新版本记录
versionRecord.uploadedAt = new Date().toISOString();
versions.versions[version] = versionRecord;
versions.latestVersion = version;
// 保存版本数据库
await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2));
// 更新缓存
const cacheData = {
version,
publishedAt: releaseData.published_at,
changelog: releaseData.body,
downloads: versionRecord.files
.filter(file => file.uploaded)
.map(file => ({
name: file.name,
url: `https://${config.R2_CUSTOM_DOMAIN}/${file.name}`,
size: formatFileSize(file.size)
}))
};
await env.R2_BUCKET.put(config.CACHE_KEY, JSON.stringify(cacheData));
// 清理旧版本
const versionList = Object.keys(versions.versions).sort((a, b) => compareVersions(b, a));
if (versionList.length > 2) {
const oldVersions = versionList.slice(2);
for (const oldVersion of oldVersions) {
const oldFiles = versions.versions[oldVersion].files;
for (const file of oldFiles) {
if (file.uploaded) {
await env.R2_BUCKET.delete(file.name);
await addLog(env, 'INFO', `删除旧文件: ${file.name}`);
}
}
delete versions.versions[oldVersion];
}
// 保存更新后的版本数据库
await env.R2_BUCKET.put(config.VERSION_DB, JSON.stringify(versions, null, 2));
}
return cacheData;
} catch (error) {
await addLog(env, 'ERROR', '检查新版本失败', error.message);
throw error;
}
}

View File

@ -1,91 +1,3 @@
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
export const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const textExts = [
'.txt', // 普通文本文件
'.md', // Markdown 文件
'.mdx', // Markdown 文件
'.html', // HTML 文件
'.htm', // HTML 文件的另一种扩展名
'.xml', // XML 文件
'.json', // JSON 文件
'.yaml', // YAML 文件
'.yml', // YAML 文件的另一种扩展名
'.csv', // 逗号分隔值文件
'.tsv', // 制表符分隔值文件
'.ini', // 配置文件
'.log', // 日志文件
'.rtf', // 富文本格式文件
'.tex', // LaTeX 文件
'.srt', // 字幕文件
'.xhtml', // XHTML 文件
'.nfo', // 信息文件(主要用于场景发布)
'.conf', // 配置文件
'.config', // 配置文件
'.env', // 环境变量文件
'.rst', // reStructuredText 文件
'.php', // PHP 脚本文件,包含嵌入的 HTML
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
'.ts', // TypeScript 文件
'.jsp', // JavaServer Pages 文件
'.aspx', // ASP.NET 文件
'.bat', // Windows 批处理文件
'.sh', // Unix/Linux Shell 脚本文件
'.py', // Python 脚本文件
'.rb', // Ruby 脚本文件
'.pl', // Perl 脚本文件
'.sql', // SQL 脚本文件
'.css', // Cascading Style Sheets 文件
'.less', // Less CSS 预处理器文件
'.scss', // Sass CSS 预处理器文件
'.sass', // Sass 文件
'.styl', // Stylus CSS 预处理器文件
'.coffee', // CoffeeScript 文件
'.ino', // Arduino 代码文件
'.asm', // Assembly 语言文件
'.go', // Go 语言文件
'.scala', // Scala 语言文件
'.swift', // Swift 语言文件
'.kt', // Kotlin 语言文件
'.rs', // Rust 语言文件
'.lua', // Lua 语言文件
'.groovy', // Groovy 语言文件
'.dart', // Dart 语言文件
'.hs', // Haskell 语言文件
'.clj', // Clojure 语言文件
'.cljs', // ClojureScript 语言文件
'.elm', // Elm 语言文件
'.erl', // Erlang 语言文件
'.ex', // Elixir 语言文件
'.exs', // Elixir 脚本文件
'.pug', // Pug (formerly Jade) 模板文件
'.haml', // Haml 模板文件
'.slim', // Slim 模板文件
'.tpl', // 模板文件(通用)
'.ejs', // Embedded JavaScript 模板文件
'.hbs', // Handlebars 模板文件
'.mustache', // Mustache 模板文件
'.jade', // Jade 模板文件 (已重命名为 Pug)
'.twig', // Twig 模板文件
'.blade', // Blade 模板文件 (Laravel)
'.vue', // Vue.js 单文件组件
'.jsx', // React JSX 文件
'.tsx', // React TSX 文件
'.graphql', // GraphQL 查询语言文件
'.gql', // GraphQL 查询语言文件
'.proto', // Protocol Buffers 文件
'.thrift', // Thrift 文件
'.toml', // TOML 配置文件
'.edn', // Clojure 数据表示文件
'.cake', // CakePHP 配置文件
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.kts', // Kotlin Script 文件
'.java' // Java 代码文件
]
export const isMac = process.platform === 'darwin'
export const isWin = process.platform === 'win32'
export const isLinux = process.platform === 'linux'

9
src/main/electron.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare global {
namespace Electron {
interface App {
isQuitting: boolean
}
}
}
export {}

View File

@ -3,67 +3,67 @@ import { app, BrowserWindow } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { registerZoomShortcut } from './services/ShortcutService'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { updateUserDataPath } from './utils/upgrade'
import { createMainWindow } from './window'
// Check for single instance lock
if (!app.requestSingleInstanceLock()) {
app.quit()
}
process.exit(0)
} else {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
await updateUserDataPath()
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(async () => {
await updateUserDataPath()
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
const mainWindow = windowService.createMainWindow()
new TrayService()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
windowService.createMainWindow()
} else {
windowService.showMainWindow()
}
})
registerShortcuts(mainWindow)
registerIpc(mainWindow, app)
if (process.env.NODE_ENV === 'development') {
installExtension(REDUX_DEVTOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
})
// Listen for second instance
app.on('second-instance', () => {
const mainWindow = BrowserWindow.getAllWindows()[0]
if (mainWindow) {
mainWindow.isMinimized() && mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
})
// Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production.
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
app.on('before-quit', () => {
app.isQuitting = true
})
const mainWindow = createMainWindow()
registerZoomShortcut(mainWindow)
registerIpc(mainWindow, app)
if (process.env.NODE_ENV === 'development') {
installExtension(REDUX_DEVTOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
})
// Listen for second instance
app.on('second-instance', () => {
const mainWindow = BrowserWindow.getAllWindows()[0]
if (mainWindow) {
mainWindow.isMinimized() && mainWindow.restore()
mainWindow.focus()
}
})
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.
// In this file you can include the rest of your app"s specific main process
// code. You can also put them in separate files and require them here.
}

View File

@ -1,6 +1,9 @@
import fs from 'node:fs'
import path from 'node:path'
import { BrowserWindow, ipcMain, session, shell } from 'electron'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron'
import log from 'electron-log'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import AppUpdater from './services/AppUpdater'
@ -8,8 +11,9 @@ import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
import { ExportService } from './services/ExportService'
import FileStorage from './services/FileStorage'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { windowService } from './services/WindowService'
import { compress, decompress } from './utils/zip'
import { createMinappWindow } from './window'
const fileManager = new FileStorage()
const backupManager = new BackupManager()
@ -22,25 +26,65 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
version: app.getVersion(),
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
filesPath: path.join(app.getPath('userData'), 'Data', 'Files')
filesPath: path.join(app.getPath('userData'), 'Data', 'Files'),
appDataPath: app.getPath('userData'),
logsPath: log.transports.file.getFile().path
}))
ipcMain.handle('app:proxy', (_, proxy: string) => session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {}))
ipcMain.handle('app:proxy', async (_, proxy: string) => {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
const proxyConfig: ProxyConfig = proxy === 'system' ? { mode: 'system' } : proxy ? { proxyRules: proxy } : {}
await Promise.all(sessions.map((session) => session.setProxy(proxyConfig)))
})
ipcMain.handle('app:reload', () => mainWindow.reload())
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
// language
ipcMain.handle('app:set-language', (_, language) => {
configManager.setLanguage(language)
})
// tray
ipcMain.handle('app:set-tray', (_, isActive: boolean) => {
configManager.setTray(isActive)
})
// theme
ipcMain.handle('app:set-theme', (_, theme: 'light' | 'dark') => {
ipcMain.handle('app:set-theme', (_, theme: ThemeMode) => {
configManager.setTheme(theme)
mainWindow?.setTitleBarOverlay &&
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
})
// clear cache
ipcMain.handle('app:clear-cache', async () => {
const sessions = [session.defaultSession, session.fromPartition('persist:webview')]
try {
await Promise.all(
sessions.map(async (session) => {
await session.clearCache()
await session.clearStorageData({
storages: ['cookies', 'filesystem', 'shadercache', 'websql', 'serviceworkers', 'cachestorage']
})
})
)
await fileManager.clearTemp()
await fs.writeFileSync(log.transports.file.getFile().path, '')
return { success: true }
} catch (error: any) {
log.error('Failed to clear cache:', error)
return { success: false, error: error.message }
}
})
// check for update
ipcMain.handle('app:check-for-update', async () => {
const update = await autoUpdater.checkForUpdates()
return {
currentVersion: autoUpdater.currentVersion,
update: await autoUpdater.checkForUpdates()
updateInfo: update?.updateInfo
}
})
@ -73,7 +117,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// minapp
ipcMain.handle('minapp', (_, args) => {
createMinappWindow({
windowService.createMinappWindow({
url: args.url,
parent: mainWindow,
windowOptions: {
@ -85,4 +129,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
// export
ipcMain.handle('export:word', exportService.exportToWord)
// open path
ipcMain.handle('open:path', async (_, path: string) => {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
unregisterAllShortcuts()
registerShortcuts(mainWindow)
}
})
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

@ -1,4 +1,4 @@
import { BrowserWindow, dialog } from 'electron'
import { app, BrowserWindow, dialog } from 'electron'
import logger from 'electron-log'
import { AppUpdater as _AppUpdater, autoUpdater, UpdateInfo } from 'electron-updater'
@ -6,10 +6,11 @@ export default class AppUpdater {
autoUpdater: _AppUpdater = autoUpdater
constructor(mainWindow: BrowserWindow) {
logger.transports.file.level = 'debug'
logger.transports.file.level = 'info'
autoUpdater.logger = logger
autoUpdater.forceDevUpdateConfig = true
autoUpdater.autoDownload = false
autoUpdater.forceDevUpdateConfig = !app.isPackaged
autoUpdater.autoDownload = true
// 检测下载错误
autoUpdater.on('error', (error) => {
@ -18,40 +19,8 @@ export default class AppUpdater {
})
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
autoUpdater.logger?.info('检测到新版本,确认是否下载')
logger.info('检测到新版本', releaseInfo)
mainWindow.webContents.send('update-available', releaseInfo)
const releaseNotes = releaseInfo.releaseNotes
let releaseContent = ''
if (releaseNotes) {
if (typeof releaseNotes === 'string') {
releaseContent = <string>releaseNotes
} else if (releaseNotes instanceof Array) {
releaseNotes.forEach((releaseNote) => {
releaseContent += `${releaseNote}\n`
})
}
} else {
releaseContent = '暂无更新说明'
}
// 弹框确认是否下载更新releaseContent是更新日志
dialog
.showMessageBox({
type: 'info',
title: '应用有新的更新',
detail: releaseContent,
message: '发现新版本,是否现在更新?',
buttons: ['下次再说', '更新']
})
.then(({ response }) => {
if (response === 1) {
logger.info('用户选择更新,准备下载更新')
mainWindow.webContents.send('download-update')
autoUpdater.downloadUpdate()
}
})
})
// 检测到不需要更新时
@ -61,23 +30,52 @@ export default class AppUpdater {
// 更新下载进度
autoUpdater.on('download-progress', (progress) => {
logger.info('下载进度', progress)
mainWindow.webContents.send('download-progress', progress)
})
// 当需要更新的内容下载完成后
autoUpdater.on('update-downloaded', () => {
logger.info('下载完成,准备更新')
autoUpdater.on('update-downloaded', (releaseInfo: UpdateInfo) => {
mainWindow.webContents.send('update-downloaded')
logger.info('下载完成,询问用户是否更新', releaseInfo)
dialog
.showMessageBox({
type: 'info',
title: '安装更新',
message: '更新下载完毕,应用将重启并进行安装'
message: `新版本 ${releaseInfo.version} 已准备就绪`,
detail: this.formatReleaseNotes(releaseInfo.releaseNotes),
buttons: ['稍后安装', '立即安装'],
defaultId: 1,
cancelId: 0
})
.then(() => {
setImmediate(() => autoUpdater.quitAndInstall())
.then(({ response }) => {
if (response === 1) {
app.isQuitting = true
setImmediate(() => autoUpdater.quitAndInstall())
} else {
mainWindow.webContents.send('update-downloaded-cancelled')
}
})
})
this.autoUpdater = autoUpdater
}
private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string {
if (!releaseNotes) {
return '暂无更新说明'
}
if (typeof releaseNotes === 'string') {
return releaseNotes
}
return releaseNotes.map((note) => note.note).join('\n')
}
}
interface ReleaseNoteInfo {
readonly version: string
readonly note: string | null
}

View File

@ -1,10 +1,9 @@
import { WebDavConfig } from '@types'
import archiver from 'archiver'
import AdmZip from 'adm-zip'
import { app } from 'electron'
import Logger from 'electron-log'
import * as fs from 'fs-extra'
import * as path from 'path'
import * as unzipper from 'unzipper'
import WebDav from './WebDav'
@ -26,7 +25,6 @@ class BackupManager {
destinationPath: string = this.backupDir
): Promise<string> {
try {
// 创建临时目录
await fs.ensureDir(this.tempDir)
// 将 data 写入临时文件
@ -38,21 +36,16 @@ class BackupManager {
const tempDataDir = path.join(this.tempDir, 'Data')
await fs.copy(sourcePath, tempDataDir)
// 创建 zip 文件
const output = fs.createWriteStream(path.join(destinationPath, fileName))
const archive = archiver('zip', { zlib: { level: 9 } })
archive.pipe(output)
archive.directory(this.tempDir, false)
await archive.finalize()
// 使用 adm-zip 创建压缩文件
const zip = new AdmZip()
zip.addLocalFolder(this.tempDir)
const backupedFilePath = path.join(destinationPath, fileName)
zip.writeZip(backupedFilePath)
// 清理临时目录
await fs.remove(this.tempDir)
Logger.log('Backup completed successfully')
const backupedFilePath = path.join(destinationPath, fileName)
return backupedFilePath
} catch (error) {
Logger.error('Backup failed:', error)
@ -61,31 +54,43 @@ class BackupManager {
}
async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise<string> {
// 创建临时目录
await fs.ensureDir(this.tempDir)
try {
// 创建临时目录
await fs.ensureDir(this.tempDir)
// 解压备份文件到临时目录
await fs
.createReadStream(backupPath)
.pipe(unzipper.Extract({ path: this.tempDir }))
.promise()
Logger.log('[backup] step 1: unzip backup file', this.tempDir)
// 读取 data.json
const dataPath = path.join(this.tempDir, 'data.json')
const data = await fs.readFile(dataPath, 'utf-8')
// 使用 adm-zip 解压
const zip = new AdmZip(backupPath)
zip.extractAllTo(this.tempDir, true) // true 表示覆盖已存在的文件
// 恢复 Data 目录
const sourcePath = path.join(this.tempDir, 'Data')
const destPath = path.join(app.getPath('userData'), 'Data')
await fs.remove(destPath)
await fs.copy(sourcePath, destPath)
Logger.log('[backup] step 2: read data.json')
// 清理临时目录
await fs.remove(this.tempDir)
// 读取 data.json
const dataPath = path.join(this.tempDir, 'data.json')
const data = await fs.readFile(dataPath, 'utf-8')
Logger.log('Restore completed successfully')
Logger.log('[backup] step 3: restore Data directory')
return data
// 恢复 Data 目录
const sourcePath = path.join(this.tempDir, 'Data')
const destPath = path.join(app.getPath('userData'), 'Data')
await fs.remove(destPath)
await fs.copy(sourcePath, destPath)
Logger.log('[backup] step 4: clean up temp directory')
// 清理临时目录
await fs.remove(this.tempDir)
Logger.log('[backup] step 5: Restore completed successfully')
return data
} catch (error) {
Logger.error('[backup] Restore failed:', error)
await fs.remove(this.tempDir).catch(() => {})
throw error
}
}
async backupToWebdav(_: Electron.IpcMainInvokeEvent, data: string, webdavConfig: WebDavConfig) {

View File

@ -1,19 +1,85 @@
import { ZOOM_SHORTCUTS } from '@shared/config/constant'
import { LanguageVarious, Shortcut, ThemeMode } from '@types'
import { app } from 'electron'
import Store from 'electron-store'
import { locales } from '../utils/locales'
export class ConfigManager {
private store: Store
private subscribers: Map<string, Array<(newValue: any) => void>> = new Map()
constructor() {
this.store = new Store()
}
getTheme(): 'light' | 'dark' {
return this.store.get('theme', 'light') as 'light' | 'dark'
getLanguage(): LanguageVarious {
const locale = Object.keys(locales).includes(app.getLocale()) ? app.getLocale() : 'en-US'
return this.store.get('language', locale) as LanguageVarious
}
setTheme(theme: 'light' | 'dark') {
setLanguage(theme: LanguageVarious) {
this.store.set('language', theme)
}
getTheme(): ThemeMode {
return this.store.get('theme', ThemeMode.light) as ThemeMode
}
setTheme(theme: ThemeMode) {
this.store.set('theme', theme)
}
isTray(): boolean {
return !!this.store.get('tray', true)
}
setTray(value: boolean) {
this.store.set('tray', value)
this.notifySubscribers('tray', value)
}
getZoomFactor(): number {
return this.store.get('zoomFactor', 1) as number
}
setZoomFactor(factor: number) {
this.store.set('zoomFactor', factor)
this.notifySubscribers('zoomFactor', factor)
}
subscribe<T>(key: string, callback: (newValue: T) => void) {
if (!this.subscribers.has(key)) {
this.subscribers.set(key, [])
}
this.subscribers.get(key)!.push(callback)
}
unsubscribe<T>(key: string, callback: (newValue: T) => void) {
const subscribers = this.subscribers.get(key)
if (subscribers) {
this.subscribers.set(
key,
subscribers.filter((subscriber) => subscriber !== callback)
)
}
}
private notifySubscribers<T>(key: string, newValue: T) {
const subscribers = this.subscribers.get(key)
if (subscribers) {
subscribers.forEach((subscriber) => subscriber(newValue))
}
}
getShortcuts() {
return this.store.get('shortcuts', ZOOM_SHORTCUTS) as Shortcut[] | []
}
setShortcuts(shortcuts: Shortcut[]) {
this.store.set('shortcuts', shortcuts)
this.notifySubscribers('shortcuts', shortcuts)
}
}
export const configManager = new ConfigManager()

View File

@ -1,5 +1,5 @@
import { documentExts, imageExts } from '@main/constant'
import { getFileType } from '@main/utils/file'
import { documentExts, imageExts } from '@shared/config/constant'
import { FileType } from '@types'
import * as crypto from 'crypto'
import {
@ -267,6 +267,11 @@ class FileStorage {
await this.initStorageDir()
}
public clearTemp = async (): Promise<void> => {
await fs.promises.rmdir(this.tempDir, { recursive: true })
await fs.promises.mkdir(this.tempDir, { recursive: true })
}
public open = async (
_: Electron.IpcMainInvokeEvent,
options: OpenDialogOptions

View File

@ -1,66 +1,131 @@
import { Shortcut } from '@types'
import { BrowserWindow, globalShortcut } from 'electron'
import Logger from 'electron-log'
export function registerZoomShortcut(mainWindow: BrowserWindow) {
const registerShortcuts = () => {
// 注册放大快捷键 (Ctrl+Plus 或 Cmd+Plus)
globalShortcut.register('CommandOrControl+=', () => {
if (mainWindow) {
const currentZoom = mainWindow.webContents.getZoomFactor()
const newZoom = currentZoom + 0.1
// Prevent zoom factor from exceeding reasonable limits
if (newZoom <= 5.0) {
mainWindow.webContents.setZoomFactor(newZoom)
import { configManager } from './ConfigManager'
let showAppAccelerator: string | null = null
function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) {
case 'zoom_in':
return (window: BrowserWindow) => handleZoom(0.1)(window)
case 'zoom_out':
return (window: BrowserWindow) => handleZoom(-0.1)(window)
case 'zoom_reset':
return (window: BrowserWindow) => {
window.webContents.setZoomFactor(1)
configManager.setZoomFactor(1)
}
case 'show_app':
return (window: BrowserWindow) => {
if (window.isVisible()) {
window.hide()
} else {
window.show()
window.focus()
}
}
})
// 注册缩小快捷键 (Ctrl+Minus 或 Cmd+Minus)
globalShortcut.register('CommandOrControl+-', () => {
if (mainWindow) {
const currentZoom = mainWindow.webContents.getZoomFactor()
const newZoom = currentZoom - 0.1
// Prevent zoom factor from going below 0.1
if (newZoom >= 0.1) {
mainWindow.webContents.setZoomFactor(newZoom)
}
}
})
// 注册重置缩放快捷键 (Ctrl+0 或 Cmd+0)
globalShortcut.register('CommandOrControl+0', () => {
if (mainWindow) {
mainWindow.webContents.setZoomFactor(1)
}
})
}
const unregisterShortcuts = () => {
globalShortcut.unregister('CommandOrControl+=')
globalShortcut.unregister('CommandOrControl+-')
globalShortcut.unregister('CommandOrControl+0')
}
// Add check for window destruction
if (mainWindow.isDestroyed()) {
return
}
// When window gains focus, register shortcuts
mainWindow.on('focus', () => {
if (!mainWindow.isDestroyed()) {
registerShortcuts()
}
})
// When window loses focus, unregister shortcuts
mainWindow.on('blur', () => {
if (!mainWindow.isDestroyed()) {
unregisterShortcuts()
}
})
// Initial registration (if window is already focused)
if (!mainWindow.isDestroyed() && mainWindow.isFocused()) {
registerShortcuts()
default:
return null
}
}
function formatShortcutKey(shortcut: string[]): string {
return shortcut.join('+')
}
function handleZoom(delta: number) {
return (window: BrowserWindow) => {
const currentZoom = window.webContents.getZoomFactor()
const newZoom = currentZoom + delta
if (newZoom >= 0.1 && newZoom <= 5.0) {
window.webContents.setZoomFactor(newZoom)
configManager.setZoomFactor(newZoom)
}
}
}
export function registerShortcuts(window: BrowserWindow) {
window.webContents.setZoomFactor(configManager.getZoomFactor())
const register = () => {
if (window.isDestroyed()) return
const shortcuts = configManager.getShortcuts()
if (!shortcuts) return
shortcuts.forEach((shortcut) => {
try {
if (shortcut.shortcut.length === 0) {
return
}
const handler = getShortcutHandler(shortcut)
if (!handler) {
return
}
const accelerator = formatShortcutKey(shortcut.shortcut)
if (shortcut.key === 'show_app') {
showAppAccelerator = accelerator
}
if (shortcut.key.includes('zoom')) {
switch (shortcut.key) {
case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => shortcut.enabled && handler(window))
globalShortcut.register('CommandOrControl+numadd', () => shortcut.enabled && handler(window))
return
case 'zoom_out':
globalShortcut.register('CommandOrControl+-', () => shortcut.enabled && handler(window))
globalShortcut.register('CommandOrControl+numsub', () => shortcut.enabled && handler(window))
return
case 'zoom_reset':
globalShortcut.register('CommandOrControl+0', () => shortcut.enabled && handler(window))
return
}
}
if (shortcut.enabled) {
globalShortcut.register(accelerator, () => handler(window))
}
} catch (error) {
Logger.error(`[ShortcutService] Failed to register shortcut ${shortcut.key}`)
}
})
}
const unregister = () => {
if (window.isDestroyed()) return
try {
globalShortcut.unregisterAll()
if (showAppAccelerator) {
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
handler && globalShortcut.register(showAppAccelerator, () => handler(window))
}
} catch (error) {
Logger.error('[ShortcutService] Failed to unregister shortcuts')
}
}
window.on('focus', () => register())
window.on('blur', () => unregister())
if (!window.isDestroyed() && window.isFocused()) {
register()
}
}
export function unregisterAllShortcuts() {
try {
showAppAccelerator = null
globalShortcut.unregisterAll()
} catch (error) {
Logger.error('[ShortcutService] Failed to unregister all shortcuts')
}
}

View File

@ -0,0 +1,90 @@
import { isMac } from '@main/constant'
import { locales } from '@main/utils/locales'
import { app, Menu, nativeImage, nativeTheme, Tray } from 'electron'
import icon from '../../../build/tray_icon.png?asset'
import iconDark from '../../../build/tray_icon_dark.png?asset'
import iconLight from '../../../build/tray_icon_light.png?asset'
import { configManager } from './ConfigManager'
import { windowService } from './WindowService'
export class TrayService {
private tray: Tray | null = null
constructor() {
this.updateTray()
this.watchTrayChanges()
}
private createTray() {
const iconPath = isMac ? (nativeTheme.shouldUseDarkColors ? iconLight : iconDark) : icon
const tray = new Tray(iconPath)
if (process.platform === 'win32') {
tray.setImage(iconPath)
} else if (process.platform === 'darwin') {
const image = nativeImage.createFromPath(iconPath)
const resizedImage = image.resize({ width: 16, height: 16 })
resizedImage.setTemplateImage(true)
tray.setImage(resizedImage)
} else if (process.platform === 'linux') {
const image = nativeImage.createFromPath(iconPath)
const resizedImage = image.resize({ width: 16, height: 16 })
tray.setImage(resizedImage)
}
this.tray = tray
const locale = locales[configManager.getLanguage()]
const { tray: trayLocale } = locale.translation
const contextMenu = Menu.buildFromTemplate([
{
label: trayLocale.show_window,
click: () => windowService.showMainWindow()
},
{ type: 'separator' },
{
label: trayLocale.quit,
click: () => this.quit()
}
])
if (process.platform === 'linux') {
this.tray.setContextMenu(contextMenu)
}
this.tray.setToolTip('Cherry Studio')
this.tray.on('right-click', () => {
this.tray?.popUpContextMenu(contextMenu)
})
this.tray.on('click', () => {
windowService.showMainWindow()
})
}
private updateTray() {
if (configManager.isTray()) {
this.createTray()
} else {
this.destroyTray()
}
}
private destroyTray() {
if (this.tray) {
this.tray.destroy()
this.tray = null
}
}
private watchTrayChanges() {
configManager.subscribe<boolean>('tray', () => this.updateTray())
}
private quit() {
app.quit()
}
}

View File

@ -0,0 +1,198 @@
import { is } from '@electron-toolkit/utils'
import { isLinux, isWin } from '@main/constant'
import { app, BrowserWindow, Menu, MenuItem, shell } from 'electron'
import windowStateKeeper from 'electron-window-state'
import { join } from 'path'
import icon from '../../../build/icon.png?asset'
import { titleBarOverlayDark, titleBarOverlayLight } from '../config'
import { locales } from '../utils/locales'
import { configManager } from './ConfigManager'
export class WindowService {
private static instance: WindowService | null = null
private mainWindow: BrowserWindow | null = null
public static getInstance(): WindowService {
if (!WindowService.instance) {
WindowService.instance = new WindowService()
}
return WindowService.instance
}
public createMainWindow(): BrowserWindow {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
return this.mainWindow
}
const mainWindowState = windowStateKeeper({
defaultWidth: 1080,
defaultHeight: 670
})
const theme = configManager.getTheme()
const isMac = process.platform === 'darwin'
this.mainWindow = new BrowserWindow({
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: 1080,
minHeight: 600,
show: true,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'under-window',
visualEffectState: 'active',
titleBarStyle: 'hidden',
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
trafficLightPosition: { x: 8, y: 12 },
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true
}
})
this.setupMainWindow(this.mainWindow, mainWindowState)
return this.mainWindow
}
public createMinappWindow({
url,
parent,
windowOptions
}: {
url: string
parent?: BrowserWindow
windowOptions?: Electron.BrowserWindowConstructorOptions
}): BrowserWindow {
const width = windowOptions?.width || 1000
const height = windowOptions?.height || 680
const minappWindow = new BrowserWindow({
width,
height,
autoHideMenuBar: true,
title: 'Cherry Studio',
...windowOptions,
parent,
webPreferences: {
preload: join(__dirname, '../preload/minapp.js'),
sandbox: false,
contextIsolation: false
}
})
minappWindow.loadURL(url)
return minappWindow
}
private setupMainWindow(mainWindow: BrowserWindow, mainWindowState: any) {
mainWindowState.manage(mainWindow)
this.setupContextMenu(mainWindow)
this.setupWindowEvents(mainWindow)
this.setupWebContentsHandlers(mainWindow)
this.setupWindowLifecycleEvents(mainWindow)
this.loadMainWindowContent(mainWindow)
}
private setupContextMenu(mainWindow: BrowserWindow) {
mainWindow.webContents.on('context-menu', () => {
const locale = locales[configManager.getLanguage()]
const { common } = locale.translation
const menu = new Menu()
menu.append(new MenuItem({ label: common.copy, role: 'copy' }))
menu.append(new MenuItem({ label: common.paste, role: 'paste' }))
menu.append(new MenuItem({ label: common.cut, role: 'cut' }))
menu.popup()
})
}
private setupWindowEvents(mainWindow: BrowserWindow) {
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
}
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
mainWindow.webContents.on('will-navigate', (event, url) => {
event.preventDefault()
shell.openExternal(url)
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
this.setupWebRequestHeaders(mainWindow)
}
private setupWebRequestHeaders(mainWindow: BrowserWindow) {
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
if (details.responseHeaders?.['X-Frame-Options']) {
delete details.responseHeaders['X-Frame-Options']
}
if (details.responseHeaders?.['x-frame-options']) {
delete details.responseHeaders['x-frame-options']
}
if (details.responseHeaders?.['Content-Security-Policy']) {
delete details.responseHeaders['Content-Security-Policy']
}
if (details.responseHeaders?.['content-security-policy']) {
delete details.responseHeaders['content-security-policy']
}
callback({ cancel: false, responseHeaders: details.responseHeaders })
})
}
private loadMainWindowContent(mainWindow: BrowserWindow) {
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
public getMainWindow(): BrowserWindow | null {
return this.mainWindow
}
private setupWindowLifecycleEvents(mainWindow: BrowserWindow) {
mainWindow.on('close', (event) => {
const notInTray = !configManager.isTray()
// Windows and Linux
if ((isWin || isLinux) && notInTray) {
return app.quit()
}
// Mac
if (!app.isQuitting) {
event.preventDefault()
mainWindow.hide()
}
})
}
public showMainWindow() {
if (this.mainWindow) {
if (this.mainWindow.isMinimized()) {
return this.mainWindow.restore()
}
this.mainWindow.show()
this.mainWindow.focus()
} else {
this.createMainWindow()
}
}
}
export const windowService = WindowService.getInstance()

View File

@ -1,6 +1,5 @@
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@main/constant'
import { FileTypes } from '../../renderer/src/types'
import { audioExts, documentExts, imageExts, textExts, videoExts } from '@shared/config/constant'
import { FileTypes } from '@types'
export function getFileType(ext: string): FileTypes {
ext = ext.toLowerCase()

13
src/main/utils/locales.ts Normal file
View File

@ -0,0 +1,13 @@
import EnUs from '../../renderer/src/i18n/locales/en-us.json'
import RuRu from '../../renderer/src/i18n/locales/ru-ru.json'
import ZhCn from '../../renderer/src/i18n/locales/zh-cn.json'
import ZhTw from '../../renderer/src/i18n/locales/zh-tw.json'
const locales = {
'en-US': EnUs,
'zh-CN': ZhCn,
'zh-TW': ZhTw,
'ru-RU': RuRu
}
export { locales }

View File

@ -0,0 +1,16 @@
function isTilingWindowManager() {
if (process.platform === 'darwin') {
return false
}
if (process.platform !== 'linux') {
return true
}
const desktopEnv = process.env.XDG_CURRENT_DESKTOP?.toLowerCase()
const tilingSystems = ['hyprland', 'i3', 'sway', 'bspwm', 'dwm', 'awesome', 'qtile', 'herbstluftwm', 'xmonad']
return tilingSystems.some((system) => desktopEnv?.includes(system))
}
export { isTilingWindowManager }

View File

@ -1,128 +0,0 @@
import { is } from '@electron-toolkit/utils'
import { BrowserWindow, Menu, MenuItem, shell } from 'electron'
import windowStateKeeper from 'electron-window-state'
import { join } from 'path'
import icon from '../../build/icon.png?asset'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import { configManager } from './services/ConfigManager'
export function createMainWindow() {
// Load the previous state with fallback to defaults
const mainWindowState = windowStateKeeper({
defaultWidth: 1080,
defaultHeight: 670
})
const theme = configManager.getTheme()
// Create the browser window.
const isMac = process.platform === 'darwin'
const mainWindow = new BrowserWindow({
x: mainWindowState.x,
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
minWidth: 1080,
minHeight: 600,
show: true,
autoHideMenuBar: true,
transparent: isMac,
vibrancy: 'fullscreen-ui',
visualEffectState: 'active',
titleBarStyle: 'hidden',
titleBarOverlay: theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight,
backgroundColor: isMac ? undefined : theme === 'dark' ? '#181818' : '#FFFFFF',
trafficLightPosition: { x: 8, y: 12 },
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
webviewTag: true
// devTools: !app.isPackaged,
}
})
mainWindowState.manage(mainWindow)
mainWindow.webContents.on('context-menu', () => {
const menu = new Menu()
menu.append(new MenuItem({ label: '复制', role: 'copy' }))
menu.append(new MenuItem({ label: '粘贴', role: 'paste' }))
menu.append(new MenuItem({ label: '剪切', role: 'cut' }))
menu.popup()
})
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
mainWindow.webContents.on('will-navigate', (event, url) => {
event.preventDefault()
shell.openExternal(url)
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
mainWindow.webContents.session.webRequest.onHeadersReceived({ urls: ['*://*/*'] }, (details, callback) => {
if (details.responseHeaders?.['X-Frame-Options']) {
delete details.responseHeaders['X-Frame-Options']
}
if (details.responseHeaders?.['x-frame-options']) {
delete details.responseHeaders['x-frame-options']
}
if (details.responseHeaders?.['Content-Security-Policy']) {
delete details.responseHeaders['Content-Security-Policy']
}
if (details.responseHeaders?.['content-security-policy']) {
delete details.responseHeaders['content-security-policy']
}
callback({ cancel: false, responseHeaders: details.responseHeaders })
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
return mainWindow
}
export function createMinappWindow({
url,
parent,
windowOptions
}: {
url: string
parent?: BrowserWindow
windowOptions?: Electron.BrowserWindowConstructorOptions
}) {
const width = windowOptions?.width || 1000
const height = windowOptions?.height || 680
const minappWindow = new BrowserWindow({
width,
height,
autoHideMenuBar: true,
title: 'Cherry Studio',
...windowOptions,
parent,
webPreferences: {
preload: join(__dirname, '../preload/minapp.js'),
sandbox: false,
contextIsolation: false
}
})
minappWindow.loadURL(url)
return minappWindow
}

View File

@ -1,6 +1,7 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { FileType } from '@renderer/types'
import { WebDavConfig } from '@renderer/types'
import { AppInfo, LanguageVarious } from '@renderer/types'
import type { OpenDialogOptions } from 'electron'
import { Readable } from 'stream'
@ -8,18 +9,16 @@ declare global {
interface Window {
electron: ElectronAPI
api: {
getAppInfo: () => Promise<{
version: string
isPackaged: boolean
appPath: string
filesPath: string
}>
getAppInfo: () => Promise<AppInfo>
checkForUpdate: () => void
openWebsite: (url: string) => void
setProxy: (proxy: string | undefined) => void
setLanguage: (theme: LanguageVarious) => void
setTray: (isActive: boolean) => void
setTheme: (theme: 'light' | 'dark') => void
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
reload: () => void
clearCache: () => Promise<{ success: boolean; error?: string }>
zip: {
compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string>
@ -54,6 +53,10 @@ declare global {
export: {
toWord: (markdown: string, fileName: string) => Promise<void>
}
openPath: (path: string) => Promise<void>
shortcuts: {
update: (shortcuts: Shortcut[]) => Promise<void>
}
}
}
}

View File

@ -1,5 +1,5 @@
import { electronAPI } from '@electron-toolkit/preload'
import { WebDavConfig } from '@types'
import { Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
// Custom APIs for renderer
@ -8,9 +8,12 @@ const api = {
reload: () => ipcRenderer.invoke('app:reload'),
setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy),
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
setLanguage: (lang: string) => ipcRenderer.invoke('app:set-language', lang),
setTray: (isActive: boolean) => ipcRenderer.invoke('app:set-tray', isActive),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
clearCache: () => ipcRenderer.invoke('app:clear-cache'),
zip: {
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
@ -43,6 +46,10 @@ const api = {
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
},
openPath: (path: string) => ipcRenderer.invoke('open:path', path),
shortcuts: {
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
}
}

View File

@ -5,7 +5,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src *; script-src 'self' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: *; frame-src * file:" />
content="default-src 'self'; connect-src blob: *; script-src 'self' 'unsafe-eval' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: * blob:; frame-src * file:" />
<style>
html,
body {

View File

@ -8,11 +8,11 @@ import { PersistGate } from 'redux-persist/integration/react'
import Sidebar from './components/app/Sidebar'
import TopViewContainer from './components/TopView'
import AntdProvider from './context/AntdProvider'
import { SyntaxHighlighterProvider } from './context/SyntaxHighlighterProvider'
import { ThemeProvider } from './context/ThemeProvider'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HistoryPage from './pages/history/HistoryPage'
import HomePage from './pages/home/HomePage'
import PaintingsPage from './pages/paintings/PaintingsPage'
import SettingsPage from './pages/settings/SettingsPage'
@ -23,23 +23,24 @@ function App(): JSX.Element {
<Provider store={store}>
<ThemeProvider>
<AntdProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings" element={<PaintingsPage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/messages/*" element={<HistoryPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</PersistGate>
<SyntaxHighlighterProvider>
<PersistGate loading={null} persistor={persistor}>
<TopViewContainer>
<HashRouter>
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/paintings" element={<PaintingsPage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/apps" element={<AppsPage />} />
<Route path="/settings/*" element={<SettingsPage />} />
</Routes>
</HashRouter>
</TopViewContainer>
</PersistGate>
</SyntaxHighlighterProvider>
</AntdProvider>
</ThemeProvider>
</Provider>

View File

@ -1,6 +1,6 @@
@font-face {
font-family: 'iconfont'; /* Project id 4563475 */
src: url('iconfont.woff2?t=1725606177995') format('woff2');
font-family: 'iconfont'; /* Project id 4753420 */
src: url('iconfont.woff2?t=1733224456443') format('woff2');
}
.iconfont {
@ -11,6 +11,14 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-at1:before {
content: '\e7df';
}
.icon-at:before {
content: '\e630';
}
.icon-a-darkmode:before {
content: '\e6cd';
}
@ -27,10 +35,6 @@
content: '\e942';
}
.icon-grid-row-2copy:before {
content: '\e681';
}
.icon-inbox:before {
content: '\e869';
}
@ -71,10 +75,6 @@
content: '\e944';
}
.icon-a-addchat:before {
content: '\e658';
}
.icon-appstore:before {
content: '\e792';
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -42,35 +42,6 @@
-webkit-app-region: no-drag;
}
.segmented-tab {
.ant-segmented-item {
overflow: hidden;
}
.ant-segmented-item-selected {
background-color: var(--color-background-mute);
}
.ant-segmented-item-label {
align-items: center;
display: flex;
flex-direction: row;
justify-content: center;
font-size: 13px;
}
.iconfont {
font-size: 13px;
margin-left: -2px;
}
.anticon-setting {
font-size: 12px;
}
.icon-business-smart-assistant {
margin-right: -2px;
}
.ant-segmented-item-icon + * {
margin-left: 4px;
}
}
.message-attachments {
.ant-upload-list-item:hover {
background-color: initial !important;

View File

@ -10,8 +10,8 @@
--color-white-mute: rgba(255, 255, 255, 0.94);
--color-black: #151515;
--color-black-soft: #202020;
--color-black-mute: #262626;
--color-black-soft: #222222;
--color-black-mute: #333333;
--color-gray-1: #515c67;
--color-gray-2: #414853;
@ -32,14 +32,16 @@
--color-text: var(--color-text-1);
--color-icon: #ffffff99;
--color-icon-white: #ffffff;
--color-border: #ffffff24;
--color-border-soft: #ffffff20;
--color-border: #ffffff22;
--color-border-soft: #ffffff11;
--color-border-mute: #ffffff11;
--color-error: #f44336;
--color-link: #1677ff;
--color-code-background: #323232;
--color-hover: rgba(40, 40, 40, 1);
--color-active: rgba(55, 55, 55, 1);
--color-frame-border: #333;
--color-group-background: var(--color-background-soft);
--navbar-background-mac: rgba(30, 30, 30, 0.6);
--navbar-background: rgba(30, 30, 30);
@ -88,13 +90,15 @@ body[theme-mode='light'] {
--color-icon: #00000099;
--color-icon-white: #000000;
--color-border: #00000028;
--color-border-soft: #00000028;
--color-border-mute: #00000011;
--color-border-soft: #00000020;
--color-border-mute: #00000010;
--color-error: #f44336;
--color-link: #1677ff;
--color-code-background: #e3e3e3;
--color-hover: var(--color-white-mute);
--color-active: var(--color-white-soft);
--color-frame-border: #ddd;
--color-group-background: var(--color-white);
--navbar-background-mac: rgba(255, 255, 255, 0.6);
--navbar-background: rgba(255, 255, 255);
@ -168,8 +172,8 @@ body,
body[os='mac'] {
#content-container {
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
border-left: 0.5px solid var(--color-border);
box-shadow: 0 0 15px 1px rgba(0, 0, 0, 0.05);
}
}
@ -208,3 +212,43 @@ body[os='windows'] {
white-space: normal;
word-wrap: break-word;
}
.bubble {
background-color: var(--chat-background);
#chat-main {
background-color: var(--chat-background);
}
#messages {
background-color: var(--chat-background);
}
#inputbar {
border-radius: 0;
margin: 0;
border: none;
border-top: 1px solid var(--color-border-mute);
background: var(--color-background);
}
.system-prompt {
background-color: var(--chat-background-assistant);
}
.message-content-container {
margin: 5px 0;
border-radius: 8px;
padding: 10px 15px 0 15px;
}
.message-user {
color: var(--chat-text-user);
.markdown,
.anticon,
.iconfont,
.message-tokens {
color: var(--chat-text-user) !important;
}
.message-action-button:hover {
background-color: var(--color-white-soft);
}
}
code {
color: var(--color-text);
}
}

View File

@ -98,10 +98,6 @@
white-space: pre;
}
code {
font-family: 'Courier New', Courier, monospace;
}
p code,
li code {
background: var(--color-background-mute);
@ -111,11 +107,18 @@
white-space: pre;
}
code {
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
pre {
border-radius: 5px;
overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute);
&:has(> .mermaid) {
background-color: transparent;
}
&:not(pre pre) {
> code:not(pre pre > code) {
padding: 15px;

View File

@ -1,15 +1,15 @@
:root {
--color-scrollbar-thumb: rgba(255, 255, 255, 0.15);
--color-scrollbar-thumb-hover: rgba(255, 255, 255, 0.2);
--color-scrollbar-thumb-right: rgba(255, 255, 255, 0.25);
--color-scrollbar-thumb-right-hover: rgba(255, 255, 255, 0.35);
--color-scrollbar-thumb-right: rgba(255, 255, 255, 0.18);
--color-scrollbar-thumb-right-hover: rgba(255, 255, 255, 0.25);
}
body[theme-mode='light'] {
--color-scrollbar-thumb: rgba(0, 0, 0, 0.15);
--color-scrollbar-thumb-hover: rgba(0, 0, 0, 0.2);
--color-scrollbar-thumb-right: rgba(0, 0, 0, 0.25);
--color-scrollbar-thumb-right-hover: rgba(0, 0, 0, 0.35);
--color-scrollbar-thumb-right: rgba(0, 0, 0, 0.18);
--color-scrollbar-thumb-right-hover: rgba(0, 0, 0, 0.25);
}
/* 全局初始化滚动条样式 */

View File

@ -19,6 +19,8 @@ const AssistantMessagesSettings: FC<Props> = ({ assistant, updateAssistant, upda
const [messages, setMessagess] = useState<AssistantMessage[]>(assistant?.messages || [])
const [hideMessages, setHideMessages] = useState(assistant?.settings?.hideMessages || false)
const showSaveButton = (assistant?.messages || []).length !== messages.length
const onSave = () => {
// 检查是否有空对话组
for (let i = 0; i < messages.length; i += 2) {
@ -129,7 +131,7 @@ const AssistantMessagesSettings: FC<Props> = ({ assistant, updateAssistant, upda
</Form.Item>
<Divider style={{ marginBottom: 15 }} />
<Form.Item>
{messages.length > 0 && (
{showSaveButton && (
<Button type="primary" onClick={onSave}>
{t('common.save')}
</Button>

View File

@ -1,6 +1,6 @@
import { PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { DEFAULT_CONEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE } from '@renderer/config/constant'
import { SettingRow } from '@renderer/pages/settings'
import { Assistant, AssistantSettings } from '@renderer/types'
import { Button, Col, Divider, Row, Slider, Switch, Tooltip } from 'antd'
@ -19,7 +19,7 @@ interface Props {
const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateAssistantSettings }) => {
const [temperature, setTemperature] = useState(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE)
const [contextCount, setConextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONEXTCOUNT)
const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
const [autoResetModel, setAutoResetModel] = useState(assistant?.settings?.autoResetModel ?? false)
@ -33,7 +33,7 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
}
}
const onConextCountChange = (value) => {
const onContextCountChange = (value) => {
if (!isNaN(value as number)) {
updateAssistantSettings({ contextCount: value })
}
@ -47,13 +47,13 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
const onReset = () => {
setTemperature(DEFAULT_TEMPERATURE)
setConextCount(DEFAULT_CONEXTCOUNT)
setContextCount(DEFAULT_CONTEXTCOUNT)
setEnableMaxTokens(false)
setMaxTokens(0)
setStreamOutput(true)
updateAssistantSettings({
temperature: DEFAULT_TEMPERATURE,
contextCount: DEFAULT_CONEXTCOUNT,
contextCount: DEFAULT_CONTEXTCOUNT,
enableMaxTokens: false,
maxTokens: 0,
streamOutput: true
@ -122,8 +122,8 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
</Row>
<Row align="middle">
<Label>
{t('chat.settings.conext_count')}{' '}
<Tooltip title={t('chat.settings.conext_count.tip')}>
{t('chat.settings.context_count')}{' '}
<Tooltip title={t('chat.settings.context_count.tip')}>
<QuestionIcon />
</Tooltip>
</Label>
@ -133,8 +133,8 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
<Slider
min={0}
max={20}
onChange={setConextCount}
onChangeComplete={onConextCountChange}
onChange={setContextCount}
onChangeComplete={onContextCountChange}
value={typeof contextCount === 'number' ? contextCount : 0}
step={1}
/>

View File

@ -74,7 +74,8 @@ const AssistantSettingPopupContainer: React.FC<Props> = ({ resolve, ...props })
content: {
padding: 0,
overflow: 'hidden',
background: 'var(--color-background)'
background: 'var(--color-background)',
border: `1px solid var(--color-frame-border)`
},
header: { padding: '10px 15px', borderBottom: '0.5px solid var(--color-border)', margin: 0 }
}}
@ -142,10 +143,11 @@ const StyledModal = styled(Modal)`
}
.ant-menu-item {
height: 36px;
border-radius: 6px;
color: var(--color-text-2);
display: flex;
align-items: center;
border: 0.5px solid transparent;
border-radius: 6px;
.ant-menu-title-content {
line-height: 36px;
}
@ -156,6 +158,7 @@ const StyledModal = styled(Modal)`
}
.ant-menu-item-selected {
background-color: var(--color-background-soft);
border: 0.5px solid var(--color-border);
.ant-menu-title-content {
color: var(--color-text-1);
font-weight: 500;

View File

@ -114,7 +114,15 @@ const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginTop: 15 }} />
</EmptyView>
)}
{opened && <webview src={app.url} ref={webviewRef} style={WebviewStyle} allowpopups={'true' as any} />}
{opened && (
<webview
src={app.url}
ref={webviewRef}
style={WebviewStyle}
allowpopups={'true' as any}
partition="persist:webview"
/>
)}
</Drawer>
)
}

View File

@ -32,9 +32,22 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
const agents = useMemo(() => {
const allAgents = [...userAgents, ...systemAgents] as Agent[]
const list = [defaultAssistant, ...allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))]
return searchText
const filtered = searchText
? list.filter((agent) => agent.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()))
: list
if (searchText.trim()) {
const newAgent: Agent = {
id: 'new',
name: searchText.trim(),
prompt: '',
topics: [],
type: 'assistant',
emoji: '⭐️'
}
return [newAgent, ...filtered]
}
return filtered
}, [assistants, defaultAssistant, searchText, userAgents])
const onCreateAssistant = async (agent: Agent) => {
@ -72,7 +85,14 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
onCancel={onCancel}
afterClose={onClose}
transitionName="ant-move-up"
styles={{ content: { borderRadius: 20, padding: 0, overflow: 'hidden', paddingBottom: 20 } }}
styles={{
content: {
borderRadius: 20,
padding: 0,
overflow: 'hidden',
paddingBottom: 20
}
}}
closeIcon={null}
footer={null}>
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
@ -105,6 +125,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
</HStack>
{agent.id === 'default' && <Tag color="green">{t('agents.tag.system')}</Tag>}
{agent.type === 'agent' && <Tag color="orange">{t('agents.tag.agent')}</Tag>}
{agent.id === 'new' && <Tag color="green">{t('agents.tag.new')}</Tag>}
</AgentItem>
))}
</Container>
@ -148,7 +169,7 @@ const SearchIcon = styled.div`
flex-direction: row;
justify-content: center;
align-items: center;
background-color: var(--color-background-soft);
background-color: var(--color-background-mute);
margin-right: 2px;
`

View File

@ -1,4 +1,5 @@
import { Input, InputProps, Modal } from 'antd'
import { Input, Modal } from 'antd'
import { TextAreaProps } from 'antd/es/input'
import { useState } from 'react'
import { Box } from '../Layout'
@ -9,7 +10,7 @@ interface PromptPopupShowParams {
message: string
defaultValue?: string
inputPlaceholder?: string
inputProps?: InputProps
inputProps?: TextAreaProps
}
interface Props extends PromptPopupShowParams {
@ -42,13 +43,14 @@ const PromptPopupContainer: React.FC<Props> = ({
return (
<Modal title={title} open={open} onOk={onOk} onCancel={handleCancel} afterClose={onClose} centered>
<Box mb={8}>{message}</Box>
<Input
<Input.TextArea
placeholder={inputPlaceholder}
value={value}
onChange={(e) => setValue(e.target.value)}
allowClear
autoFocus
onPressEnter={onOk}
rows={1}
{...inputProps}
/>
</Modal>

View File

@ -0,0 +1,69 @@
import HistoryPage from '@renderer/pages/history/HistoryPage'
import { Modal } from 'antd'
import { useState } from 'react'
import { TopView } from '../TopView'
interface Props {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ resolve }) => {
const [open, setOpen] = useState(true)
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
SearchPopup.hide = onCancel
return (
<Modal
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
title={null}
width="920px"
transitionName="ant-move-down"
styles={{
content: {
padding: 0,
border: `1px solid var(--color-frame-border)`
},
body: { height: '85vh' }
}}
centered
footer={null}>
<HistoryPage />
</Modal>
)
}
export default class SearchPopup {
static topviewId = 0
static hide() {
TopView.hide('SearchPopup')
}
static show() {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
resolve={(v) => {
resolve(v)
TopView.hide('SearchPopup')
}}
/>,
'SearchPopup'
)
})
}
}

View File

@ -1,7 +1,8 @@
import { SearchOutlined } from '@ant-design/icons'
import { PushpinOutlined, SearchOutlined } from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { TopView } from '@renderer/components/TopView'
import { getModelLogo, isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
@ -30,14 +31,40 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
const [searchText, setSearchText] = useState('')
const inputRef = useRef<InputRef>(null)
const { providers } = useProviders()
const [pinnedModels, setPinnedModels] = useState<string[]>([])
useEffect(() => {
const loadPinnedModels = async () => {
const setting = await db.settings.get('pinned:models')
const savedPinnedModels = setting?.value || []
// Filter out invalid pinned models
const allModelIds = providers.flatMap((p) => p.models || []).map((m) => getModelUniqId(m))
const validPinnedModels = savedPinnedModels.filter((id) => allModelIds.includes(id))
// Update storage if there were invalid models
if (validPinnedModels.length !== savedPinnedModels.length) {
await db.settings.put({ id: 'pinned:models', value: validPinnedModels })
}
setPinnedModels(validPinnedModels)
}
loadPinnedModels()
}, [providers])
const togglePin = async (modelId: string) => {
const newPinnedModels = pinnedModels.includes(modelId)
? pinnedModels.filter((id) => id !== modelId)
: [...pinnedModels, modelId]
await db.settings.put({ id: 'pinned:models', value: newPinnedModels })
setPinnedModels(newPinnedModels)
}
const filteredItems: MenuItem[] = providers
.filter((p) => p.models && p.models.length > 0)
.map((p) => ({
key: p.id,
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
type: 'group',
children: reverse(sortBy(p.models, 'name'))
.map((p) => {
const filteredModels = reverse(sortBy(p.models, 'name'))
.filter((m) =>
[m.name + m.provider + t('provider.' + p.id)].join('').toLowerCase().includes(searchText.toLowerCase())
)
@ -45,7 +72,17 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
key: getModelUniqId(m),
label: (
<ModelItem>
{m?.name} {isVisionModel(m) && <VisionIcon />}
<span>
{m?.name} {isVisionModel(m) && <VisionIcon />}
</span>
<PinIcon
onClick={(e) => {
e.stopPropagation()
togglePin(getModelUniqId(m))
}}
isPinned={pinnedModels.includes(getModelUniqId(m))}>
<PushpinOutlined />
</PinIcon>
</ModelItem>
),
icon: (
@ -58,8 +95,58 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
setOpen(false)
}
}))
}))
.filter((item) => item.children && item.children.length > 0) as MenuItem[]
// Only return the group if it has filtered models
return filteredModels.length > 0
? {
key: p.id,
label: p.isSystem ? t(`provider.${p.id}`) : p.name,
type: 'group',
children: filteredModels
}
: null
})
.filter(Boolean) as MenuItem[] // Filter out null items
if (pinnedModels.length > 0 && searchText.length === 0) {
const pinnedItems = providers
.flatMap((p) => p.models || [])
.filter((m) => pinnedModels.includes(getModelUniqId(m)))
.map((m) => ({
key: getModelUniqId(m),
label: (
<ModelItem>
{m?.name} {isVisionModel(m) && <VisionIcon />}
<PinIcon
onClick={(e) => {
e.stopPropagation()
togglePin(getModelUniqId(m))
}}
isPinned={true}>
<PushpinOutlined />
</PinIcon>
</ModelItem>
),
icon: (
<Avatar src={getModelLogo(m?.id || '')} size={24}>
{first(m?.name)}
</Avatar>
),
onClick: () => {
resolve(m)
setOpen(false)
}
}))
if (pinnedItems.length > 0) {
filteredItems.unshift({
key: 'pinned',
label: t('model.pinned'),
type: 'group',
children: pinnedItems
} as MenuItem)
}
}
const onCancel = () => {
setOpen(false)
@ -81,7 +168,15 @@ const PopupContainer: React.FC<PopupContainerProps> = ({ model, resolve }) => {
onCancel={onCancel}
afterClose={onClose}
transitionName="ant-move-down"
styles={{ content: { borderRadius: 20, padding: 0, overflow: 'hidden', paddingBottom: 20 } }}
styles={{
content: {
borderRadius: 20,
padding: 0,
overflow: 'hidden',
paddingBottom: 20,
border: '1px solid var(--color-border)'
}
}}
closeIcon={null}
footer={null}>
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
@ -141,6 +236,23 @@ const StyledMenu = styled(Menu)`
.ant-menu-item {
height: 36px;
line-height: 36px;
&.ant-menu-item-selected {
background-color: var(--color-background-mute) !important;
color: var(--color-text-primary) !important;
}
&:not([data-menu-id^='pinned-']) {
.pin-icon {
opacity: 0;
}
&:hover {
.pin-icon {
opacity: 0.3;
}
}
}
}
`
@ -148,6 +260,8 @@ const ModelItem = styled.div`
display: flex;
align-items: center;
font-size: 14px;
position: relative;
width: 100%;
`
const EmptyState = styled.div`
@ -169,8 +283,23 @@ const SearchIcon = styled.div`
margin-right: 2px;
`
const PinIcon = styled.span.attrs({ className: 'pin-icon' })<{ isPinned: boolean }>`
margin-left: auto;
padding: 0 8px;
opacity: ${(props) => (props.isPinned ? 1 : 'inherit')};
transition: opacity 0.2s;
position: absolute;
right: 0;
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
transform: ${(props) => (props.isPinned ? 'rotate(-45deg)' : 'none')};
&:hover {
opacity: 1 !important;
color: ${(props) => (props.isPinned ? 'var(--color-primary)' : 'inherit')};
}
`
export default class SelectModelPopup {
static topviewId = 0
static hide() {
TopView.hide('SelectModelPopup')
}

View File

@ -27,17 +27,28 @@ const PopupContainer: React.FC<Props> = ({ title, resolve }) => {
resolve({})
}
TemplatePopup.hide = onCancel
return (
<Modal title={title} open={open} onOk={onOk} onCancel={onCancel} afterClose={onClose}>
<Modal
title={title}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
transitionName="ant-move-down"
centered>
<Box mb={8}>Name</Box>
</Modal>
)
}
const TopViewKey = 'TemplatePopup'
export default class TemplatePopup {
static topviewId = 0
static hide() {
TopView.hide('TemplatePopup')
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
@ -46,10 +57,10 @@ export default class TemplatePopup {
{...props}
resolve={(v) => {
resolve(v)
this.hide()
TopView.hide(TopViewKey)
}}
/>,
'TemplatePopup'
TopViewKey
)
})
}

View File

@ -49,6 +49,8 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
setTimeout(resizeTextArea, 0)
}, [])
TextEditPopup.hide = onCancel
return (
<Modal
title={t('common.edit')}
@ -75,10 +77,12 @@ const PopupContainer: React.FC<Props> = ({ text, textareaProps, modalProps, reso
)
}
const TopViewKey = 'TextEditPopup'
export default class TextEditPopup {
static topviewId = 0
static hide() {
TopView.hide('TextEditPopup')
TopView.hide(TopViewKey)
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
@ -87,10 +91,10 @@ export default class TextEditPopup {
{...props}
resolve={(v) => {
resolve(v)
this.hide()
TopView.hide(TopViewKey)
}}
/>,
'TextEditPopup'
TopViewKey
)
})
}

View File

@ -1,27 +1,41 @@
import { TranslationOutlined } from '@ant-design/icons'
import { LoadingOutlined, TranslationOutlined } from '@ant-design/icons'
import { useDefaultModel } from '@renderer/hooks/useAssistant'
import { fetchTranslate } from '@renderer/services/ApiService'
import { getDefaultTopic, getDefaultTranslateAssistant } from '@renderer/services/AssistantService'
import { getUserMessage } from '@renderer/services/MessagesService'
import { Button } from 'antd'
import { FC, useState } from 'react'
import { Button, Tooltip } from 'antd'
import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
text?: string
onTranslated: (translatedText: string) => void
disabled?: boolean
style?: React.CSSProperties
isLoading?: boolean
}
const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style }) => {
const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style, isLoading }) => {
const { t } = useTranslation()
const { translateModel } = useDefaultModel()
const [isTranslating, setIsTranslating] = useState(false)
const translateConfirm = () => {
return window?.modal?.confirm({
title: t('translate.confirm.title'),
content: t('translate.confirm.content'),
centered: true
})
}
const handleTranslate = async () => {
if (!text?.trim()) return
if (!(await translateConfirm())) {
return
}
if (!translateModel) {
window.message.error({
content: t('translate.error.not_configured'),
@ -39,7 +53,8 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style }) =>
const message = getUserMessage({
assistant,
topic: getDefaultTopic('default'),
type: 'text'
type: 'text',
content: text
})
const translatedText = await fetchTranslate({ message, assistant })
@ -55,16 +70,53 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style }) =>
}
}
useEffect(() => {
setIsTranslating(isLoading ?? false)
}, [isLoading])
return (
<Button
icon={<TranslationOutlined style={{ fontSize: 14 }} />}
onClick={handleTranslate}
disabled={disabled || isTranslating}
loading={isTranslating}
style={style}
size="small"
/>
<Tooltip placement="top" title={t('chat.input.translate')} arrow>
<ToolbarButton onClick={handleTranslate} disabled={disabled || isTranslating} style={style} type="text">
{isTranslating ? <LoadingOutlined spin /> : <TranslationOutlined />}
</ToolbarButton>
</Tooltip>
)
}
const ToolbarButton = styled(Button)`
min-width: 30px;
height: 30px;
font-size: 17px;
border-radius: 50%;
transition: all 0.3s ease;
color: var(--color-icon);
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0;
&.anticon,
&.iconfont {
transition: all 0.3s ease;
color: var(--color-icon);
}
&:hover {
background-color: var(--color-background-soft);
.anticon,
.iconfont {
color: var(--color-text-1);
}
}
&.active {
background-color: var(--color-primary) !important;
.anticon,
.iconfont {
color: var(--color-white-soft);
}
&:hover {
background-color: var(--color-primary);
}
}
`
export default TranslateButton

View File

@ -38,7 +38,6 @@ const NavbarContainer = styled.div`
max-height: var(--navbar-height);
margin-left: ${isMac ? 'calc(var(--sidebar-width) * -1)' : 0};
padding-left: ${isMac ? 'var(--sidebar-width)' : 0};
transition: background-color 0.3s ease;
-webkit-app-region: drag;
`

View File

@ -1,10 +1,11 @@
import { FileSearchOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons'
import { FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { Tooltip } from 'antd'
import { Avatar } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
@ -50,57 +51,67 @@ const Sidebar: FC = () => {
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
<MainMenus>
<Menus onClick={MinApp.onClose}>
<StyledLink onClick={() => to('/')}>
<Icon className={isRoute('/')}>
<i className="iconfont icon-chat" />
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/agents')}>
<Icon className={isRoutes('/agents')}>
<i className="iconfont icon-business-smart-assistant" />
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/paintings')}>
<Icon className={isRoute('/paintings')}>
<PictureOutlined style={{ fontSize: 16 }} />
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/translate')}>
<Icon className={isRoute('/translate')}>
<TranslationOutlined />
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/apps')}>
<Icon className={isRoute('/apps')}>
<i className="iconfont icon-appstore" />
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/files')}>
<Icon className={isRoute('/files')}>
<FolderOutlined />
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/messages')}>
<Icon className={isRoutes('/messages')}>
<FileSearchOutlined />
</Icon>
</StyledLink>
<Tooltip title={t('assistants.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to('/')}>
<Icon className={isRoute('/')}>
<i className="iconfont icon-chat" />
</Icon>
</StyledLink>
</Tooltip>
<Tooltip title={t('agents.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to('/agents')}>
<Icon className={isRoutes('/agents')}>
<i className="iconfont icon-business-smart-assistant" />
</Icon>
</StyledLink>
</Tooltip>
<Tooltip title={t('paintings.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to('/paintings')}>
<Icon className={isRoute('/paintings')}>
<PictureOutlined style={{ fontSize: 16 }} />
</Icon>
</StyledLink>
</Tooltip>
<Tooltip title={t('translate.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to('/translate')}>
<Icon className={isRoute('/translate')}>
<TranslationOutlined />
</Icon>
</StyledLink>
</Tooltip>
<Tooltip title={t('minapp.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to('/apps')}>
<Icon className={isRoute('/apps')}>
<i className="iconfont icon-appstore" />
</Icon>
</StyledLink>
</Tooltip>
<Tooltip title={t('files.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to('/files')}>
<Icon className={isRoute('/files')}>
<FolderOutlined />
</Icon>
</StyledLink>
</Tooltip>
</Menus>
</MainMenus>
<Menus onClick={MinApp.onClose}>
<Icon onClick={() => toggleTheme()}>
{theme === 'dark' ? (
<i className="iconfont icon-theme icon-dark1" />
) : (
<i className="iconfont icon-theme icon-theme-light" />
)}
</Icon>
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
<i className="iconfont icon-setting" />
<Tooltip title={t('settings.theme.title')} mouseEnterDelay={0.8} placement="right">
<Icon onClick={() => toggleTheme()}>
{theme === 'dark' ? (
<i className="iconfont icon-theme icon-dark1" />
) : (
<i className="iconfont icon-theme icon-theme-light" />
)}
</Icon>
</StyledLink>
</Tooltip>
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
<i className="iconfont icon-setting" />
</Icon>
</StyledLink>
</Tooltip>
</Menus>
</Container>
)
@ -116,7 +127,6 @@ const Container = styled.div`
height: ${isMac ? 'calc(100vh - var(--navbar-height))' : '100vh'};
-webkit-app-region: drag !important;
margin-top: ${isMac ? 'var(--navbar-height)' : 0};
transition: background-color 0.3s ease;
`
const AvatarImg = styled(Avatar)`
@ -147,14 +157,12 @@ const Icon = styled.div`
align-items: center;
border-radius: 50%;
margin-bottom: 5px;
transition: background-color 0.2s ease;
-webkit-app-region: none;
transition: all 0.2s ease;
border: 0.5px solid transparent;
.iconfont,
.anticon {
color: var(--color-icon);
font-size: 20px;
transition: color 0.2s ease;
text-decoration: none;
}
.anticon {
@ -170,6 +178,7 @@ const Icon = styled.div`
}
&.active {
background-color: var(--color-active);
border: 0.5px solid var(--color-border);
.iconfont,
.anticon {
color: var(--color-icon-white);

File diff suppressed because one or more lines are too long

View File

@ -1,99 +1,10 @@
export const DEFAULT_TEMPERATURE = 0.7
export const DEFAULT_CONEXTCOUNT = 5
export const DEFAULT_CONTEXTCOUNT = 5
export const DEFAULT_MAX_TOKENS = 4096
export const FONT_FAMILY =
"Ubuntu, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif"
export const platform = window.electron?.process?.platform
export const isMac = platform === 'darwin'
export const isWindows = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux'
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const textExts = [
'.txt', // 普通文本文件
'.md', // Markdown 文件
'.mdx', // Markdown 文件
'.html', // HTML 文件
'.htm', // HTML 文件的另一种扩展名
'.xml', // XML 文件
'.json', // JSON 文件
'.yaml', // YAML 文件
'.yml', // YAML 文件的另一种扩展名
'.csv', // 逗号分隔值文件
'.tsv', // 制表符分隔值文件
'.ini', // 配置文件
'.log', // 日志文件
'.rtf', // 富文本格式文件
'.tex', // LaTeX 文件
'.srt', // 字幕文件
'.xhtml', // XHTML 文件
'.nfo', // 信息文件(主要用于场景发布)
'.conf', // 配置文件
'.config', // 配置文件
'.env', // 环境变量文件
'.rst', // reStructuredText 文件
'.php', // PHP 脚本文件,包含嵌入的 HTML
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
'.ts', // TypeScript 文件
'.jsp', // JavaServer Pages 文件
'.aspx', // ASP.NET 文件
'.bat', // Windows 批处理文件
'.sh', // Unix/Linux Shell 脚本文件
'.py', // Python 脚本文件
'.rb', // Ruby 脚本文件
'.pl', // Perl 脚本文件
'.sql', // SQL 脚本文件
'.css', // Cascading Style Sheets 文件
'.less', // Less CSS 预处理器文件
'.scss', // Sass CSS 预处理器文件
'.sass', // Sass 文件
'.styl', // Stylus CSS 预处理器文件
'.coffee', // CoffeeScript 文件
'.ino', // Arduino 代码文件
'.asm', // Assembly 语言文件
'.go', // Go 语言文件
'.scala', // Scala 语言文件
'.swift', // Swift 语言文件
'.kt', // Kotlin 语言文件
'.rs', // Rust 语言文件
'.lua', // Lua 语言文件
'.groovy', // Groovy 语言文件
'.dart', // Dart 语言文件
'.hs', // Haskell 语言文件
'.clj', // Clojure 语言文件
'.cljs', // ClojureScript 语言文件
'.elm', // Elm 语言文件
'.erl', // Erlang 语言文件
'.ex', // Elixir 语言文件
'.exs', // Elixir 脚本文件
'.pug', // Pug (formerly Jade) 模板文件
'.haml', // Haml 模板文件
'.slim', // Slim 模板文件
'.tpl', // 模板文件(通用)
'.ejs', // Embedded JavaScript 模板文件
'.hbs', // Handlebars 模板文件
'.mustache', // Mustache 模板文件
'.jade', // Jade 模板文件 (已重命名为 Pug)
'.twig', // Twig 模板文件
'.blade', // Blade 模板文件 (Laravel)
'.vue', // Vue.js 单文件组件
'.jsx', // React JSX 文件
'.tsx', // React TSX 文件
'.graphql', // GraphQL 查询语言文件
'.gql', // GraphQL 查询语言文件
'.proto', // Protocol Buffers 文件
'.thrift', // Thrift 文件
'.toml', // TOML 配置文件
'.edn', // Clojure 数据表示文件
'.cake', // CakePHP 配置文件
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.kts', // Kotlin Script 文件
'.java' // Java 代码文件
]

View File

@ -5,6 +5,7 @@ import BaicuanAppLogo from '@renderer/assets/images/apps/baixiaoying.webp'
import BoltAppLogo from '@renderer/assets/images/apps/bolt.svg'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png'
import DoubaoAppLogo from '@renderer/assets/images/apps/doubao.png'
import DuckDuckGoAppLogo from '@renderer/assets/images/apps/duckduckgo.webp'
import FeloAppLogo from '@renderer/assets/images/apps/felo.png'
import GeminiAppLogo from '@renderer/assets/images/apps/gemini.png'
import HuggingChatLogo from '@renderer/assets/images/apps/huggingchat.svg'
@ -215,6 +216,12 @@ const _apps: MinAppType[] = [
logo: BoltAppLogo,
url: 'https://bolt.new/',
bodered: true
},
{
id: 'duckduckgo',
name: 'DuckDuckGo',
logo: DuckDuckGoAppLogo,
url: 'https://duck.ai'
}
]

View File

@ -129,19 +129,23 @@ const visionAllowedModels = [
'moondream',
'minicpm',
'gemini-1\\.5',
'gemini-exp',
'claude-3',
'vision',
'glm-4v',
'qwen-vl',
'qwen2-vl',
'internvl2',
'grok',
'pixtral',
'gpt-4(?:-[\\w-]+)',
'gpt-4o(?:-[\\w-]+)?'
'gpt-4o(?:-[\\w-]+)?',
'chatgpt-4o(?:-[\\w-]+)?'
]
const visionExcludedModels = ['gpt-4-\\d+-preview', 'gpt-4-turbo-preview', 'gpt-4-32k', 'gpt-4-\\d+']
const VISION_REGEX = new RegExp(
export const VISION_REGEX = new RegExp(
`\\b(?!(?:${visionExcludedModels.join('|')})\\b)(${visionAllowedModels.join('|')})\\b`,
'i'
)
@ -187,6 +191,7 @@ export function getModelLogo(modelId: string) {
palm: isLight ? PalmModelLogo : PalmModelLogoDark,
step: isLight ? StepModelLogo : StepModelLogoDark,
hailuo: isLight ? HailuoModelLogo : HailuoModelLogoDark,
doubao: isLight ? DoubaoModelLogo : DoubaoModelLogoDark,
'ep-202': isLight ? DoubaoModelLogo : DoubaoModelLogoDark,
cohere: isLight ? CohereModelLogo : CohereModelLogoDark,
command: isLight ? CohereModelLogo : CohereModelLogoDark,
@ -326,6 +331,12 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: ' GPT-4o-mini',
group: 'GPT 4o'
},
{
id: 'chatgpt-4o-latest',
provider: 'openai',
name: ' GPT-4o-latest',
group: 'GPT 4o'
},
{
id: 'gpt-4-turbo',
provider: 'openai',
@ -338,12 +349,6 @@ export const SYSTEM_MODELS: Record<string, Model[]> = {
name: ' GPT-4',
group: 'GPT 4'
},
{
id: 'gpt-3.5-turbo',
provider: 'openai',
name: ' GPT-3.5-turbo',
group: 'GPT 3.5'
},
{
id: 'o1-mini',
provider: 'openai',
@ -1039,7 +1044,7 @@ export function isEmbeddingModel(model: Model): boolean {
}
export function isVisionModel(model: Model): boolean {
return VISION_REGEX.test(model.id)
return VISION_REGEX.test(model.id) || model.type?.includes('vision') || false
}
export function isSupportedModel(model: OpenAI.Models.Model): boolean {

View File

@ -46,3 +46,6 @@ export const AGENT_PROMPT = `
export const SUMMARIZE_PROMPT =
'你是一名擅长会话的助理,你需要将用户的会话总结为 10 个字以内的标题,不要使用标点符号和其他特殊符号。'
export const TRANSLATE_PROMPT =
'You are a translation expert. Translate from input language to {{target_language}}, provide the translation result directly without any explanation and keep original format. Do not translate if the target language is the same as the source language.'

View File

@ -11,8 +11,12 @@ import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.p
import GithubProviderLogo from '@renderer/assets/images/providers/github.png'
import GoogleProviderLogo from '@renderer/assets/images/providers/google.png'
import GraphRagProviderLogo from '@renderer/assets/images/providers/graph-rag.png'
import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png'
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
import MinimaxProviderLogo from '@renderer/assets/images/providers/minimax.png'
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
import MoonshotProviderLogo from '@renderer/assets/images/providers/moonshot.png'
import NvidiaProviderLogo from '@renderer/assets/images/providers/nvidia.png'
import OcoolAiProviderLogo from '@renderer/assets/images/providers/ocoolai.png'
@ -24,10 +28,6 @@ import StepProviderLogo from '@renderer/assets/images/providers/step.png'
import TogetherProviderLogo from '@renderer/assets/images/providers/together.png'
import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png'
import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png'
import GrokProviderLogo from '@renderer/assets/images/providers/grok.png'
import HyperbolicProviderLogo from '@renderer/assets/images/providers/hyperbolic.png'
import MistralProviderLogo from '@renderer/assets/images/providers/mistral.png'
import JinaProviderLogo from '@renderer/assets/images/providers/jina.png'
export function getProviderLogo(providerId: string) {
switch (providerId) {
@ -326,6 +326,7 @@ export const PROVIDER_CONFIG = {
},
websites: {
official: 'https://app.hyperbolic.xyz',
apiKey: 'https://app.hyperbolic.xyz/settings',
docs: 'https://docs.hyperbolic.xyz',
models: 'https://app.hyperbolic.xyz/models'
}
@ -336,6 +337,7 @@ export const PROVIDER_CONFIG = {
},
websites: {
official: 'https://mistral.ai',
apiKey: 'https://console.mistral.ai/api-keys/',
docs: 'https://docs.mistral.ai',
models: 'https://docs.mistral.ai/getting-started/models/models_overview'
}
@ -346,6 +348,7 @@ export const PROVIDER_CONFIG = {
},
websites: {
official: 'https://jina.ai',
apiKey: 'https://jina.ai/',
docs: 'https://jina.ai',
models: 'https://jina.ai'
}

View File

@ -1,6 +1,10 @@
import { useSettings } from '@renderer/hooks/useSettings'
import { LanguageVarious } from '@renderer/types'
import { ConfigProvider, theme } from 'antd'
import enUS from 'antd/locale/en_US'
import ruRU from 'antd/locale/ru_RU'
import zhCN from 'antd/locale/zh_CN'
import zhTW from 'antd/locale/zh_TW'
import { FC, PropsWithChildren } from 'react'
import { useTheme } from './ThemeProvider'
@ -18,11 +22,11 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
components: {
Segmented: {
trackBg: 'transparent',
itemSelectedBg: isDarkTheme ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)',
itemSelectedBg: isDarkTheme ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
boxShadowTertiary: undefined,
borderRadiusLG: 12,
borderRadiusSM: 12,
borderRadiusXS: 12
borderRadiusLG: 16,
borderRadiusSM: 16,
borderRadiusXS: 16
},
Menu: {
activeBarBorderWidth: 0,
@ -38,12 +42,17 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
)
}
function getAntdLocale(language: string) {
function getAntdLocale(language: LanguageVarious) {
switch (language) {
case 'zh-CN':
return zhCN
case 'zh-TW':
return zhTW
case 'en-US':
return undefined
return enUS
case 'ru-RU':
return ruRU
default:
return zhCN
}

View File

@ -0,0 +1,88 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { useSettings } from '@renderer/hooks/useSettings'
import { CodeStyleVarious, ThemeMode } from '@renderer/types'
import React, { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
import {
BundledLanguage,
bundledLanguages,
BundledTheme,
bundledThemes,
createHighlighter,
HighlighterGeneric
} from 'shiki'
interface SyntaxHighlighterContextType {
codeToHtml: (code: string, language: string) => Promise<string>
}
const SyntaxHighlighterContext = createContext<SyntaxHighlighterContextType | undefined>(undefined)
export const SyntaxHighlighterProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { theme } = useTheme()
const [highlighter, setHighlighter] = useState<HighlighterGeneric<BundledLanguage, BundledTheme> | null>(null)
const { codeStyle } = useSettings()
useMermaid()
const highlighterTheme = useMemo(() => {
if (!codeStyle || codeStyle === 'auto') {
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
}
return codeStyle
}, [theme, codeStyle])
useEffect(() => {
const initHighlighter = async () => {
const commonLanguages = ['javascript', 'typescript', 'python', 'java', 'markdown']
const hl = await createHighlighter({
themes: [highlighterTheme],
langs: commonLanguages
})
setHighlighter(hl)
// Load all themes and languages
// hl.loadTheme(...(Object.keys(bundledThemes) as BundledTheme[]))
// hl.loadLanguage(...(Object.keys(bundledLanguages) as BundledLanguage[]))
}
initHighlighter()
}, [highlighterTheme])
const codeToHtml = async (code: string, language: string) => {
if (!highlighter) return ''
try {
if (!highlighter.getLoadedLanguages().includes(language as BundledLanguage)) {
if (language in bundledLanguages || language === 'text') {
await highlighter.loadLanguage(language as BundledLanguage)
console.log(`Loaded language: ${language}`)
} else {
return `<pre style="padding: 10px"><code>${code}</code></pre>`
}
}
return highlighter.codeToHtml(code, {
lang: language,
theme: highlighterTheme
})
} catch (error) {
console.warn(`Error highlighting code for language '${language}':`, error)
return `<pre style="padding: 10px"><code>${code}</code></pre>`
}
}
return <SyntaxHighlighterContext.Provider value={{ codeToHtml }}>{children}</SyntaxHighlighterContext.Provider>
}
export const useSyntaxHighlighter = () => {
const context = useContext(SyntaxHighlighterContext)
if (!context) {
throw new Error('useSyntaxHighlighter must be used within a SyntaxHighlighterProvider')
}
return context
}
export const codeThemes = ['auto', ...Object.keys(bundledThemes)] as CodeStyleVarious[]

View File

@ -1,8 +1,6 @@
import { FileType, Topic } from '@renderer/types'
import { Dexie, type EntityTable } from 'dexie'
import { populateTopics } from './populate'
// Database declaration (move this to its own module also)
export const db = new Dexie('CherryStudio') as Dexie & {
files: EntityTable<FileType, 'id'>
@ -14,14 +12,10 @@ db.version(1).stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count'
})
db.version(2)
.stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id, messages',
settings: '&id, value'
})
.upgrade(populateTopics)
db.on('populate', populateTopics)
db.version(2).stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id, messages',
settings: '&id, value'
})
export default db

View File

@ -1,27 +0,0 @@
import i18n from '@renderer/i18n'
import { Transaction } from 'dexie'
import localforage from 'localforage'
export async function populateTopics(trans: Transaction) {
const indexedKeys = await localforage.keys()
if (indexedKeys.length > 0) {
for (const key of indexedKeys) {
const value: any = await localforage.getItem(key)
if (key.startsWith('topic:')) {
await trans.db.table('topics').add({ id: value.id, messages: value.messages })
}
if (key === 'image://avatar') {
await trans.db.table('settings').add({ id: key, value: await localforage.getItem(key) })
}
}
window.modal.success({
title: i18n.t('message.upgrade.success.title'),
content: i18n.t('message.upgrade.success.content'),
okText: i18n.t('message.upgrade.success.button'),
centered: true,
onOk: () => window.api.reload()
})
}
}

View File

@ -14,7 +14,7 @@ import { useSettings } from './useSettings'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language, windowStyle, manualUpdateCheck } = useSettings()
const { proxyUrl, language, windowStyle, manualUpdateCheck, proxyMode } = useSettings()
const { minappShow } = useRuntime()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
@ -35,8 +35,14 @@ export function useAppInit() {
}, [])
useEffect(() => {
proxyUrl && window.api.setProxy(proxyUrl)
}, [proxyUrl])
if (proxyMode === 'system') {
window.api.setProxy('system')
} else if (proxyMode === 'custom') {
proxyUrl && window.api.setProxy(proxyUrl)
} else {
window.api.setProxy('')
}
}, [proxyUrl, proxyMode])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || 'en-US')

View File

@ -0,0 +1,40 @@
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { loadScript, runAsyncFunction } from '@renderer/utils'
import { useEffect } from 'react'
import { useRuntime } from './useRuntime'
export const useMermaid = () => {
const { theme } = useTheme()
const { generating } = useRuntime()
useEffect(() => {
runAsyncFunction(async () => {
if (!window.mermaid) {
await loadScript('https://unpkg.com/mermaid@11.4.0/dist/mermaid.min.js')
}
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default'
})
window.mermaid.contentLoaded()
})
}, [theme])
useEffect(() => {
if (!window.mermaid || generating) return
const renderMermaid = () => {
const mermaidElements = document.querySelectorAll('.mermaid')
mermaidElements.forEach((element) => {
if (!element.querySelector('svg')) {
element.removeAttribute('data-processed')
}
})
window.mermaid.contentLoaded()
}
setTimeout(renderMermaid, 100)
}, [generating])
}

View File

@ -8,6 +8,7 @@ import { uuid } from '@renderer/utils'
export function usePaintings() {
const paintings = useAppSelector((state) => state.paintings.paintings)
const dispatch = useAppDispatch()
const generateRandomSeed = () => Math.floor(Math.random() * 1000000).toString()
return {
paintings,
@ -20,7 +21,7 @@ export function usePaintings() {
negativePrompt: '',
imageSize: '1024x1024',
numImages: 1,
seed: '',
seed: generateRandomSeed(),
steps: 25,
guidanceScale: 4.5,
model: TEXT_TO_IMAGES_MODELS[0].id

View File

@ -4,6 +4,7 @@ import {
setSendMessageShortcut as _setSendMessageShortcut,
setTheme,
setTopicPosition,
setTray,
setWindowStyle
} from '@renderer/store/settings'
import { ThemeMode } from '@renderer/types'
@ -17,6 +18,9 @@ export function useSettings() {
setSendMessageShortcut(shortcut: SendMessageShortcut) {
dispatch(_setSendMessageShortcut(shortcut))
},
setTray(isActive: boolean) {
dispatch(setTray(isActive))
},
setTheme(theme: ThemeMode) {
dispatch(setTheme(theme))
},

View File

@ -0,0 +1,60 @@
import { useAppSelector } from '@renderer/store'
import { useCallback } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
interface UseShortcutOptions {
preventDefault?: boolean
enableOnFormTags?: boolean
enabled?: boolean
description?: string
}
const defaultOptions: UseShortcutOptions = {
preventDefault: true,
enableOnFormTags: true,
enabled: true
}
export const useShortcut = (
shortcutKey: string,
callback: (e: KeyboardEvent) => void,
options: UseShortcutOptions = defaultOptions
) => {
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const formatShortcut = useCallback((shortcut: string[]) => {
return shortcut
.map((key) => {
switch (key.toLowerCase()) {
case 'command':
return 'meta'
default:
return key.toLowerCase()
}
})
.join('+')
}, [])
const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
useHotkeys(
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : '',
(e) => {
if (options.preventDefault) {
e.preventDefault()
}
if (options.enabled !== false) {
callback(e)
}
},
{
enableOnFormTags: options.enableOnFormTags,
description: options.description || shortcutConfig?.key
}
)
}
export function useShortcuts() {
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
return { shortcuts }
}

View File

@ -2,13 +2,15 @@ import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import enUS from './locales/en-us.json'
import ruRU from './locales/ru-ru.json'
import zhCN from './locales/zh-cn.json'
import zhTW from './locales/zh-tw.json'
const resources = {
'en-US': enUS,
'zh-CN': zhCN,
'zh-TW': zhTW
'zh-TW': zhTW,
'ru-RU': ruRU
}
i18n.use(initReactI18next).init({

View File

@ -1,407 +1,511 @@
{
"translation": {
"common": {
"avatar": "Avatar",
"language": "Language",
"model": "Model",
"models": "Models",
"topics": "Topics",
"docs": "Docs",
"and": "and",
"assistant": "Assistant",
"name": "Name",
"description": "Description",
"prompt": "Prompt",
"rename": "Rename",
"delete": "Delete",
"edit": "Edit",
"duplicate": "Duplicate",
"copy": "Copy",
"regenerate": "Regenerate",
"provider": "Provider",
"you": "You",
"save": "Save",
"footnotes": "References",
"select": "Select",
"search": "Search",
"default": "Default",
"warning": "Warning",
"back": "Back",
"chat": "Chat",
"close": "Close",
"cancel": "Cancel",
"download": "Download"
},
"button": {
"add": "Add",
"added": "Added",
"manage": "Manage",
"select_model": "Select Model",
"show.all": "Show All",
"collapse": "Collapse"
},
"message": {
"copied": "Copied!",
"assistant.added.content": "Assistant added successfully",
"message.delete.title": "Delete Message",
"message.delete.content": "Are you sure you want to delete this message?",
"error.enter.api.key": "Please enter your API key first",
"error.enter.api.host": "Please enter your API host first",
"error.enter.model": "Please select a model first",
"error.invalid.proxy.url": "Invalid proxy URL",
"error.invalid.webdav": "Invalid WebDAV settings",
"api.connection.failed": "Connection failed",
"api.connection.success": "Connection successful",
"chat.completion.paused": "Chat completion paused",
"switch.disabled": "Switching is disabled while the assistant is generating",
"restore.success": "Restored successfully",
"backup.success": "Backup successful",
"backup.failed": "Backup failed",
"reset.confirm.content": "Are you sure you want to clear all data?",
"reset.double.confirm.title": "DATA LOST !!!",
"reset.double.confirm.content": "All data will be lost, do you want to continue?",
"upgrade.success.title": "Upgrade successfully",
"upgrade.success.content": "Please restart the application to complete the upgrade",
"upgrade.success.button": "Restart",
"topic.added": "New topic added",
"save.success.title": "Saved successfully",
"message.style": "Message Style",
"message.style.bubble": "Bubble",
"message.style.plain": "Plain"
},
"chat": {
"save": "Save",
"default.name": "⭐️ Default Assistant",
"default.description": "Hello, I'm Default Assistant. You can start chatting with me right away",
"default.topic.name": "Default Topic",
"topics.title": "Topics",
"topics.auto_rename": "Auto Rename",
"topics.edit.title": "Edit Name",
"topics.edit.placeholder": "Enter new name",
"topics.clear.title": "Clear Messages",
"topics.move_to": "Move to",
"topics.list": "Topic List",
"topics.export.title": "Export",
"topics.export.image": "Export as image",
"topics.export.md": "Export as markdown",
"topics.export.word": "Export as Word",
"input.new_topic": "New Topic",
"input.topics": " Topics ",
"input.clear": "Clear",
"input.new.context": "Clear Context",
"input.expand": "Expand",
"input.collapse": "Collapse",
"input.clear.title": "Clear all messages?",
"input.clear.content": "Do you want to clear all messages of the current topic?",
"input.placeholder": "Type your message here...",
"input.send": "Send",
"input.pause": "Pause",
"input.settings": "Settings",
"input.upload": "Upload image or document file",
"input.context_count.tip": "Context Count",
"input.estimated_tokens.tip": "Estimated tokens",
"settings.temperature": "Temperature",
"settings.temperature.tip": "Lower values make the model more creative and unpredictable, while higher values make it more deterministic and precise.",
"settings.conext_count": "Context",
"settings.conext_count.tip": "The number of previous messages to keep in the context.",
"settings.max_tokens": "Enable Max Tokens Limit",
"settings.max_tokens.tip": "The maximum number of tokens the model can generate. Normal chat suggests 500-800. Short text generation suggests 800-2000. Code generation suggests 2000-3600. Long text generation suggests above 4000.",
"settings.reset": "Reset",
"settings.set_as_default": "Apply to default assistant",
"settings.max": "Max",
"settings.show_line_numbers": "Show Line Numbers in Code",
"suggestions.title": "Suggested Questions",
"add.assistant.title": "Add Assistant",
"message.new.context": "New Context",
"message.new.branch": "New Branch",
"message.new.branch.created": "New Branch Created",
"assistant.search.placeholder": "Search",
"artifacts.button.preview": "Preview",
"artifacts.button.download": "Download"
},
"assistants": {
"title": "Assistants",
"abbr": "Assistant",
"search": "Search assistants...",
"settings.prompt": "Prompt Settings",
"settings.model": "Model Settings",
"settings.preset_messages": "Preset Messages",
"settings.default_model": "Default Model",
"settings.auto_reset_model": "Auto Reset Model",
"settings.auto_reset_model.tip": "Automatically reset the model when a new topic is created.",
"edit.title": "Edit Assistant",
"copy.title": "Copy Assistant",
"clear.title": "Clear topics",
"clear.content": "Clearing the topic will delete all topics and files in the assistant. Are you sure you want to continue?",
"save.title": "Save to agent",
"save.success": "Saved successfully",
"delete.title": "Delete Assistant",
"delete.content": "Deleting an assistant will delete all topics and files under the assistant. Are you sure you want to delete it?"
},
"model": {
"stream_output": "Stream Output",
"search": "Search models..."
},
"images": {
"title": "Images",
"image.size": "Image Size",
"button.new.image": "New Image",
"button.delete.image": "Delete Image",
"button.delete.image.confirm": "Are you sure you want to delete this image?",
"number_images": "Number Images",
"number_images_tip": "Number of images to generate (1-4)",
"seed": "Seed",
"seed_tip": "The same seed and prompt can produce similar images",
"inference_steps": "Inference Steps",
"inference_steps_tip": "The number of inference steps to perform. More steps produce higher quality but take longer",
"guidance_scale": "Guidance Scale",
"guidance_scale_tip": "Classifier Free Guidance. How close you want the model to stick to your prompt when looking for a related image to show you",
"negative_prompt": "Negative Prompt",
"negative_prompt_tip": "Describe what you don't want included in the image",
"prompt_placeholder": "Describe the image you want to create, e.g. A serene lake at sunset with mountains in the background",
"regenerate.confirm": "This will replace your existing generated images. Do you want to continue?"
},
"files": {
"title": "Files",
"file": "File",
"name": "Name",
"size": "Size",
"count": "Count",
"created_at": "Created At",
"image": "Image",
"text": "Text",
"document": "Document",
"actions": "Actions",
"open": "Open",
"all": "All Files"
},
"agents": {
"title": "Agents",
"my_agents": "My Agents",
"add.title": "Create Agent",
"edit.title": "Edit Agent",
"add.button": "Add to Assistant",
"add.name": "Name",
"add.name.placeholder": "Enter name",
"add.prompt": "Prompt",
"add.prompt.placeholder": "Enter prompt",
"add.button": "Add to Assistant",
"manage.title": "Manage Agents",
"add.title": "Create Agent",
"delete.popup.content": "Are you sure you want to delete this agent?",
"tag.default": "Default",
"tag.system": "System",
"tag.agent": "Agent",
"edit.message.title": "Preset messages",
"edit.message.add.title": "Add",
"edit.message.group.title": "Message Group",
"edit.message.assistant.title": "Assistant",
"edit.message.assistant.placeholder": "Enter assistant message",
"edit.message.user.title": "User",
"edit.message.user.placeholder": "Enter user message",
"edit.message.assistant.title": "Assistant",
"edit.message.empty.content": "Conversation input content cannot be empty",
"edit.message.group.title": "Message Group",
"edit.message.title": "Preset messages",
"edit.message.user.placeholder": "Enter user message",
"edit.message.user.title": "User",
"edit.model.select.title": "Select Model",
"edit.settings.hide_preset_messages": "Hide Preset Message",
"edit.title": "Edit Agent",
"manage.title": "Manage Agents",
"my_agents": "My Agents",
"search.no_results": "No results found",
"sorting.title": "Sorting"
"sorting.title": "Sorting",
"tag.agent": "Agent",
"tag.default": "Default",
"tag.new": "New",
"tag.system": "System",
"title": "Agents"
},
"assistants": {
"abbr": "Assistant",
"clear.content": "Clearing the topic will delete all topics and files in the assistant. Are you sure you want to continue?",
"clear.title": "Clear topics",
"copy.title": "Copy Assistant",
"delete.content": "Deleting an assistant will delete all topics and files under the assistant. Are you sure you want to delete it?",
"delete.title": "Delete Assistant",
"edit.title": "Edit Assistant",
"save.success": "Saved successfully",
"save.title": "Save to agent",
"search": "Search assistants...",
"settings.auto_reset_model": "Auto Reset Model",
"settings.auto_reset_model.tip": "Automatically reset the model when a new topic is created.",
"settings.default_model": "Default Model",
"settings.model": "Model Settings",
"settings.preset_messages": "Preset Messages",
"settings.prompt": "Prompt Settings",
"title": "Assistants"
},
"button": {
"add": "Add",
"added": "Added",
"collapse": "Collapse",
"manage": "Manage",
"select_model": "Select Model",
"show.all": "Show All"
},
"chat": {
"add.assistant.title": "Add Assistant",
"artifacts.button.download": "Download",
"artifacts.button.preview": "Preview",
"assistant.search.placeholder": "Search",
"default.description": "Hello, I'm Default Assistant. You can start chatting with me right away",
"default.name": "⭐️ Default Assistant",
"default.topic.name": "Default Topic",
"input.clear": "Clear",
"input.clear.content": "Do you want to clear all messages of the current topic?",
"input.clear.title": "Clear all messages?",
"input.collapse": "Collapse",
"input.context_count.tip": "Context Count",
"input.estimated_tokens.tip": "Estimated tokens",
"input.expand": "Expand",
"input.new.context": "Clear Context",
"input.new_topic": "New Topic {{Command}}+N",
"input.pause": "Pause",
"input.placeholder": "Type your message here...",
"input.send": "Send",
"input.settings": "Settings",
"input.topics": " Topics ",
"input.translate": "Translate to English",
"input.upload": "Upload image or document file",
"message.new.branch": "New Branch",
"message.new.branch.created": "New Branch Created",
"message.new.context": "New Context",
"save": "Save",
"settings.code_collapsible": "Code block collapsible",
"settings.context_count": "Context",
"settings.context_count.tip": "The number of previous messages to keep in the context.",
"settings.max": "Max",
"settings.max_tokens": "Enable max tokens limit",
"settings.max_tokens.tip": "The maximum number of tokens the model can generate. Normal chat suggests 500-800. Short text generation suggests 800-2000. Code generation suggests 2000-3600. Long text generation suggests above 4000.",
"settings.reset": "Reset",
"settings.set_as_default": "Apply to default assistant",
"settings.show_line_numbers": "Show line numbers in code",
"settings.temperature": "Temperature",
"settings.temperature.tip": "Lower values make the model more creative and unpredictable, while higher values make it more deterministic and precise.",
"suggestions.title": "Suggested Questions",
"topics.auto_rename": "Auto Rename",
"topics.clear.title": "Clear Messages",
"topics.edit.placeholder": "Enter new name",
"topics.edit.title": "Edit Name",
"topics.export.image": "Export as image",
"topics.export.md": "Export as markdown",
"topics.export.title": "Export",
"topics.export.word": "Export as Word",
"topics.list": "Topic List",
"topics.move_to": "Move to",
"topics.title": "Topics",
"translate": "Translate"
},
"common": {
"and": "and",
"assistant": "Assistant",
"avatar": "Avatar",
"back": "Back",
"cancel": "Cancel",
"chat": "Chat",
"close": "Close",
"copy": "Copy",
"cut": "Cut",
"default": "Default",
"delete": "Delete",
"description": "Description",
"docs": "Docs",
"download": "Download",
"duplicate": "Duplicate",
"edit": "Edit",
"footnotes": "References",
"language": "Language",
"model": "Model",
"models": "Models",
"name": "Name",
"paste": "Paste",
"prompt": "Prompt",
"provider": "Provider",
"regenerate": "Regenerate",
"rename": "Rename",
"reset": "Reset",
"save": "Save",
"search": "Search",
"select": "Select",
"topics": "Topics",
"warning": "Warning",
"you": "You",
"clear": "Clear",
"add": "Add"
},
"error": {
"backup.file_format": "Backup file format error",
"chat.response": "Something went wrong. Please check if you have set your API key in the Settings > Providers",
"no_api_key": "API key is not configured",
"provider_disabled": "Model provider is not enabled",
"render": {
"title": "Render Error",
"description": "Failed to render formula. Please check if the formula format is correct"
}
},
"export": {
"assistant": "Assistant",
"attached_files": "Attached Files",
"conversation_details": "Conversation Details",
"conversation_history": "Conversation History",
"created": "Created",
"last_updated": "Last Updated",
"messages": "Messages",
"user": "User"
},
"files": {
"actions": "Actions",
"all": "All Files",
"count": "Count",
"created_at": "Created At",
"document": "Document",
"file": "File",
"image": "Image",
"name": "Name",
"open": "Open",
"size": "Size",
"text": "Text",
"title": "Files"
},
"history": {
"continue_chat": "Continue Chatting",
"locate.message": "Locate the message",
"search.messages": "Search All Messages",
"search.placeholder": "Search topics or messages...",
"search.topics.empty": "No topics found, press Enter to search all messages",
"title": "Topics Search"
},
"languages": {
"arabic": "Arabic",
"chinese": "Chinese",
"chinese-traditional": "Traditional Chinese",
"english": "English",
"french": "French",
"italian": "Italian",
"japanese": "Japanese",
"korean": "Korean",
"portuguese": "Portuguese",
"russian": "Russian",
"spanish": "Spanish"
},
"mermaid": {
"download": {
"png": "Download PNG",
"svg": "Download SVG"
},
"tabs": {
"preview": "Preview",
"source": "Source"
},
"title": "Mermaid Diagram"
},
"message": {
"api.connection.failed": "Connection failed",
"api.connection.success": "Connection successful",
"assistant.added.content": "Assistant added successfully",
"backup.failed": "Backup failed",
"backup.success": "Backup successful",
"chat.completion.paused": "Chat completion paused",
"copied": "Copied!",
"error.enter.api.host": "Please enter your API host first",
"error.enter.api.key": "Please enter your API key first",
"error.enter.model": "Please select a model first",
"error.invalid.proxy.url": "Invalid proxy URL",
"error.invalid.webdav": "Invalid WebDAV settings",
"message.code_style": "Code style",
"message.delete.content": "Are you sure you want to delete this message?",
"message.delete.title": "Delete Message",
"message.style": "Message style",
"message.style.bubble": "Bubble",
"message.style.plain": "Plain",
"reset.confirm.content": "Are you sure you want to clear all data?",
"reset.double.confirm.content": "All data will be lost, do you want to continue?",
"reset.double.confirm.title": "DATA LOST !!!",
"restore.success": "Restored successfully",
"save.success.title": "Saved successfully",
"switch.disabled": "Switching is disabled while the assistant is generating",
"topic.added": "New topic added",
"upgrade.success.button": "Restart",
"upgrade.success.content": "Please restart the application to complete the upgrade",
"upgrade.success.title": "Upgrade successfully",
"regenerate.confirm": "Regenerating will replace current message"
},
"minapp": {
"title": "MinApp"
},
"history": {
"title": "Topics Search",
"search.placeholder": "Search topics or messages...",
"continue_chat": "Continue Chatting",
"search.topics.empty": "No topics found, press Enter to search all messages",
"search.messages": "Search All Messages",
"locate.message": "Locate the message"
"model": {
"pinned": "Pinned",
"search": "Search models...",
"stream_output": "Stream output",
"type": {
"select": "Select Model Types",
"text": "Text",
"vision": "Vision"
}
},
"ollama": {
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes.",
"keep_alive_time.placeholder": "Minutes",
"keep_alive_time.title": "Keep Alive Time",
"title": "Ollama"
},
"paintings": {
"button.delete.image": "Delete Image",
"button.delete.image.confirm": "Are you sure you want to delete this image?",
"button.new.image": "New Image",
"guidance_scale": "Guidance Scale",
"guidance_scale_tip": "Classifier Free Guidance. How close you want the model to stick to your prompt when looking for a related image to show you",
"image.size": "Image Size",
"inference_steps": "Inference Steps",
"inference_steps_tip": "The number of inference steps to perform. More steps produce higher quality but take longer",
"negative_prompt": "Negative Prompt",
"negative_prompt_tip": "Describe what you don't want included in the image",
"number_images": "Number Images",
"number_images_tip": "Number of images to generate (1-4)",
"prompt_placeholder": "Describe the image you want to create, e.g. A serene lake at sunset with mountains in the background",
"regenerate.confirm": "This will replace your existing generated images. Do you want to continue?",
"seed": "Seed",
"seed_tip": "The same seed and prompt can produce similar images",
"title": "Images"
},
"provider": {
"jina": "Jina",
"mistral": "Mistral",
"hyperbolic": "Hyperbolic",
"grok": "Grok",
"nvidia": "Nvidia",
"hunyuan": "Tencent Hunyuan",
"zhinao": "360AI",
"fireworks": "Fireworks",
"together": "Together",
"openai": "OpenAI",
"gemini": "Gemini",
"deepseek": "DeepSeek",
"moonshot": "Moonshot",
"silicon": "SiliconFlow",
"openrouter": "OpenRouter",
"yi": "Yi",
"zhipu": "ZHIPU AI",
"groq": "Groq",
"ollama": "Ollama",
"aihubmix": "AiHubMix",
"anthropic": "Anthropic",
"azure-openai": "Azure OpenAI",
"baichuan": "Baichuan",
"dashscope": "Alibaba Cloud",
"anthropic": "Anthropic",
"aihubmix": "AiHubMix",
"stepfun": "StepFun",
"deepseek": "DeepSeek",
"doubao": "Doubao",
"minimax": "MiniMax",
"graphrag-kylin-mountain": "GraphRAG",
"fireworks": "Fireworks",
"gemini": "Gemini",
"github": "GitHub Models",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent Hunyuan",
"hyperbolic": "Hyperbolic",
"jina": "Jina",
"minimax": "MiniMax",
"mistral": "Mistral",
"moonshot": "Moonshot",
"nvidia": "Nvidia",
"ocoolai": "ocoolAI",
"azure-openai": "Azure OpenAI"
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"silicon": "SiliconFlow",
"stepfun": "StepFun",
"together": "Together",
"yi": "Yi",
"zhinao": "360AI",
"zhipu": "ZHIPU AI"
},
"settings": {
"title": "Settings",
"general": "General Settings",
"data": "Data Settings",
"provider": "Model Provider",
"model": "Default Model",
"assistant": "Default Assistant",
"about": "About & Feedback",
"messages.model.title": "Model Settings",
"messages.title": "Message Settings",
"messages.divider": "Show divider between messages",
"messages.use_serif_font": "Use serif font",
"messages.input.title": "Input Settings",
"messages.input.show_estimated_tokens": "Show estimated input tokens",
"messages.input.send_shortcuts": "Send shortcuts",
"messages.input.paste_long_text_as_file": "Paste long text as file",
"messages.markdown_rendering_input_message": "Markdown render input msg",
"messages.math_engine": "Math render engine",
"about.checkUpdate": "Check Update",
"about.checkingUpdate": "Checking for updates...",
"about.contact.button": "Email",
"about.contact.title": "Contact",
"about.description": "A powerful AI assistant for producer",
"about.downloading": "Downloading...",
"about.feedback.button": "Feedback",
"about.feedback.title": "Feedback",
"about.license.button": "License",
"about.license.title": "License",
"about.releases.button": "Releases",
"about.releases.title": "Release Notes",
"about.title": "About",
"about.updateAvailable": "Found new version {{version}}",
"about.updateError": "Update error",
"about.updateNotAvailable": "You are using the latest version",
"about.website.button": "Website",
"about.website.title": "Official Website",
"advanced.auto_switch_to_topics": "Auto switch to topic",
"advanced.title": "Advanced Settings",
"assistant": "Default Assistant",
"assistant.model_params": "Model Parameters",
"assistant.title": "Default Assistant",
"data": {
"app_data": "App Data",
"app_logs": "App Logs",
"clear_cache": {
"button": "Clear Cache",
"confirm": "Clearing the cache will delete application cache data, including minapp data. This action is irreversible, continue?",
"error": "Error clearing cache",
"success": "Cache cleared",
"title": "Clear Cache"
},
"data.title": "Data Directory",
"title": "Data Settings",
"webdav.backup.button": "Backup to WebDAV",
"webdav.host": "WebDAV Host",
"webdav.host.placeholder": "http://localhost:8080",
"webdav.password": "WebDAV Password",
"webdav.path": "WebDAV Path",
"webdav.path.placeholder": "/backup",
"webdav.restore.button": "Restore from WebDAV",
"webdav.title": "WebDAV",
"webdav.user": "WebDAV User"
},
"display.title": "Display Settings",
"font_size.title": "Message font size",
"general": "General Settings",
"general.backup.button": "Backup",
"general.backup.title": "Data Backup and Recovery",
"general.manually_check_update.title": "Turn off update checking",
"general.reset.button": "Reset",
"general.reset.title": "Data Reset",
"general.restore.button": "Restore",
"general.title": "General Settings",
"general.user_name": "User Name",
"general.user_name.placeholder": "Enter your name",
"general.backup.title": "Data Backup and Recovery",
"general.backup.button": "Backup",
"general.restore.button": "Restore",
"general.view_webdav_settings": "View WebDAV settings",
"general.reset.title": "Data Reset",
"general.reset.button": "Reset",
"general.manually_check_update.title": "Turn off update checking",
"data.webdav.title": "WebDAV",
"data.webdav.host": "WebDAV Host",
"data.webdav.host.placeholder": "http://localhost:8080",
"data.webdav.user": "WebDAV User",
"data.webdav.password": "WebDAV Password",
"data.webdav.path": "WebDAV Path",
"data.webdav.path.placeholder": "/backup",
"data.webdav.backup.button": "Backup to WebDAV",
"data.webdav.restore.button": "Restore from WebDAV",
"advanced.title": "Advanced Settings",
"advanced.click_assistant_switch_to_topics": "Auto switch to topic",
"provider.api_key": "API Key",
"provider.check": "Check",
"provider.get_api_key": "Get API Key",
"provider.api_host": "API Host",
"provider.api_version": "API Version",
"provider.docs_check": "Check",
"provider.docs_more_details": "for more details",
"provider.search_placeholder": "Search model id or name",
"provider.api.url.reset": "Reset",
"provider.api.url.preview": "Preview: {{url}}",
"provider.api.url.tip": "Ending with / ignores v1, ending with # forces use of input address",
"models.default_assistant_model": "Default Assistant Model",
"models.topic_naming_model": "Topic Naming Model",
"models.translate_model": "Translate Model",
"input.auto_translate_with_space": "Quickly translate with 3 spaces",
"messages.divider": "Show divider between messages",
"messages.input.paste_long_text_as_file": "Paste long text as file",
"messages.input.send_shortcuts": "Send shortcuts",
"messages.input.show_estimated_tokens": "Show estimated tokens",
"messages.input.title": "Input Settings",
"messages.markdown_rendering_input_message": "Markdown render input msg",
"messages.math_engine": "Math render engine",
"messages.model.title": "Model Settings",
"messages.title": "Message Settings",
"messages.use_serif_font": "Use serif font",
"model": "Default Model",
"models.add.add_model": "Add Model",
"models.add.model_id.placeholder": "Required e.g. gpt-3.5-turbo",
"models.add.group_name": "Group Name",
"models.add.group_name.placeholder": "Optional e.g. ChatGPT",
"models.add.group_name.tooltip": "Optional e.g. ChatGPT",
"models.add.model_id": "Model ID",
"models.add.model_id.placeholder": "Required e.g. gpt-3.5-turbo",
"models.add.model_id.tooltip": "Example: gpt-3.5-turbo",
"models.add.model_name": "Model Name",
"models.add.model_name.placeholder": "Optional e.g. GPT-4",
"models.add.group_name": "Group Name",
"models.add.group_name.tooltip": "Optional e.g. ChatGPT",
"models.add.group_name.placeholder": "Optional e.g. ChatGPT",
"models.default_assistant_model": "Default Assistant Model",
"models.default_assistant_model_description": "Model used when creating a new assistant, if the assistant is not set, this model will be used",
"models.empty": "No models found",
"assistant.title": "Default Assistant",
"assistant.model_params": "Model Parameters",
"about.description": "A powerful AI assistant for producer",
"about.updateNotAvailable": "You are using the latest version",
"about.checkingUpdate": "Checking for updates...",
"about.updateError": "Update error",
"about.checkUpdate": "Check Update",
"about.downloading": "Downloading...",
"provider.delete.title": "Delete Provider",
"provider.delete.content": "Are you sure you want to delete this provider?",
"provider.edit.name": "Provider Name",
"provider.edit.name.placeholder": "Example: OpenAI",
"about.title": "About",
"about.releases.title": "Release Notes",
"about.releases.button": "Releases",
"about.website.title": "Official Website",
"about.website.button": "Website",
"about.feedback.title": "Feedback",
"about.feedback.button": "Feedback",
"about.contact.title": "Contact",
"about.license.title": "License",
"about.license.button": "License",
"about.contact.button": "Email",
"models.topic_naming_model": "Topic Naming Model",
"models.topic_naming_model_description": "Model used when automatically naming a new topic",
"models.translate_model": "Translate Model",
"models.translate_model_description": "Model used for translation service",
"models.translate_model_prompt_message": "Please enter the translate model prompt",
"models.translate_model_prompt_title": "Translate Model Prompt",
"models.topic_naming_model_setting_title": "Topic Naming Model Settings",
"models.enable_topic_naming": "Topic Auto Naming",
"provider": {
"add.name": "Provider Name",
"add.name.placeholder": "Example: OpenAI",
"add.title": "Add Provider",
"add.type": "Provider Type",
"api.url.preview": "Preview: {{url}}",
"api.url.reset": "Reset",
"api.url.tip": "Ending with / ignores v1, ending with # forces use of input address",
"api_host": "API Host",
"api_key": "API Key",
"api_key.tip": "Multiple keys separated by commas",
"api_version": "API Version",
"check": "Check",
"check_all_keys": "Check All Keys",
"check_multiple_keys": "Check Multiple API Keys",
"delete.content": "Are you sure you want to delete this provider?",
"delete.title": "Delete Provider",
"docs_check": "Check",
"docs_more_details": "for more details",
"get_api_key": "Get API Key",
"no_models": "Please add models first before checking the API connection",
"not_checked": "Not Checked",
"remove_duplicate_keys": "Remove Duplicate Keys",
"remove_invalid_keys": "Remove Invalid Keys",
"search_placeholder": "Search model id or name",
"title": "Model Provider"
},
"provider.api.url.preview": "Preview: {{url}}",
"provider.api.url.reset": "Reset",
"provider.api.url.tip": "Ending with / ignores v1, ending with # forces use of input address",
"provider.api_host": "API Host",
"provider.api_key": "API Key",
"provider.api_key.tip": "Multiple keys separated by commas",
"provider.api_version": "API Version",
"provider.check": "Check",
"provider.docs_check": "Check",
"provider.docs_more_details": "for more details",
"provider.get_api_key": "Get API Key",
"provider.search_placeholder": "Search model id or name",
"proxy": {
"mode": {
"custom": "Custom Proxy",
"none": "No Proxy",
"system": "System Proxy",
"title": "Proxy Mode"
},
"title": "Proxy Settings"
},
"proxy.title": "Proxy Address",
"theme.title": "Theme",
"theme.dark": "Dark",
"theme.light": "Light",
"theme.auto": "Auto",
"theme.window.style.title": "Window Style",
"theme.window.style.transparent": "Transparent Window",
"theme.window.style.opaque": "Opaque Window",
"font_size.title": "Message Font Size",
"topic.position": "Topic Position",
"topic.position.left": "Left",
"topic.position.right": "Right",
"topic.show.time": "Show Topic Time",
"shortcuts": {
"title": "Keyboard Shortcuts",
"action": "Action",
"key": "Key",
"new_topic": "New Topic",
"title": "Keyboard Shortcuts",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out",
"zoom_reset": "Reset Zoom"
}
"zoom_reset": "Reset Zoom",
"show_app": "Show App",
"reset_defaults": "Reset Defaults",
"reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
"press_shortcut": "Press Shortcut",
"alt_warning": "Mac does not support Option + letters as shortcuts",
"reset_to_default": "Reset to Default",
"clear_shortcut": "Clear Shortcut"
},
"theme.auto": "Auto",
"theme.dark": "Dark",
"theme.light": "Light",
"theme.title": "Theme",
"theme.window.style.opaque": "Opaque Window",
"theme.window.style.title": "Window Style",
"theme.window.style.transparent": "Transparent Window",
"title": "Settings",
"topic.position": "Topic position",
"topic.position.left": "Left",
"topic.position.right": "Right",
"topic.show.time": "Show topic time",
"tray.title": "Enable System Tray Icon"
},
"translate": {
"title": "Translation",
"any.language": "Any language",
"button.translate": "Translate",
"confirm": {
"content": "Translation will replace the original text, continue?",
"title": "Translation Confirmation"
},
"error.not_configured": "Translation model is not configured",
"error.failed": "Translation failed",
"input.placeholder": "Enter text to translate",
"output.placeholder": "Translation",
"confirm": "Original text has been copied to clipboard. Do you want to replace it with the translated text?"
"processing": "Translation in progress...",
"title": "Translation",
"close": "Close"
},
"languages": {
"english": "English",
"chinese": "Chinese",
"chinese-traditional": "Traditional Chinese",
"japanese": "Japanese",
"korean": "Korean",
"russian": "Russian",
"spanish": "Spanish",
"french": "French",
"italian": "Italian",
"portuguese": "Portuguese",
"arabic": "Arabic"
},
"ollama": {
"title": "Ollama",
"keep_alive_time.title": "Keep Alive Time",
"keep_alive_time.placeholder": "Minutes",
"keep_alive_time.description": "The time in minutes to keep the connection alive, default is 5 minutes."
},
"error": {
"chat.response": "Something went wrong. Please check if you have set your API key in the Settings > Providers",
"backup.file_format": "Backup file format error",
"provider_disabled": "Model provider is not enabled",
"no_api_key": "API key is not configured"
"tray": {
"quit": "Quit",
"show_window": "Show Window"
},
"words": {
"knowledgeGraph": "Knowledge Graph",
"visualization": "Visualization"
},
"export": {
"attached_files": "Attached Files",
"user": "User",
"assistant": "Assistant",
"created": "Created",
"last_updated": "Last Updated",
"messages": "Messages",
"conversation_details": "Conversation Details",
"conversation_history": "Conversation History"
}
}
}

View File

@ -0,0 +1,511 @@
{
"translation": {
"agents": {
"add.button": "Добавить к ассистенту",
"add.name": "Имя",
"add.name.placeholder": "Введите имя",
"add.prompt": "Промпт",
"add.prompt.placeholder": "Введите промпт",
"add.title": "Создать агента",
"delete.popup.content": "Вы уверены, что хотите удалить этого агента?",
"edit.message.add.title": "Добавить",
"edit.message.assistant.placeholder": "Введите сообщение ассистента",
"edit.message.assistant.title": "Ассистент",
"edit.message.empty.content": "Содержание вводимого сообщения не может быть пустым",
"edit.message.group.title": "Группа сообщений",
"edit.message.title": "Предустановленные сообщения",
"edit.message.user.placeholder": "Введите сообщение пользователя",
"edit.message.user.title": "Пользователь",
"edit.model.select.title": "Выбрать модель",
"edit.settings.hide_preset_messages": "Скрыть предустановленные сообщения",
"edit.title": "Редактировать агента",
"manage.title": "Редактировать агентов",
"my_agents": "Мои агенты",
"search.no_results": "Результаты не найдены",
"sorting.title": "Сортировка",
"tag.agent": "Агент",
"tag.default": "По умолчанию",
"tag.new": "Новый",
"tag.system": "Система",
"title": "Агенты"
},
"assistants": {
"abbr": "Ассистент",
"clear.content": "Очистка топика удалит все топики и файлы в ассистенте. Вы уверены, что хотите продолжить?",
"clear.title": "Очистить топики",
"copy.title": "Копировать ассистента",
"delete.content": "Удаление ассистента удалит все топики и файлы под ассистентом. Вы уверены, что хотите удалить его?",
"delete.title": "Удалить ассистента",
"edit.title": "Редактировать ассистента",
"save.success": "Успешно сохранено",
"save.title": "Сохранить в агента",
"search": "Поиск ассистентов...",
"settings.auto_reset_model": "Автосброс модели",
"settings.auto_reset_model.tip": "Автоматически сбрасывать модель при создании нового топика.",
"settings.default_model": "Модель по умолчанию",
"settings.model": "Настройки модели",
"settings.preset_messages": "Предустановленные сообщения",
"settings.prompt": "Настройки промптов",
"title": "Ассистенты"
},
"button": {
"add": "Добавить",
"added": "Добавлено",
"collapse": "Свернуть",
"manage": "Редактировать",
"select_model": "Выбрать модель",
"show.all": "Показать все"
},
"chat": {
"add.assistant.title": "Добавить ассистента",
"artifacts.button.download": "Скачать",
"artifacts.button.preview": "Предпросмотр",
"assistant.search.placeholder": "Поиск",
"default.description": "Привет, я Ассистент по умолчанию. Вы можете начать общаться со мной прямо сейчас",
"default.name": "⭐️ Ассистент по умолчанию",
"default.topic.name": "Топик по умолчанию",
"input.clear": "Очистить",
"input.clear.content": "Хотите очистить все сообщения текущего топика?",
"input.clear.title": "Очистить все сообщения?",
"input.collapse": "Свернуть",
"input.context_count.tip": "Количество контекстов",
"input.estimated_tokens.tip": "Затраты токенов",
"input.expand": "Развернуть",
"input.new.context": "Очистить контекст",
"input.new_topic": "Новый топик {{Command}}+N",
"input.pause": "Остановить",
"input.placeholder": "Введите ваше сообщение здесь...",
"input.send": "Отправить",
"input.settings": "Настройки",
"input.topics": " Топики ",
"input.translate": "Перевести на английский",
"input.upload": "Загрузить изображение или документ",
"message.new.branch": "Новая ветка",
"message.new.branch.created": "Новая ветка создана",
"message.new.context": "Новый контекст",
"save": "Сохранить",
"settings.code_collapsible": "Блок кода свернут",
"settings.context_count": "Контекст",
"settings.context_count.tip": "Количество предыдущих сообщений, которые нужно сохранить в контексте.",
"settings.max": "Максимум",
"settings.max_tokens": "Включить лимит максимальных токенов",
"settings.max_tokens.tip": "Максимальное количество токенов, которые может сгенерировать модель. Обычный чат предполагает 500-800. Генерация короткого текста предполагает 800-2000. Генерация кода предполагает 2000-3600. Генерация длинного текста предполагает выше 4000.",
"settings.reset": "Сбросить",
"settings.set_as_default": "Применить к ассистенту по умолчанию",
"settings.show_line_numbers": "Показать номера строк в коде",
"settings.temperature": "Температура",
"settings.temperature.tip": "Меньшие значения делают модель более креативной и непредсказуемой, в то время как большие значения делают её более детерминированной и точной.",
"suggestions.title": "Предложенные вопросы",
"topics.auto_rename": "Автопереименование",
"topics.clear.title": "Очистить сообщения",
"topics.edit.placeholder": "Введите новый заголовок",
"topics.edit.title": "Редактировать заголовок",
"topics.export.image": "Экспорт как изображение",
"topics.export.md": "Экспорт как markdown",
"topics.export.title": "Экспорт",
"topics.export.word": "Экспорт как Word",
"topics.list": "Список топиков",
"topics.move_to": "Переместить в",
"topics.title": "Топики",
"translate": "Перевести"
},
"common": {
"and": "и",
"assistant": "Ассистент",
"avatar": "Аватар",
"back": "Назад",
"cancel": "Отмена",
"chat": "Чат",
"close": "Закрыть",
"copy": "Копировать",
"cut": "Вырезать",
"default": "По умолчанию",
"delete": "Удалить",
"description": "Описание",
"docs": "Документы",
"download": "Скачать",
"duplicate": "Дублировать",
"edit": "Редактировать",
"footnotes": "Сноски",
"language": "Язык",
"model": "Модель",
"models": "Модели",
"name": "Имя",
"paste": "Вставить",
"prompt": "Промпт",
"provider": "Провайдер",
"regenerate": "Пересоздать",
"rename": "Переименовать",
"reset": "Сбросить",
"save": "Сохранить",
"search": "Поиск",
"select": "Выбрать",
"topics": "Топики",
"warning": "Предупреждение",
"you": "Вы",
"clear": "Очистить",
"add": "Добавить"
},
"error": {
"backup.file_format": "Ошибка формата файла резервной копии",
"chat.response": "Что-то пошло не так. Пожалуйста, проверьте, установлен ли ваш ключ API в Настройки > Провайдеры",
"no_api_key": "Ключ API не настроен",
"provider_disabled": "Провайдер моделей не включен",
"render": {
"title": "Ошибка рендеринга",
"description": "Не удалось рендерить формулу. Пожалуйста, проверьте, правильно ли формат формулы"
}
},
"export": {
"assistant": "Ассистент",
"attached_files": "Прикрепленные файлы",
"conversation_details": "Детали разговора",
"conversation_history": "История разговора",
"created": "Создано",
"last_updated": "Последнее обновление",
"messages": "Сообщения",
"user": "Пользователь"
},
"files": {
"actions": "Действия",
"all": "Все файлы",
"count": "Количество",
"created_at": "Дата создания",
"document": "Документ",
"file": "Файл",
"image": "Изображение",
"name": "Имя",
"open": "Открыть",
"size": "Размер",
"text": "Текст",
"title": "Файлы"
},
"history": {
"continue_chat": "Продолжить чат",
"locate.message": "Найти сообщение",
"search.messages": "Поиск всех сообщений",
"search.placeholder": "Поиск топиков или сообщений...",
"search.topics.empty": "Топики не найдены, нажмите Enter для поиска всех сообщений",
"title": "Поиск топиков"
},
"languages": {
"arabic": "Арабский",
"chinese": "Китайский",
"chinese-traditional": "Китайский традиционный",
"english": "Английский",
"french": "Французский",
"italian": "Итальянский",
"japanese": "Японский",
"korean": "Корейский",
"portuguese": "Португальский",
"russian": "Русский",
"spanish": "Испанский"
},
"mermaid": {
"download": {
"png": "Скачать PNG",
"svg": "Скачать SVG"
},
"tabs": {
"preview": "Предпросмотр",
"source": "Исходный код"
},
"title": "Диаграмма Mermaid"
},
"message": {
"api.connection.failed": "Соединение не удалось",
"api.connection.success": "Соединение успешно",
"assistant.added.content": "Ассистент успешно добавлен",
"backup.failed": "Создание резервной копии не удалось",
"backup.success": "Резервная копия успешно создана",
"chat.completion.paused": "Завершение чата приостановлено",
"copied": "Скопировано!",
"error.enter.api.host": "Пожалуйста, введите ваш API хост",
"error.enter.api.key": "Пожалуйста, введите ваш API ключ",
"error.enter.model": "Пожалуйста, выберите модель",
"error.invalid.proxy.url": "Неверный URL прокси",
"error.invalid.webdav": "Неверные настройки WebDAV",
"message.code_style": "Стиль кода",
"message.delete.content": "Вы уверены, что хотите удалить это сообщение?",
"message.delete.title": "Удалить сообщение",
"message.style": "Стиль сообщения",
"message.style.bubble": "Пузырь",
"message.style.plain": "Простой",
"reset.confirm.content": "Вы уверены, что хотите очистить все данные?",
"reset.double.confirm.content": "Все данные будут утеряны, хотите продолжить?",
"reset.double.confirm.title": "ДАННЫЕ БУДУТ УТЕРЯНЫ !!!",
"restore.success": "Успешно восстановлено",
"save.success.title": "Успешно сохранено",
"switch.disabled": "Переключение отключено, пока ассистент генерирует",
"topic.added": "Новый топик добавлен",
"upgrade.success.button": "Перезапустить",
"upgrade.success.content": "Пожалуйста, перезапустите приложение для завершения обновления",
"upgrade.success.title": "Обновление успешно",
"regenerate.confirm": "Перегенерация заменит текущее сообщение"
},
"minapp": {
"title": "Встроенные приложения"
},
"model": {
"pinned": "Закреплено",
"search": "Поиск моделей...",
"stream_output": "Потоковый вывод",
"type": {
"select": "Выберите тип модели",
"text": "Текст",
"vision": "Изображение"
}
},
"ollama": {
"keep_alive_time.description": "Время в минутах, в течение которого модель остается активной, по умолчанию 5 минут.",
"keep_alive_time.placeholder": "Минуты",
"keep_alive_time.title": "Время жизни модели",
"title": "Ollama"
},
"paintings": {
"button.delete.image": "Удалить изображение",
"button.delete.image.confirm": "Вы уверены, что хотите удалить это изображение?",
"button.new.image": "Новое изображение",
"guidance_scale": "Масштаб руководства",
"guidance_scale_tip": "Без классификатора руководства. Насколько близко вы хотите, чтобы модель придерживалась вашего промпта при поиске связанного изображения для показа вам",
"image.size": "Размер изображения",
"inference_steps": "Шаги вывода",
"inference_steps_tip": "Количество шагов вывода для выполнения. Больше шагов производят более высокое качество, но занимают больше времени",
"negative_prompt": "Негативный промпт",
"negative_prompt_tip": "Опишите, что вы не хотите включать в изображение",
"number_images": "Количество изображений",
"number_images_tip": "Количество изображений для генерации (1-4)",
"prompt_placeholder": "Опишите изображение, которое вы хотите создать, например, Спокойное озеро на закате с горами на заднем плане",
"regenerate.confirm": "Это заменит ваши существующие сгенерированные изображения. Хотите продолжить?",
"seed": "Ключ генерации",
"seed_tip": "Одинаковый ключ генерации и промпт могут производить похожие изображения",
"title": "Изображения"
},
"provider": {
"aihubmix": "AiHubMix",
"anthropic": "Anthropic",
"azure-openai": "Azure OpenAI",
"baichuan": "Baichuan",
"dashscope": "Alibaba Cloud",
"deepseek": "DeepSeek",
"doubao": "Doubao",
"fireworks": "Fireworks",
"gemini": "Gemini",
"github": "GitHub Models",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "Tencent Hunyuan",
"hyperbolic": "Hyperbolic",
"jina": "Jina",
"minimax": "MiniMax",
"mistral": "Mistral",
"moonshot": "Moonshot",
"nvidia": "Nvidia",
"ocoolai": "ocoolAI",
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"silicon": "SiliconFlow",
"stepfun": "StepFun",
"together": "Together",
"yi": "Yi",
"zhinao": "360AI",
"zhipu": "ZHIPU AI"
},
"settings": {
"about": "О программе и обратная связь",
"about.checkUpdate": "Проверить обновления",
"about.checkingUpdate": "Проверка обновлений...",
"about.contact.button": "Электронная почта",
"about.contact.title": "Контакты",
"about.description": "Мощный AI-ассистент для созидания",
"about.downloading": "Загрузка...",
"about.feedback.button": "Обратная связь",
"about.feedback.title": "Обратная связь",
"about.license.button": "Лицензия",
"about.license.title": "Лицензия",
"about.releases.button": "Релизы",
"about.releases.title": "Заметки о релизах",
"about.title": "О программе",
"about.updateAvailable": "Найдено новое обновление {{version}}",
"about.updateError": "Ошибка обновления",
"about.updateNotAvailable": "Вы используете последнюю версию",
"about.website.button": "Сайт",
"about.website.title": "Официальный сайт",
"advanced.auto_switch_to_topics": "Автоматически переключаться на топик",
"advanced.title": "Расширенные настройки",
"assistant": "Ассистент по умолчанию",
"assistant.model_params": "Параметры модели",
"assistant.title": "Ассистент по умолчанию",
"data": {
"app_data": "Данные приложения",
"app_logs": "Логи приложения",
"clear_cache": {
"button": "Очистка кэша",
"confirm": "Очистка кэша удалит данные приложения. Это действие необратимо, продолжить?",
"error": "Ошибка при очистке кэша",
"success": "Кэш очищен",
"title": "Очистка кэша"
},
"data.title": "Каталог данных",
"title": "Настройки данных",
"webdav.backup.button": "Резервное копирование на WebDAV",
"webdav.host": "Хост WebDAV",
"webdav.host.placeholder": "http://localhost:8080",
"webdav.password": "Пароль WebDAV",
"webdav.path": "Путь WebDAV",
"webdav.path.placeholder": "/backup",
"webdav.restore.button": "Восстановление с WebDAV",
"webdav.title": "WebDAV",
"webdav.user": "Пользователь WebDAV"
},
"display.title": "Настройки отображения",
"font_size.title": "Размер шрифта сообщений",
"general": "Общие настройки",
"general.backup.button": "Резервное копирование",
"general.backup.title": "Резервное копирование и восстановление данных",
"general.manually_check_update.title": "Отключить проверку обновлений",
"general.reset.button": "Сброс",
"general.reset.title": "Сброс данных",
"general.restore.button": "Восстановление",
"general.title": "Общие настройки",
"general.user_name": "Имя пользователя",
"general.user_name.placeholder": "Введите ваше имя",
"general.view_webdav_settings": "Просмотр настроек WebDAV",
"input.auto_translate_with_space": "Быстрый перевод с помощью 3-х пробелов",
"messages.divider": "Показывать разделитель между сообщениями",
"messages.input.paste_long_text_as_file": "Вставлять длинный текст как файл",
"messages.input.send_shortcuts": "Горячие клавиши для отправки",
"messages.input.show_estimated_tokens": "Показывать затраты токенов",
"messages.input.title": "Настройки ввода",
"messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown",
"messages.math_engine": "Математический движок",
"messages.model.title": "Настройки модели",
"messages.title": "Настройки сообщений",
"messages.use_serif_font": "Использовать serif шрифт",
"model": "Модель по умолчанию",
"models.add.add_model": "Добавить модель",
"models.add.group_name": "Имя группы",
"models.add.group_name.placeholder": "Необязательно, например, ChatGPT",
"models.add.group_name.tooltip": "Необязательно, например, ChatGPT",
"models.add.model_id": "ID модели",
"models.add.model_id.placeholder": "Обязательно, например, gpt-3.5-turbo",
"models.add.model_id.tooltip": "Пример: gpt-3.5-turbo",
"models.add.model_name": "Имя модели",
"models.add.model_name.placeholder": "Необязательно, например, GPT-4",
"models.default_assistant_model": "Модель ассистента по умолчанию",
"models.default_assistant_model_description": "Модель, используемая при создании нового ассистента, если ассистент не имеет настроенной модели, будет использоваться эта модель",
"models.empty": "Модели не найдены",
"models.topic_naming_model": "Модель именования топика",
"models.topic_naming_model_description": "Модель, используемая при автоматическом именовании нового топика",
"models.translate_model": "Модель перевода",
"models.translate_model_description": "Модель, используемая для сервиса перевода",
"models.translate_model_prompt_message": "Введите модель перевода",
"models.translate_model_prompt_title": "Модель перевода",
"models.topic_naming_model_setting_title": "Настройки модели именования топика",
"models.enable_topic_naming": "Автоматическое переименование топика",
"provider": {
"add.name": "Имя провайдера",
"add.name.placeholder": "Пример: OpenAI",
"add.title": "Добавить провайдер",
"add.type": "Тип провайдера",
"api.url.preview": "Предпросмотр: {{url}}",
"api.url.reset": "Сброс",
"api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес",
"api_host": "Хост API",
"api_key": "Ключ API",
"api_key.tip": "Несколько ключей, разделенных запятыми",
"api_version": "Версия API",
"check": "Проверить",
"check_all_keys": "Проверить все ключи",
"check_multiple_keys": "Проверить несколько ключей API",
"delete.content": "Вы уверены, что хотите удалить этот провайдер?",
"delete.title": "Удалить провайдер",
"docs_check": "Проверить",
"docs_more_details": "для получения дополнительной информации",
"get_api_key": "Получить ключ API",
"no_models": "Пожалуйста, добавьте модели перед проверкой соединения с API",
"not_checked": "Не проверено",
"remove_duplicate_keys": "Удалить дубликаты ключей",
"remove_invalid_keys": "Удалить недействительные ключи",
"search_placeholder": "Поиск по ID или имени модели",
"title": "Провайдеры моделей"
},
"provider.api.url.preview": "Предпросмотр: {{url}}",
"provider.api.url.reset": "Сброс",
"provider.api.url.tip": "Заканчивая на / игнорирует v1, заканчивая на # принудительно использует введенный адрес",
"provider.api_host": "Хост API",
"provider.api_key": "Ключ API",
"provider.api_key.tip": "Несколько ключей, разделенных запятыми",
"provider.api_version": "Версия API",
"provider.check": "Проверить",
"provider.docs_check": "Проверить",
"provider.docs_more_details": "для получения дополнительной информации",
"provider.get_api_key": "Получить ключ API",
"provider.search_placeholder": "Поиск по ID или имени модели",
"proxy": {
"mode": {
"custom": "Пользовательский прокси",
"none": "Не использовать прокси",
"system": "Системный прокси",
"title": "Режим прокси"
},
"title": "Настройки прокси"
},
"proxy.title": "Адрес прокси",
"shortcuts": {
"action": "Действие",
"key": "Клавиша",
"new_topic": "Новый топик",
"title": "Горячие клавиши",
"zoom_in": "Увеличить",
"zoom_out": "Уменьшить",
"zoom_reset": "Сбросить масштаб",
"show_app": "Показать приложение",
"reset_defaults": "Сбросить настройки по умолчанию",
"reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
"press_shortcut": "Нажмите сочетание клавиш",
"alt_warning": "Mac не поддерживает Option + буквы как горячие клавиши",
"reset_to_default": "Сбросить настройки по умолчанию",
"clear_shortcut": "Очистить сочетание клавиш"
},
"theme.auto": "Автоматически",
"theme.dark": "Темная",
"theme.light": "Светлая",
"theme.title": "Тема",
"theme.window.style.opaque": "Непрозрачное окно",
"theme.window.style.title": "Стиль окна",
"theme.window.style.transparent": "Прозрачное окно",
"title": "Настройки",
"topic.position": "Позиция топиков",
"topic.position.left": "Слева",
"topic.position.right": "Справа",
"topic.show.time": "Показывать время топика",
"tray.title": "Включить значок системного трея"
},
"translate": {
"any.language": "Любой язык",
"button.translate": "Перевести",
"confirm": {
"content": "Перевод заменит исходный текст, продолжить?",
"title": "Перевод подтверждение"
},
"error.not_configured": "Модель перевода не настроена",
"error.failed": "Перевод не удалось",
"input.placeholder": "Введите текст для перевода",
"output.placeholder": "Перевод",
"processing": "Перевод в процессе...",
"title": "Перевод",
"close": "Закрыть"
},
"tray": {
"quit": "Выйти",
"show_window": "Показать окно"
},
"words": {
"knowledgeGraph": "Граф знаний",
"visualization": "Визуализация"
}
}
}

View File

@ -1,407 +1,499 @@
{
"translation": {
"common": {
"avatar": "头像",
"language": "语言",
"model": "模型",
"models": "模型",
"topics": "话题",
"docs": "文档",
"and": "和",
"assistant": "智能体",
"name": "名称",
"description": "描述",
"prompt": "提示词",
"rename": "重命名",
"delete": "删除",
"edit": "编辑",
"duplicate": "复制",
"copy": "复制",
"regenerate": "重新生成",
"provider": "提供商",
"you": "用户",
"save": "保存",
"footnote": "引用内容",
"select": "选择",
"search": "搜索",
"default": "默认",
"warning": "警告",
"back": "返回",
"chat": "聊天",
"close": "关闭",
"cancel": "取消",
"download": "下载"
},
"button": {
"add": "添加",
"added": "已添加",
"manage": "管理",
"select_model": "选择模型",
"show.all": "显示全部",
"collapse": "收起"
},
"message": {
"copied": "已复制",
"assistant.added.content": "智能体添加成功",
"message.delete.title": "删除消息",
"message.delete.content": "确定要删除此消息吗?",
"error.enter.api.key": "请输入您的 API 密钥",
"error.enter.api.host": "请输入您的 API 地址",
"error.enter.model": "请选择一个模型",
"error.invalid.proxy.url": "无效的代理地址",
"error.invalid.webdav": "无效的 WebDAV 设置",
"api.connection.failed": "连接失败",
"api.connection.success": "连接成功",
"chat.completion.paused": "会话已停止",
"switch.disabled": "模型回复完成后才能切换",
"restore.success": "恢复成功",
"backup.success": "备份成功",
"backup.failed": "备份失败",
"reset.confirm.content": "确定要重置所有数据吗?",
"reset.double.confirm.title": "数据丢失!!!",
"reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?",
"upgrade.success.title": "升级成功",
"upgrade.success.content": "重启用以完成升级",
"upgrade.success.button": "重启",
"topic.added": "话题添加成功",
"save.success.title": "保存成功",
"message.style": "消息样式",
"message.style.bubble": "气泡",
"message.style.plain": "简洁"
},
"chat": {
"save": "保存",
"default.name": "⭐️ 默认助手",
"default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。",
"default.topic.name": "默认话题",
"topics.title": "话题",
"topics.auto_rename": "生成话题名",
"topics.edit.title": "编辑话题名",
"topics.edit.placeholder": "输入新名称",
"topics.clear.title": "清空消息",
"topics.move_to": "移动到",
"topics.list": "话题列表",
"topics.export.title": "导出",
"topics.export.image": "导出为图片",
"topics.export.md": "导出为 Markdown",
"topics.export.word": "导出为 Word",
"input.new_topic": "新话题",
"input.topics": " 话题 ",
"input.clear": "清空消息",
"input.new.context": "清除上下文",
"input.expand": "展开",
"input.collapse": "收起",
"input.clear.title": "清空消息",
"input.clear.content": "确定要清除当前会话所有消息吗?",
"input.placeholder": "在这里输入消息...",
"input.send": "发送",
"input.pause": "暂停",
"input.settings": "设置",
"input.upload": "上传图片或文档",
"input.context_count.tip": "上下文数",
"input.estimated_tokens.tip": "预估 token 数",
"settings.temperature": "模型温度",
"settings.temperature.tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7",
"settings.conext_count": "上下文数",
"settings.conext_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10",
"settings.max_tokens": "开启消息长度限制",
"settings.max_tokens.tip": "单次交互所用的最大 Token 数, 会影响返回结果的长度。普通聊天建议 500-800短文生成建议 800-2000代码生成建议 2000-3600长文生成建议切换模型到 4000 左右",
"settings.reset": "重置",
"settings.set_as_default": "应用到默认助手",
"settings.max": "不限",
"settings.show_line_numbers": "代码显示行号",
"suggestions.title": "建议的问题",
"add.assistant.title": "添加助手",
"message.new.context": "清除上下文",
"message.new.branch": "新分支",
"message.new.branch.created": "新分支已创建",
"assistant.search.placeholder": "搜索",
"artifacts.button.preview": "预览",
"artifacts.button.download": "下载"
},
"assistants": {
"title": "助手",
"abbr": "助手",
"search": "搜索助手",
"settings.prompt": "提示词设置",
"settings.model": "模型设置",
"settings.preset_messages": "预设消息",
"settings.default_model": "默认模型",
"settings.auto_reset_model": "自动重置模型",
"settings.auto_reset_model.tip": "创建新话题时自动重置模型",
"edit.title": "编辑助手",
"copy.title": "复制助手",
"clear.title": "清空话题",
"clear.content": "清空话题会删除助手下所有话题和文件,确定要继续吗?",
"save.title": "保存到智能体",
"save.success": "保存成功",
"delete.title": "删除助手",
"delete.content": "删除助手会删除所有该助手下的话题和文件,确定要继续吗?"
},
"model": {
"stream_output": "流式输出",
"search": "搜索模型..."
},
"images": {
"title": "图片",
"image.size": "图片尺寸",
"button.new.image": "新建图片",
"button.delete.image": "删除图片",
"button.delete.image.confirm": "确定要删除此图片吗?",
"number_images": "生成数量",
"number_images_tip": "一次生成的图片数量 (1-4)",
"seed": "随机种子",
"seed_tip": "相同的种子和提示词可以生成相似的图片",
"inference_steps": "推理步数",
"inference_steps_tip": "要执行的推理步数。步数越多,质量越高但耗时越长",
"guidance_scale": "引导比例",
"guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度",
"negative_prompt": "反向提示词",
"negative_prompt_tip": "描述你不想在图片中出现的内容",
"prompt_placeholder": "描述你想创建的图片,例如:一个宁静的湖泊,夕阳西下,远处是群山",
"regenerate.confirm": "这将覆盖已生成的图片,是否继续?"
},
"files": {
"title": "文件",
"file": "文件",
"name": "文件名",
"size": "大小",
"count": "文件数",
"created_at": "创建时间",
"image": "图片",
"text": "文本",
"document": "文档",
"actions": "操作",
"open": "打开",
"all": "所有文件"
},
"agents": {
"title": "智能体",
"my_agents": "我的智能体",
"add.title": "创建智能体",
"edit.title": "编辑智能体",
"add.button": "添加到助手",
"add.name": "名称",
"add.name.placeholder": "输入名称",
"add.prompt": "提示词",
"add.prompt.placeholder": "输入提示词",
"add.button": "添加到助手",
"manage.title": "管理智能体",
"add.title": "创建智能体",
"delete.popup.content": "确定要删除此智能体吗?",
"tag.default": "默认",
"tag.system": "系统",
"tag.agent": "智能体",
"edit.message.title": "预设消息",
"edit.message.add.title": "添加",
"edit.message.group.title": "消息组",
"edit.message.assistant.title": "助手",
"edit.message.assistant.placeholder": "输入助手消息",
"edit.message.user.title": "用户",
"edit.message.user.placeholder": "输入用户消息",
"edit.message.assistant.title": "助手",
"edit.message.empty.content": "会话输入内容不能为空",
"edit.message.group.title": "消息组",
"edit.message.title": "预设消息",
"edit.message.user.placeholder": "输入用户消息",
"edit.message.user.title": "用户",
"edit.model.select.title": "选择模型",
"edit.settings.hide_preset_messages": "隐藏预设消息",
"edit.title": "编辑智能体",
"manage.title": "管理智能体",
"my_agents": "我的智能体",
"search.no_results": "没有找到相关智能体",
"sorting.title": "排序"
"sorting.title": "排序",
"tag.agent": "智能体",
"tag.default": "默认",
"tag.new": "新建",
"tag.system": "系统",
"title": "智能体"
},
"assistants": {
"abbr": "助手",
"clear.content": "清空话题会删除助手下所有话题和文件,确定要继续吗?",
"clear.title": "清空话题",
"copy.title": "复制助手",
"delete.content": "删除助手会删除所有该助手下的话题和文件,确定要继续吗?",
"delete.title": "删除助手",
"edit.title": "编辑助手",
"save.success": "保存成功",
"save.title": "保存到智能体",
"search": "搜索助手",
"settings.auto_reset_model": "自动重置模型",
"settings.auto_reset_model.tip": "创建新话题时自动重置模型",
"settings.default_model": "默认模型",
"settings.model": "模型设置",
"settings.preset_messages": "预设消息",
"settings.prompt": "提示词设置",
"title": "助手"
},
"button": {
"add": "添加",
"added": "已添加",
"collapse": "收起",
"manage": "管理",
"select_model": "选择模型",
"show.all": "显示全部"
},
"chat": {
"add.assistant.title": "添加助手",
"artifacts.button.download": "下载",
"artifacts.button.preview": "预览",
"assistant.search.placeholder": "搜索",
"default.description": "你好,我是默认助手。你可以立刻开始跟我聊天。",
"default.name": "⭐️ 默认助手",
"default.topic.name": "默认话题",
"input.clear": "清空消息",
"input.clear.content": "确定要清除当前会话所有消息吗?",
"input.clear.title": "清空消息",
"input.collapse": "收起",
"input.context_count.tip": "上下文数",
"input.estimated_tokens.tip": "预估 token 数",
"input.expand": "展开",
"input.new.context": "清除上下文",
"input.new_topic": "新话题 {{Command}}+N",
"input.pause": "暂停",
"input.placeholder": "在这里输入消息...",
"input.send": "发送",
"input.settings": "设置",
"input.topics": " 话题 ",
"input.translate": "翻译成英文",
"input.upload": "上传图片或文档",
"message.new.branch": "新分支",
"message.new.branch.created": "新分支已创建",
"message.new.context": "清除上下文",
"save": "保存",
"settings.code_collapsible": "代码块可折叠",
"settings.context_count": "上下文数",
"settings.context_count.tip": "要保留在上下文中的消息数量,数值越大,上下文越长,消耗的 token 越多。普通聊天建议 5-10",
"settings.max": "不限",
"settings.max_tokens": "开启消息长度限制",
"settings.max_tokens.tip": "单次交互所用的最大 Token 数, 会影响返回结果的长度。普通聊天建议 500-800短文生成建议 800-2000代码生成建议 2000-3600长文生成建议切换模型到 4000 左右",
"settings.reset": "重置",
"settings.set_as_default": "应用到默认助手",
"settings.show_line_numbers": "代码显示行号",
"settings.temperature": "模型温度",
"settings.temperature.tip": "模型生成文本的随机程度。值越大,回复内容越赋有多样性、创造性、随机性;设为 0 根据事实回答。日常聊天建议设置为 0.7",
"suggestions.title": "建议的问题",
"topics.auto_rename": "生成话题名",
"topics.clear.title": "清空消息",
"topics.edit.placeholder": "输入新名称",
"topics.edit.title": "编辑话题名",
"topics.export.image": "导出为图片",
"topics.export.md": "导出为 Markdown",
"topics.export.title": "导出",
"topics.export.word": "导出为 Word",
"topics.list": "话题列表",
"topics.move_to": "移动到",
"topics.title": "话题",
"translate": "翻译"
},
"common": {
"and": "和",
"assistant": "智能体",
"avatar": "头像",
"back": "返回",
"cancel": "取消",
"chat": "聊天",
"close": "关闭",
"copy": "复制",
"cut": "剪切",
"default": "默认",
"delete": "删除",
"description": "描述",
"docs": "文档",
"download": "下载",
"duplicate": "复制",
"edit": "编辑",
"footnote": "引用内容",
"language": "语言",
"model": "模型",
"models": "模型",
"name": "名称",
"paste": "粘贴",
"prompt": "提示词",
"provider": "提供商",
"regenerate": "重新生成",
"rename": "重命名",
"reset": "重置",
"save": "保存",
"search": "搜索",
"select": "选择",
"topics": "话题",
"warning": "警告",
"you": "用户",
"clear": "清除",
"add": "添加"
},
"error": {
"backup.file_format": "备份文件格式错误",
"chat.response": "出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥",
"no_api_key": "API 密钥未配置",
"provider_disabled": "模型提供商未启用",
"render": {
"title": "渲染错误",
"description": "渲染公式失败,请检查公式格式是否正确"
}
},
"export": {
"assistant": "助手",
"attached_files": "附件",
"conversation_details": "会话详情",
"conversation_history": "会话历史",
"created": "创建时间",
"last_updated": "最后更新",
"messages": "消息数",
"user": "用户"
},
"files": {
"actions": "操作",
"all": "所有文件",
"count": "文件数",
"created_at": "创建时间",
"document": "文档",
"file": "文件",
"image": "图片",
"name": "文件名",
"open": "打开",
"size": "大小",
"text": "文本",
"title": "文件"
},
"history": {
"continue_chat": "继续聊天",
"locate.message": "定位到消息",
"search.messages": "搜索所有消息",
"search.placeholder": "搜索话题或消息...",
"search.topics.empty": "没有找到相关话题, 点击回车键搜索所有消息",
"title": "话题搜索"
},
"languages": {
"arabic": "阿拉伯文",
"chinese": "简体中文",
"chinese-traditional": "繁体中文",
"english": "英文",
"french": "法文",
"italian": "意大利文",
"japanese": "日文",
"korean": "韩文",
"portuguese": "葡萄牙文",
"russian": "俄文",
"spanish": "西班牙文"
},
"mermaid": {
"download": {
"png": "下载 PNG",
"svg": "下载 SVG"
},
"tabs": {
"preview": "预览",
"source": "源码"
},
"title": "Mermaid 图表"
},
"message": {
"api.connection.failed": "连接失败",
"api.connection.success": "连接成功",
"assistant.added.content": "智能体添加成功",
"backup.failed": "备份失败",
"backup.success": "备份成功",
"chat.completion.paused": "会话已停止",
"copied": "已复制",
"error.enter.api.host": "请输入您的 API 地址",
"error.enter.api.key": "请输入您的 API 密钥",
"error.enter.model": "请选择一个模型",
"error.invalid.proxy.url": "无效的代理地址",
"error.invalid.webdav": "无效的 WebDAV 设置",
"message.code_style": "代码风格",
"message.delete.content": "确定要删除此消息吗?",
"message.delete.title": "删除消息",
"message.style": "消息样式",
"message.style.bubble": "气泡",
"message.style.plain": "简洁",
"reset.confirm.content": "确定要重置所有数据吗?",
"reset.double.confirm.content": "你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?",
"reset.double.confirm.title": "数据丢失!!!",
"restore.success": "恢复成功",
"save.success.title": "保存成功",
"switch.disabled": "模型回复完成后才能切换",
"topic.added": "话题添加成功",
"upgrade.success.button": "重启",
"upgrade.success.content": "重启用以完成升级",
"upgrade.success.title": "升级成功",
"regenerate.confirm": "重新生成会覆盖当前消息"
},
"minapp": {
"title": "小程序"
},
"history": {
"title": "话题搜索",
"search.placeholder": "搜索话题或消息...",
"continue_chat": "继续聊天",
"search.topics.empty": "没有找到相关话题, 点击回车键搜索所有消息",
"search.messages": "搜索所有消息",
"locate.message": "定位到消息"
"model": {
"pinned": "已固定",
"search": "搜索模型...",
"stream_output": "流式输出",
"type": {
"select": "选择模型类型",
"text": "文本",
"vision": "图像"
}
},
"ollama": {
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟",
"keep_alive_time.placeholder": "分钟",
"keep_alive_time.title": "保持活跃时间",
"title": "Ollama"
},
"paintings": {
"button.delete.image": "删除图片",
"button.delete.image.confirm": "确定要删除此图片吗?",
"button.new.image": "新建图片",
"guidance_scale": "引导比例",
"guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度",
"image.size": "图片尺寸",
"inference_steps": "推理步数",
"inference_steps_tip": "要执行的推理步数。步数越多,质量越高但耗时越长",
"negative_prompt": "反向提示词",
"negative_prompt_tip": "描述你不想在图片中出现的内容",
"number_images": "生成数量",
"number_images_tip": "一次生成的图片数量 (1-4)",
"prompt_placeholder": "描述你想创建的图片,例如:一个宁静的湖泊,夕阳西下,远处是群山",
"regenerate.confirm": "这将覆盖已生成的图片,是否继续?",
"seed": "随机种子",
"seed_tip": "相同的种子和提示词可以生成相似的图片",
"title": "图片"
},
"provider": {
"jina": "Jina",
"mistral": "Mistral",
"hyperbolic": "Hyperbolic",
"grok": "Grok",
"nvidia": "英伟达",
"hunyuan": "腾讯混元",
"zhinao": "360智脑",
"fireworks": "Fireworks",
"together": "Together",
"openai": "OpenAI",
"gemini": "Gemini",
"deepseek": "深度求索",
"moonshot": "月之暗面",
"silicon": "硅基流动",
"openrouter": "OpenRouter",
"yi": "零一万物",
"zhipu": "智谱AI",
"groq": "Groq",
"ollama": "Ollama",
"aihubmix": "AiHubMix",
"anthropic": "Anthropic",
"azure-openai": "Azure OpenAI",
"baichuan": "百川",
"dashscope": "阿里云百炼",
"anthropic": "Anthropic",
"aihubmix": "AiHubMix",
"stepfun": "阶跃星辰",
"deepseek": "深度求索",
"doubao": "豆包",
"minimax": "MiniMax",
"graphrag-kylin-mountain": "GraphRAG",
"fireworks": "Fireworks",
"gemini": "Gemini",
"github": "GitHub Models",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "腾讯混元",
"hyperbolic": "Hyperbolic",
"jina": "Jina",
"minimax": "MiniMax",
"mistral": "Mistral",
"moonshot": "月之暗面",
"nvidia": "英伟达",
"ocoolai": "ocoolAI",
"azure-openai": "Azure OpenAI"
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"silicon": "硅基流动",
"stepfun": "阶跃星辰",
"together": "Together",
"yi": "零一万物",
"zhinao": "360智脑",
"zhipu": "智谱AI"
},
"settings": {
"title": "设置",
"general": "常规设置",
"data": "数据设置",
"provider": "模型服务",
"model": "默认模型",
"assistant": "默认助手",
"about": "关于我们",
"messages.model.title": "模型设置",
"messages.title": "消息设置",
"messages.divider": "消息分割线",
"messages.use_serif_font": "使用衬线字体",
"messages.input.title": "输入设置",
"messages.input.show_estimated_tokens": "状态显示",
"messages.input.send_shortcuts": "发送快捷键",
"messages.input.paste_long_text_as_file": "长文本粘贴为文件",
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
"messages.math_engine": "数学公式引擎",
"about.checkUpdate": "检查更新",
"about.checkingUpdate": "正在检查更新...",
"about.contact.button": "邮件",
"about.contact.title": "邮件联系",
"about.description": "一款为创造者而生的 AI 助手",
"about.downloading": "正在下载更新...",
"about.feedback.button": "反馈",
"about.feedback.title": "意见反馈",
"about.license.button": "查看",
"about.license.title": "许可证",
"about.releases.button": "查看",
"about.releases.title": "更新日志",
"about.title": "关于我们",
"about.updateAvailable": "发现新版本 {{version}}",
"about.updateError": "更新出错",
"about.updateNotAvailable": "你的软件已是最新版本",
"about.website.button": "查看",
"about.website.title": "官方网站",
"advanced.auto_switch_to_topics": "自动切换到话题",
"advanced.title": "高级设置",
"assistant": "默认助手",
"assistant.model_params": "模型参数",
"assistant.title": "默认助手",
"data": {
"app_data": "应用数据",
"app_logs": "应用日志",
"clear_cache": {
"button": "清除缓存",
"confirm": "清除缓存将删除应用缓存的数据,包括小程序数据。此操作不可恢复,是否继续?",
"error": "清除缓存失败",
"success": "缓存清除成功",
"title": "清除缓存"
},
"data.title": "数据目录",
"title": "数据设置",
"webdav.backup.button": "备份到 WebDAV",
"webdav.host": "WebDAV 地址",
"webdav.host.placeholder": "http://localhost:8080",
"webdav.password": "WebDAV 密码",
"webdav.path": "WebDAV 路径",
"webdav.path.placeholder": "/backup",
"webdav.restore.button": "从 WebDAV 恢复",
"webdav.title": "WebDAV",
"webdav.user": "WebDAV 用户名"
},
"display.title": "显示设置",
"font_size.title": "消息字体大小",
"general": "常规设置",
"general.backup.button": "备份",
"general.backup.title": "数据备份与恢复",
"general.manually_check_update.title": "关闭更新检测",
"general.reset.button": "重置",
"general.reset.title": "重置数据",
"general.restore.button": "恢复",
"general.title": "常规设置",
"general.user_name": "用户名",
"general.user_name.placeholder": "请输入用户名",
"general.backup.title": "数据备份与恢复",
"general.backup.button": "备份",
"general.restore.button": "恢复",
"general.reset.title": "重置数据",
"general.reset.button": "重置",
"general.view_webdav_settings": "查看 WebDAV 设置",
"general.manually_check_update.title": "关闭更新检测",
"data.webdav.title": "WebDAV",
"data.webdav.host": "WebDAV 地址",
"data.webdav.host.placeholder": "http://localhost:8080",
"data.webdav.user": "WebDAV 用户名",
"data.webdav.password": "WebDAV 密码",
"data.webdav.path": "WebDAV 路径",
"data.webdav.path.placeholder": "/backup",
"data.webdav.backup.button": "备份到 WebDAV",
"data.webdav.restore.button": "从 WebDAV 恢复",
"advanced.title": "高级设置",
"advanced.click_assistant_switch_to_topics": "点击助手切换到话题",
"provider.api_key": "API 密钥",
"provider.check": "检查",
"provider.get_api_key": "点击这里获取密钥",
"provider.api_host": "API 地址",
"provider.api_version": "API 版本",
"provider.docs_check": "查看",
"provider.docs_more_details": "获取更多详情",
"provider.search_placeholder": "搜索模型 ID 或名称",
"provider.api.url.reset": "重置",
"provider.api.url.preview": "预览: {{url}}",
"provider.api.url.tip": "/结尾忽略v1版本#结尾制使用输入地址",
"models.default_assistant_model": "默认助手模型",
"models.topic_naming_model": "话题命名模型",
"models.translate_model": "翻译模型",
"input.auto_translate_with_space": "快速敲击3次空格翻译",
"messages.divider": "消息分割线",
"messages.input.paste_long_text_as_file": "长文本粘贴为文件",
"messages.input.send_shortcuts": "发送快捷键",
"messages.input.show_estimated_tokens": "显示预估 Token 数",
"messages.input.title": "输入设置",
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
"messages.math_engine": "数学公式引擎",
"messages.model.title": "模型设置",
"messages.title": "消息设置",
"messages.use_serif_font": "使用衬线字体",
"model": "默认模型",
"models.add.add_model": "添加模型",
"models.add.model_id.placeholder": "必填 例如 gpt-3.5-turbo",
"models.add.group_name": "分组名称",
"models.add.group_name.placeholder": "例如 ChatGPT",
"models.add.group_name.tooltip": "例如 ChatGPT",
"models.add.model_id": "模型 ID",
"models.add.model_id.placeholder": "必填 例如 gpt-3.5-turbo",
"models.add.model_id.tooltip": "例如 gpt-3.5-turbo",
"models.add.model_name": "模型名称",
"models.add.model_name.placeholder": "例如 GPT-3.5",
"models.add.group_name": "分组名称",
"models.add.group_name.tooltip": "例如 ChatGPT",
"models.add.group_name.placeholder": "例如 ChatGPT",
"models.default_assistant_model": "默认助手模型",
"models.default_assistant_model_description": "创建新助手时使用的模型,如果助手未设置模型,则使用此模型",
"models.empty": "没有模型",
"assistant.title": "默认助手",
"assistant.model_params": "模型参数",
"about.description": "一款为创造者而生的 AI 助手",
"about.updateNotAvailable": "你的软件已是最新版本",
"about.checkingUpdate": "正在检查更新...",
"about.updateError": "更新出错",
"about.checkUpdate": "检查更新",
"about.downloading": "正在下载更新...",
"provider.delete.title": "删除提供商",
"provider.delete.content": "确定要删除此模型提供商吗?",
"provider.edit.name": "模型提供商名称",
"provider.edit.name.placeholder": "例如 OpenAI",
"about.title": "关于我们",
"about.releases.title": "更新日志",
"about.releases.button": "查看",
"about.website.title": "官方网站",
"about.website.button": "查看",
"about.feedback.title": "意见反馈",
"about.feedback.button": "反馈",
"about.contact.title": "邮件联系",
"about.license.title": "许可证",
"about.license.button": "查看",
"about.contact.button": "邮件",
"models.topic_naming_model": "话题命名模型",
"models.topic_naming_model_description": "自动命名新话题时使用的模型",
"models.translate_model": "翻译模型",
"models.translate_model_description": "翻译服务使用的模型",
"models.translate_model_prompt_message": "请输入翻译模型提示词",
"models.translate_model_prompt_title": "翻译模型提示词",
"models.topic_naming_model_setting_title": "话题命名模型设置",
"models.enable_topic_naming": "话题自动重命名",
"provider": {
"add.name": "提供商名称",
"add.name.placeholder": "例如 OpenAI",
"add.title": "添加提供商",
"add.type": "提供商类型",
"api.url.preview": "预览: {{url}}",
"api.url.reset": "重置",
"api.url.tip": "/结尾忽略v1版本#结尾制使用输入地址",
"api_host": "API 地址",
"api_key": "API 密钥",
"api_key.tip": "多个密钥使用逗号分隔",
"api_version": "API 版本",
"check": "检查",
"check_all_keys": "检查所有密钥",
"check_multiple_keys": "检查多个 API 密钥",
"delete.content": "确定要删除此模型提供商吗?",
"delete.title": "删除提供商",
"docs_check": "查看",
"docs_more_details": "获取更多详情",
"get_api_key": "点击这里获取密钥",
"no_models": "请先添加模型再检查 API 连接",
"not_checked": "未检查",
"remove_duplicate_keys": "移除重复密钥",
"remove_invalid_keys": "删除无效密钥",
"search_placeholder": "搜索模型 ID 或名称",
"title": "模型服务"
},
"proxy": {
"mode": {
"custom": "自定义代理",
"none": "不使用代理",
"system": "系统代理",
"title": "代理模式"
},
"title": "代理设置"
},
"proxy.title": "代理地址",
"theme.title": "主题",
"shortcuts": {
"action": "操作",
"key": "按键",
"new_topic": "新建话题",
"title": "快捷方式",
"zoom_in": "放大界面",
"zoom_out": "缩小界面",
"zoom_reset": "重置缩放",
"show_app": "显示应用",
"reset_defaults": "重置默认快捷键",
"reset_defaults_confirm": "确定要重置所有快捷键吗?",
"press_shortcut": "按下快捷键",
"alt_warning": "Mac 系统不能使用 Option + 字母作为快捷键",
"reset_to_default": "重置为默认",
"clear_shortcut": "清除快捷键"
},
"theme.auto": "跟随系统",
"theme.dark": "深色主题",
"theme.light": "浅色主题",
"theme.auto": "跟随系统",
"theme.title": "主题",
"theme.window.style.opaque": "不透明窗口",
"theme.window.style.title": "窗口样式",
"theme.window.style.transparent": "透明窗口",
"theme.window.style.opaque": "不透明窗口",
"font_size.title": "消息字体大小",
"title": "设置",
"topic.position": "话题位置",
"topic.position.left": "左侧",
"topic.position.right": "右侧",
"topic.show.time": "显示话题时间",
"shortcuts": {
"title": "快捷方式",
"action": "操作",
"key": "按键",
"new_topic": "新建话题",
"zoom_in": "放大界面",
"zoom_out": "缩小界面",
"zoom_reset": "重置缩放"
}
"tray.title": "启用系统托盘图标"
},
"translate": {
"title": "翻译",
"any.language": "任意语言",
"button.translate": "翻译",
"confirm": {
"content": "翻译后将覆盖原文,是否继续?",
"title": "翻译确认"
},
"error.not_configured": "翻译模型未配置",
"error.failed": "翻译失败",
"input.placeholder": "输入文本进行翻译",
"output.placeholder": "翻译",
"confirm": "原文已复制到剪贴板,是否用翻译后的文本替换?"
"processing": "翻译中...",
"title": "翻译",
"close": "关闭"
},
"languages": {
"english": "英文",
"chinese": "简体中文",
"chinese-traditional": "繁体中文",
"japanese": "日文",
"korean": "韩文",
"russian": "俄文",
"spanish": "西班牙文",
"french": "法文",
"italian": "意大利文",
"portuguese": "葡萄牙文",
"arabic": "阿拉伯文"
},
"ollama": {
"title": "Ollama",
"keep_alive_time.title": "保持活跃时间",
"keep_alive_time.placeholder": "分钟",
"keep_alive_time.description": "对话后模型在内存中保持的时间默认5分钟"
},
"error": {
"chat.response": "出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥",
"backup.file_format": "备份文件格式错误",
"provider_disabled": "模型提供商未启用",
"no_api_key": "API 密钥未配置"
"tray": {
"quit": "退出",
"show_window": "显示窗口"
},
"words": {
"knowledgeGraph": "知识图谱",
"visualization": "可视化"
},
"export": {
"attached_files": "附件",
"user": "用户",
"assistant": "助手",
"created": "创建时间",
"last_updated": "最后更新",
"messages": "消息数",
"conversation_details": "会话详情",
"conversation_history": "会话历史"
}
}
}

View File

@ -1,407 +1,499 @@
{
"translation": {
"common": {
"avatar": "頭像",
"language": "語言",
"model": "模型",
"models": "模型",
"topics": "話題",
"docs": "文件",
"and": "與",
"assistant": "智能體",
"name": "名稱",
"description": "描述",
"prompt": "提示詞",
"rename": "重新命名",
"delete": "刪除",
"edit": "編輯",
"duplicate": "複製",
"copy": "複製",
"regenerate": "重新生成",
"provider": "提供商",
"you": "您",
"save": "保存",
"footnotes": "引用",
"select": "選擇",
"search": "搜尋",
"default": "預設",
"warning": "警告",
"back": "返回",
"chat": "聊天",
"close": "關閉",
"cancel": "取消",
"download": "下載"
},
"button": {
"add": "添加",
"added": "已添加",
"manage": "管理",
"select_model": "選擇模型",
"show.all": "顯示全部",
"collapse": "收起"
},
"message": {
"copied": "已複製",
"assistant.added.content": "智能體添加成功",
"message.delete.title": "刪除訊息",
"message.delete.content": "確定要刪除此訊息嗎?",
"error.enter.api.key": "請先輸入您的 API 密鑰",
"error.enter.api.host": "請先輸入您的 API 主機地址",
"error.enter.model": "請先選擇一個模型",
"error.invalid.proxy.url": "無效的代理 URL",
"error.invalid.webdav": "無效的 WebDAV 設定",
"api.connection.failed": "連接失敗",
"api.connection.success": "連接成功",
"chat.completion.paused": "聊天完成已暫停",
"switch.disabled": "助手生成回覆時無法切換",
"restore.success": "恢復成功",
"backup.success": "備份成功",
"backup.failed": "備份失敗",
"reset.confirm.content": "確定要清除所有資料嗎?",
"reset.double.confirm.title": "資料將會丟失!!!",
"reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?",
"upgrade.success.title": "升級成功",
"upgrade.success.content": "請重新啟動應用以完成升級",
"upgrade.success.button": "重新啟動",
"topic.added": "新話題已添加",
"save.success.title": "保存成功",
"message.style": "消息樣式",
"message.style.bubble": "氣泡",
"message.style.plain": "簡潔"
},
"chat": {
"save": "保存",
"default.name": "⭐️ 預設助手",
"default.description": "你好,我是預設助手。你可以立即開始與我聊天。",
"default.topic.name": "預設話題",
"topics.title": "話題",
"topics.auto_rename": "自動重新命名",
"topics.edit.title": "編輯名稱",
"topics.edit.placeholder": "輸入新名稱",
"topics.clear.title": "清空消息",
"topics.move_to": "移動到",
"topics.list": "話題列表",
"topics.export.title": "匯出",
"topics.export.image": "匯出為圖片",
"topics.export.md": "匯出為 Markdown",
"topics.export.word": "導出為 Word",
"input.new_topic": "新話題",
"input.topics": " 話題 ",
"input.clear": "清除",
"input.new.context": "清除上下文",
"input.expand": "展開",
"input.collapse": "收起",
"input.clear.title": "清除所有訊息?",
"input.clear.content": "您想要清除當前話題的所有訊息嗎?",
"input.placeholder": "在此輸入您的訊息...",
"input.send": "發送",
"input.pause": "暫停",
"input.settings": "設定",
"input.upload": "上傳圖片或文檔",
"input.context_count.tip": "上下文數量",
"input.estimated_tokens.tip": "預估 Token 數",
"settings.temperature": "溫度",
"settings.temperature.tip": "較低的值使模型更具創造性和不可預測性,較高的值則使其更具確定性和精確性。",
"settings.conext_count": "上下文",
"settings.conext_count.tip": "在上下文中保留的前幾則訊息。",
"settings.max_tokens": "啟用最大 Token 限制",
"settings.max_tokens.tip": "模型可以生成的最大 Token 數。普通聊天建議 500-800。短文生成建議 800-2000。代碼生成建議 2000-3600。長文生成建議超過 4000。",
"settings.reset": "重置",
"settings.set_as_default": "設為預設助手",
"settings.max": "最大",
"settings.show_line_numbers": "代码顯示行號",
"suggestions.title": "建議的問題",
"add.assistant.title": "添加助手",
"message.new.context": "新上下文",
"message.new.branch": "新分支",
"message.new.branch.created": "新分支已建立",
"assistant.search.placeholder": "搜尋",
"artifacts.button.preview": "預覽",
"artifacts.button.download": "下載"
},
"assistants": {
"title": "助手",
"abbr": "助",
"search": "搜尋助手...",
"settings.prompt": "提示詞設定",
"settings.model": "模型設定",
"settings.preset_messages": "預設訊息",
"settings.default_model": "預設模型",
"settings.auto_reset_model": "自動重置模型",
"settings.auto_reset_model.tip": "每次新的話題時自動重置模型",
"edit.title": "編輯助手",
"copy.title": "複製助手",
"clear.title": "清空話題",
"clear.content": "清空話題會刪除助手下所有主題和文件,確定要繼續嗎?",
"save.title": "儲存到智能體",
"save.success": "儲存成功",
"delete.title": "删除助手",
"delete.content": "删除助手会删除所有该助手下的话题和文件,确定要繼續吗?"
},
"model": {
"stream_output": "串流輸出",
"search": "搜尋模型..."
},
"images": {
"title": "繪圖",
"image.size": "影像尺寸",
"button.new.image": "新繪圖",
"button.delete.image": "刪除繪圖",
"button.delete.image.confirm": "確定要刪除此繪圖嗎?",
"number_images": "生成數量",
"number_images_tip": "一次生成的圖片數量 (1-4)",
"seed": "隨機種子",
"seed_tip": "相同的種子和提示詞可以生成相似的圖片",
"inference_steps": "推理步數",
"inference_steps_tip": "要執行的推理步數。步數越多,質量越高但耗時越長",
"guidance_scale": "引導比例",
"guidance_scale_tip": "無分類器指導。控制模型在尋找相關圖像時對提示詞的遵循程度",
"negative_prompt": "反向提示詞",
"negative_prompt_tip": "描述你不想在圖片中出現的內容",
"prompt_placeholder": "描述你想創建的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山",
"regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?"
},
"files": {
"title": "檔案",
"file": "檔案",
"name": "名稱",
"size": "大小",
"count": "數量",
"created_at": "建立時間",
"image": "圖片",
"text": "文本",
"document": "文檔",
"actions": "操作",
"open": "打開",
"all": "所有檔案"
},
"agents": {
"title": "智能體",
"my_agents": "我的智能體",
"add.title": "创建智能體",
"edit.title": "編輯智能體",
"add.button": "添加到助手",
"add.name": "名稱",
"add.name.placeholder": "輸入名稱",
"add.prompt": "提示詞",
"add.prompt.placeholder": "輸入提示詞",
"add.button": "添加到助手",
"manage.title": "管理智能體",
"add.title": "创建智能體",
"delete.popup.content": "確定要刪除此智能體嗎?",
"tag.default": "預設",
"tag.system": "系統",
"tag.agent": "智能体",
"edit.message.title": "預設訊息",
"edit.message.add.title": "添加",
"edit.message.group.title": "訊息組",
"edit.message.assistant.title": "助手",
"edit.message.assistant.placeholder": "輸入助手消息",
"edit.message.user.title": "用戶",
"edit.message.user.placeholder": "輸入用戶消息",
"edit.message.assistant.title": "助手",
"edit.message.empty.content": "會話輸入內容不能為空",
"edit.message.group.title": "訊息組",
"edit.message.title": "預設訊息",
"edit.message.user.placeholder": "輸入用戶消息",
"edit.message.user.title": "用戶",
"edit.model.select.title": "選擇模型",
"edit.settings.hide_preset_messages": "隱藏預設消息",
"edit.title": "編輯智能體",
"manage.title": "管理智能體",
"my_agents": "我的智能體",
"search.no_results": "沒有找到相關智能體",
"sorting.title": "排序"
"sorting.title": "排序",
"tag.agent": "智能体",
"tag.default": "預設",
"tag.new": "新建",
"tag.system": "系統",
"title": "智能體"
},
"assistants": {
"abbr": "助",
"clear.content": "清空話題會刪除助手下所有主題和文件,確定要繼續嗎?",
"clear.title": "清空話題",
"copy.title": "複製助手",
"delete.content": "删除助手会删除所有该助手下的话题和文件,确定要繼續吗?",
"delete.title": "删除助手",
"edit.title": "編輯助手",
"save.success": "儲存成功",
"save.title": "儲存到智能體",
"search": "搜尋助手...",
"settings.auto_reset_model": "自動重置模型",
"settings.auto_reset_model.tip": "每次新的話題時自動重置模型",
"settings.default_model": "預設模型",
"settings.model": "模型設定",
"settings.preset_messages": "預設訊息",
"settings.prompt": "提示詞設定",
"title": "助手"
},
"button": {
"add": "添加",
"added": "已添加",
"collapse": "收起",
"manage": "管理",
"select_model": "選擇模型",
"show.all": "顯示全部"
},
"chat": {
"add.assistant.title": "添加助手",
"artifacts.button.download": "下載",
"artifacts.button.preview": "預覽",
"assistant.search.placeholder": "搜尋",
"default.description": "你好,我是預設助手。你可以立即開始與我聊天。",
"default.name": "⭐️ 預設助手",
"default.topic.name": "預設話題",
"input.clear": "清除",
"input.clear.content": "您想要清除當前話題的所有訊息嗎?",
"input.clear.title": "清除所有訊息?",
"input.collapse": "收起",
"input.context_count.tip": "上下文數量",
"input.estimated_tokens.tip": "預估 Token 數",
"input.expand": "展開",
"input.new.context": "清除上下文",
"input.new_topic": "新話題 {{Command}}+N",
"input.pause": "暫停",
"input.placeholder": "在此輸入您的訊息...",
"input.send": "發送",
"input.settings": "設定",
"input.topics": " 話題 ",
"input.translate": "翻譯成英文",
"input.upload": "上傳圖片或文檔",
"message.new.branch": "新分支",
"message.new.branch.created": "新分支已建立",
"message.new.context": "新上下文",
"save": "保存",
"settings.code_collapsible": "代码块可折叠",
"settings.context_count": "上下文",
"settings.context_count.tip": "在上下文中保留的前幾則訊息。",
"settings.max": "最大",
"settings.max_tokens": "啟用最大 Token 限制",
"settings.max_tokens.tip": "模型可以生成的最大 Token 數。普通聊天建議 500-800。短文生成建議 800-2000。代碼生成建議 2000-3600。長文生成建議超過 4000。",
"settings.reset": "重置",
"settings.set_as_default": "設為預設助手",
"settings.show_line_numbers": "代码顯示行號",
"settings.temperature": "溫度",
"settings.temperature.tip": "較低的值使模型更具創造性和不可預測性,較高的值則使其更具確定性和精確性。",
"suggestions.title": "建議的問題",
"topics.auto_rename": "自動重新命名",
"topics.clear.title": "清空消息",
"topics.edit.placeholder": "輸入新名稱",
"topics.edit.title": "編輯名稱",
"topics.export.image": "匯出為圖片",
"topics.export.md": "匯出為 Markdown",
"topics.export.title": "匯出",
"topics.export.word": "導出為 Word",
"topics.list": "話題列表",
"topics.move_to": "移動到",
"topics.title": "話題",
"translate": "翻譯"
},
"common": {
"and": "與",
"assistant": "智能體",
"avatar": "頭像",
"back": "返回",
"cancel": "取消",
"chat": "聊天",
"close": "關閉",
"copy": "複製",
"cut": "剪下",
"default": "預設",
"delete": "刪除",
"description": "描述",
"docs": "文件",
"download": "下載",
"duplicate": "複製",
"edit": "編輯",
"footnotes": "引用",
"language": "語言",
"model": "模型",
"models": "模型",
"name": "名稱",
"paste": "貼上",
"prompt": "提示詞",
"provider": "提供商",
"regenerate": "重新生成",
"rename": "重新命名",
"reset": "重置",
"save": "保存",
"search": "搜尋",
"select": "選擇",
"topics": "話題",
"warning": "警告",
"you": "您",
"clear": "清除",
"add": "添加"
},
"error": {
"backup.file_format": "備份文件格式錯誤",
"chat.response": "出現錯誤。如果尚未配置 API 密鑰,請前往設定 > 模型提供者中配置密鑰",
"no_api_key": "API 密鑰未配置",
"provider_disabled": "模型提供商未啟用",
"render": {
"title": "渲染錯誤",
"description": "渲染公式失敗,請檢查公式格式是否正確"
}
},
"export": {
"assistant": "助手",
"attached_files": "附件",
"conversation_details": "會話詳情",
"conversation_history": "會話歷史",
"created": "創建時間",
"last_updated": "最後<E69C80><E5BE8C>新",
"messages": "訊息數",
"user": "用戶"
},
"files": {
"actions": "操作",
"all": "所有檔案",
"count": "數量",
"created_at": "建立時間",
"document": "文檔",
"file": "檔案",
"image": "圖片",
"name": "名稱",
"open": "打開",
"size": "大小",
"text": "文本",
"title": "檔案"
},
"history": {
"continue_chat": "繼續聊天",
"locate.message": "定位到訊息",
"search.messages": "搜尋所有訊息",
"search.placeholder": "搜尋話題或訊息...",
"search.topics.empty": "沒有找到相關話題, 點擊回車鍵搜尋所有訊息",
"title": "搜尋話題"
},
"languages": {
"arabic": "阿拉伯文",
"chinese": "簡體中文",
"chinese-traditional": "繁體中文",
"english": "英文",
"french": "法文",
"italian": "意大利文",
"japanese": "日文",
"korean": "韓文",
"portuguese": "葡萄牙文",
"russian": "俄文",
"spanish": "西班牙文"
},
"mermaid": {
"download": {
"png": "下載 PNG",
"svg": "下載 SVG"
},
"tabs": {
"preview": "預覽",
"source": "原始碼"
},
"title": "Mermaid 圖表"
},
"message": {
"api.connection.failed": "連接失敗",
"api.connection.success": "連接成功",
"assistant.added.content": "智能體添加成功",
"backup.failed": "備份失敗",
"backup.success": "備份成功",
"chat.completion.paused": "聊天完成已暫停",
"copied": "已複製",
"error.enter.api.host": "請先輸入您的 API 主機地址",
"error.enter.api.key": "請先輸入您的 API 密鑰",
"error.enter.model": "請先選擇一個模型",
"error.invalid.proxy.url": "無效的代理 URL",
"error.invalid.webdav": "無效的 WebDAV 設定",
"message.code_style": "程式碼風格",
"message.delete.content": "確定要刪除此訊息嗎?",
"message.delete.title": "刪除訊息",
"message.style": "消息樣式",
"message.style.bubble": "氣泡",
"message.style.plain": "簡潔",
"reset.confirm.content": "確定要清除所有資料嗎?",
"reset.double.confirm.content": "所有資料將會被清除,您確定要繼續嗎?",
"reset.double.confirm.title": "資料將會丟失!!!",
"restore.success": "恢復成功",
"save.success.title": "保存成功",
"switch.disabled": "助手生成回覆時無法切換",
"topic.added": "新話題已添加",
"upgrade.success.button": "重新啟動",
"upgrade.success.content": "請重新啟動應用以完成升級",
"upgrade.success.title": "升級成功",
"regenerate.confirm": "重新生成會覆蓋當前訊息"
},
"minapp": {
"title": "小程序"
},
"history": {
"title": "搜尋話題",
"search.placeholder": "搜尋話題或訊息...",
"continue_chat": "繼續聊天",
"search.topics.empty": "沒有找到相關話題, 點擊回車鍵搜尋所有訊息",
"search.messages": "搜尋所有訊息",
"locate.message": "定位到訊息"
"model": {
"pinned": "已固定",
"search": "搜尋模型...",
"stream_output": "串流輸出",
"type": {
"select": "選擇模型類型",
"text": "文字",
"vision": "圖像"
}
},
"ollama": {
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。",
"keep_alive_time.placeholder": "分鐘",
"keep_alive_time.title": "保持活躍時間",
"title": "Ollama"
},
"paintings": {
"button.delete.image": "刪除繪圖",
"button.delete.image.confirm": "確定要刪除此繪圖嗎?",
"button.new.image": "新繪圖",
"guidance_scale": "引導比例",
"guidance_scale_tip": "無分類器指導。控制模型在尋找相關圖像時對提示詞的遵循程度",
"image.size": "影像尺寸",
"inference_steps": "推理步數",
"inference_steps_tip": "要執行的推理步數。步數越多,質量越高但耗時越長",
"negative_prompt": "反向提示詞",
"negative_prompt_tip": "描述你不想在圖片中出現的內容",
"number_images": "生成數量",
"number_images_tip": "一次生成的圖片數量 (1-4)",
"prompt_placeholder": "描述你想創建的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山",
"regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?",
"seed": "隨機種子",
"seed_tip": "相同的種子和提示詞可以生成相似的圖片",
"title": "繪圖"
},
"provider": {
"jina": "Jina",
"mistral": "Mistral",
"hyperbolic": "Hyperbolic",
"grok": "Grok",
"nvidia": "輝達",
"zhinao": "360智腦",
"hunyuan": "騰訊混元",
"fireworks": "Fireworks",
"together": "Together",
"openai": "OpenAI",
"gemini": "Gemini",
"deepseek": "深度求索",
"moonshot": "月之暗面",
"silicon": "SiliconFlow",
"openrouter": "OpenRouter",
"yi": "零一萬物",
"zhipu": "智譜AI",
"groq": "Groq",
"ollama": "Ollama",
"aihubmix": "AiHubMix",
"anthropic": "Anthropic",
"azure-openai": "Azure OpenAI",
"baichuan": "百川",
"dashscope": "阿里雲百鍊",
"anthropic": "Anthropic",
"aihubmix": "AiHubMix",
"stepfun": "StepFun",
"deepseek": "深度求索",
"doubao": "豆包",
"minimax": "MiniMax",
"graphrag-kylin-mountain": "GraphRAG",
"fireworks": "Fireworks",
"gemini": "Gemini",
"github": "GitHub Models",
"graphrag-kylin-mountain": "GraphRAG",
"grok": "Grok",
"groq": "Groq",
"hunyuan": "騰訊混元",
"hyperbolic": "Hyperbolic",
"jina": "Jina",
"minimax": "MiniMax",
"mistral": "Mistral",
"moonshot": "月之暗面",
"nvidia": "輝達",
"ocoolai": "ocoolAI",
"azure-openai": "Azure OpenAI"
"ollama": "Ollama",
"openai": "OpenAI",
"openrouter": "OpenRouter",
"silicon": "SiliconFlow",
"stepfun": "StepFun",
"together": "Together",
"yi": "零一萬物",
"zhinao": "360智腦",
"zhipu": "智譜AI"
},
"settings": {
"title": "設定",
"general": "一般設定",
"data": "數據設定",
"provider": "模型提供者",
"model": "預設模型",
"assistant": "預設助手",
"about": "關於與回饋",
"messages.model.title": "模型設定",
"messages.title": "訊息設定",
"messages.divider": "訊息間顯示分隔線",
"messages.use_serif_font": "使用襯線字體",
"messages.input.title": "輸入設定",
"messages.input.show_estimated_tokens": "顯示預估輸入 Token 數",
"messages.input.send_shortcuts": "發送快捷鍵",
"messages.input.paste_long_text_as_file": "將長文本貼上為檔案",
"messages.math_engine": "Markdown 渲染輸入訊息",
"messages.math_render_engine": "數學公式引擎",
"about.checkUpdate": "檢查更新",
"about.checkingUpdate": "正在檢查更新...",
"about.contact.button": "郵件",
"about.contact.title": "聯繫方式",
"about.description": "一款為創作者而生的強大 AI 助手",
"about.downloading": "正在下載...",
"about.feedback.button": "回饋",
"about.feedback.title": "回饋",
"about.license.button": "查看",
"about.license.title": "許可證",
"about.releases.button": "查看",
"about.releases.title": "更新日誌",
"about.title": "關於我們",
"about.updateAvailable": "發現新版本 {{version}}",
"about.updateError": "更新錯誤",
"about.updateNotAvailable": "您正在使用最新版本",
"about.website.button": "網站",
"about.website.title": "官方網站",
"advanced.auto_switch_to_topics": "自動切換到話題",
"advanced.title": "進階設定",
"assistant": "預設助手",
"assistant.model_params": "模型參數",
"assistant.title": "預設助手",
"data": {
"clear_cache": {
"button": "清除緩存",
"confirm": "清除緩存將刪除應用緩存數據,包括小程序數據。此操作不可恢復,是否繼續?",
"error": "清除緩存失敗",
"success": "緩存清除成功",
"title": "清除緩存"
},
"data.app_data": "應用數據",
"data.app_logs": "應用日誌",
"data.title": "數據目錄",
"title": "數據設定",
"webdav.backup.button": "從 WebDAV 備份",
"webdav.host": "WebDAV 主機位址",
"webdav.host.placeholder": "http://localhost:8080",
"webdav.password": "WebDAV 密碼",
"webdav.path": "WebDAV Path",
"webdav.path.placeholder": "/backup",
"webdav.restore.button": "從 WebDAV 恢復",
"webdav.title": "WebDAV",
"webdav.user": "WebDAV 使用者名稱"
},
"display.title": "顯示設定",
"font_size.title": "訊息字體大小",
"general": "一般設定",
"general.backup.button": "備份",
"general.backup.title": "資料備份與復原",
"general.manually_check_update.title": "關閉更新檢查",
"general.reset.button": "重置",
"general.reset.title": "資料重置",
"general.restore.button": "復原",
"general.title": "一般設定",
"general.user_name": "使用者名稱",
"general.user_name.placeholder": "輸入您的名稱",
"general.backup.title": "資料備份與復原",
"general.backup.button": "備份",
"general.restore.button": "復原",
"general.view_webdav_settings": "查看 WebDAV 設定",
"general.reset.title": "資料重置",
"general.reset.button": "重置",
"general.manually_check_update.title": "關閉更新檢查",
"data.webdav.title": "WebDAV",
"data.webdav.host": "WebDAV 主機位址",
"data.webdav.host.placeholder": "http://localhost:8080",
"data.webdav.user": "WebDAV 使用者名稱",
"data.webdav.password": "WebDAV 密碼",
"data.webdav.path": "WebDAV Path",
"data.webdav.path.placeholder": "/backup",
"data.webdav.backup.button": "從 WebDAV 備份",
"data.webdav.restore.button": "從 WebDAV 恢復",
"advanced.title": "進階設定",
"advanced.click_assistant_switch_to_topics": "點擊助手切換到話題",
"provider.api_key": "API 密鑰",
"provider.check": "檢查",
"provider.get_api_key": "獲取 API 密鑰",
"provider.api_host": "API 主機地址",
"provider.api_version": "API 版本",
"provider.docs_check": "檢查",
"provider.docs_more_details": "查看更多細節",
"provider.search_placeholder": "搜尋模型 ID 或名稱",
"provider.api.url.reset": "重置",
"provider.api.url.preview": "預覽: {{url}}",
"provider.api.url.tip": "/結尾忽略v1版本#結尾強制使用輸入位址",
"models.default_assistant_model": "預設助手模型",
"models.topic_naming_model": "話題命名模型",
"models.translate_model": "翻譯模型",
"input.auto_translate_with_space": "快速敲擊3次空格翻譯",
"messages.divider": "訊息間顯示分隔線",
"messages.input.paste_long_text_as_file": "將長文本貼上為檔案",
"messages.input.send_shortcuts": "發送快捷鍵",
"messages.input.show_estimated_tokens": "顯示預估 Token 數",
"messages.input.title": "輸入設定",
"messages.math_engine": "Markdown 渲染輸入訊息",
"messages.math_render_engine": "數學公式引擎",
"messages.model.title": "模型設定",
"messages.title": "訊息設定",
"messages.use_serif_font": "使用襯線字體",
"model": "預設模型",
"models.add.add_model": "添加模型",
"models.add.model_id.placeholder": "必填,例如 gpt-3.5-turbo",
"models.add.group_name": "群組名稱",
"models.add.group_name.placeholder": "可選,例如 ChatGPT",
"models.add.group_name.tooltip": "可選,例如 ChatGPT",
"models.add.model_id": "模型 ID",
"models.add.model_id.placeholder": "必填,例如 gpt-3.5-turbo",
"models.add.model_id.tooltip": "例如 gpt-3.5-turbo",
"models.add.model_name": "模型名稱",
"models.add.model_name.placeholder": "可選,例如 GPT-4",
"models.add.group_name": "群組名稱",
"models.add.group_name.tooltip": "可選,例如 ChatGPT",
"models.add.group_name.placeholder": "可選,例如 ChatGPT",
"models.default_assistant_model": "預設助手模型",
"models.default_assistant_model_description": "創建新助手時使用的模型,如果助手未設置模型,則使用此模型",
"models.empty": "找不到模型",
"assistant.title": "預設助手",
"assistant.model_params": "模型參數",
"about.description": "一款為創作者而生的強大 AI 助手",
"about.updateNotAvailable": "您正在使用最新版本",
"about.checkingUpdate": "正在檢查更新...",
"about.updateError": "更新錯誤",
"about.checkUpdate": "檢查更新",
"about.downloading": "正在下載...",
"provider.delete.title": "刪除提供者",
"provider.delete.content": "確定要刪除此提供者嗎?",
"provider.edit.name": "提供者名稱",
"provider.edit.name.placeholder": "例如OpenAI",
"about.title": "關於我們",
"about.releases.title": "更新日誌",
"about.releases.button": "查看",
"about.website.title": "官方網站",
"about.website.button": "網站",
"about.feedback.title": "回饋",
"about.feedback.button": "回饋",
"about.contact.title": "聯繫方式",
"about.license.title": "許可證",
"about.license.button": "查看",
"about.contact.button": "郵件",
"models.topic_naming_model": "話題命名模型",
"models.topic_naming_model_description": "自動命名新話題時使用的模型",
"models.translate_model": "翻譯模型",
"models.translate_model_description": "翻譯服務使用的模型",
"models.translate_model_prompt_message": "請輸入翻譯模型提示詞",
"models.translate_model_prompt_title": "翻譯模型提示詞",
"models.topic_naming_model_setting_title": "話題命名模型設定",
"models.enable_topic_naming": "話題自動重命名",
"provider": {
"add.name": "提供者名稱",
"add.name.placeholder": "例如OpenAI",
"add.title": "添加提供者",
"add.type": "提供商類型",
"api.url.preview": "預覽: {{url}}",
"api.url.reset": "重置",
"api.url.tip": "/結尾忽略v1版本#結尾強制使用輸入位址",
"api_host": "API 主機地址",
"api_key": "API 密鑰",
"api_key.tip": "多個密鑰使用逗號分隔",
"api_version": "API 版本",
"check": "檢查",
"check_all_keys": "檢查所有密鑰",
"check_multiple_keys": "檢查多個 API 密鑰",
"delete.content": "確定要刪除此提供者嗎?",
"delete.title": "刪除提供者",
"docs_check": "檢查",
"docs_more_details": "查看更多細節",
"get_api_key": "獲取 API 密鑰",
"no_models": "請先添加模型再檢查 API 連接",
"not_checked": "未檢查",
"remove_duplicate_keys": "移除重複密鑰",
"remove_invalid_keys": "刪除無效密鑰",
"search_placeholder": "搜尋模型 ID 或名稱",
"title": "模型提供者"
},
"proxy": {
"mode": {
"custom": "自定義代理",
"none": "不使用代理",
"system": "系統代理",
"title": "代理模式"
},
"title": "代理設定"
},
"proxy.title": "代理地址",
"theme.title": "主題",
"shortcuts": {
"action": "操作",
"key": "按鍵",
"new_topic": "新建話題",
"title": "快速方式",
"zoom_in": "放大界面",
"zoom_out": "縮小界面",
"zoom_reset": "重置縮放",
"show_app": "顯示應用",
"reset_defaults": "重置預設快捷鍵",
"reset_defaults_confirm": "確定要重置所有快捷鍵嗎?",
"press_shortcut": "按下快捷鍵",
"alt_warning": "Mac 不能使用 Option + 字母作為快捷鍵",
"reset_to_default": "重置為預設",
"clear_shortcut": "清除快捷鍵"
},
"theme.auto": "自動",
"theme.dark": "深色主題",
"theme.light": "淺色主題",
"theme.auto": "自動",
"theme.title": "主題",
"theme.window.style.opaque": "不透明視窗",
"theme.window.style.title": "視窗樣式",
"theme.window.style.transparent": "透明視窗",
"theme.window.style.opaque": "不透明視窗",
"font_size.title": "訊息字體大小",
"title": "設定",
"topic.position": "話題位置",
"topic.position.left": "左側",
"topic.position.right": "右側",
"topic.show.time": "顯示話題時間",
"shortcuts": {
"title": "快速方式",
"action": "操作",
"key": "按鍵",
"new_topic": "新建話題",
"zoom_in": "放大界面",
"zoom_out": "縮小界面",
"zoom_reset": "重置縮放"
}
"tray.title": "啟用系統托盤圖標"
},
"translate": {
"title": "翻譯",
"any.language": "任意語言",
"button.translate": "翻譯",
"confirm": {
"content": "翻譯後將覆蓋原文,是否繼續?",
"title": "翻譯確認"
},
"error.not_configured": "翻譯模型未配置",
"error.failed": "翻譯失敗",
"input.placeholder": "輸入文字進行翻譯",
"output.placeholder": "翻譯",
"confirm": "原文已複製到剪貼簿,是否用翻譯後的文字替換?"
"processing": "翻譯中...",
"title": "翻譯",
"close": "關閉"
},
"languages": {
"english": "英文",
"chinese": "簡體中文",
"chinese-traditional": "繁體中文",
"japanese": "日文",
"korean": "韓文",
"russian": "俄文",
"spanish": "西班牙文",
"french": "法文",
"italian": "意大利文",
"portuguese": "葡萄牙文",
"arabic": "阿拉伯文"
},
"ollama": {
"title": "Ollama",
"keep_alive_time.title": "保持活躍時間",
"keep_alive_time.placeholder": "分鐘",
"keep_alive_time.description": "對話後模型在記憶體中保持的時間(預設為 5 分鐘)。"
},
"error": {
"chat.response": "出現錯誤。如果尚未配置 API 密鑰,請前往設定 > 模型提供者中配置密鑰",
"backup.file_format": "備份文件格式錯誤",
"provider_disabled": "模型提供商未啟用",
"no_api_key": "API 密鑰未配置"
"tray": {
"quit": "退出",
"show_window": "顯示視窗"
},
"words": {
"knowledgeGraph": "知識圖譜",
"visualization": "可視化"
},
"export": {
"attached_files": "附件",
"user": "用戶",
"assistant": "助手",
"created": "創建時間",
"last_updated": "最後更新",
"messages": "訊息數",
"conversation_details": "會話詳情",
"conversation_history": "會話歷史"
}
}
}

View File

@ -1,31 +1,6 @@
import KeyvStorage from '@kangfenmao/keyv-storage'
import localforage from 'localforage'
import { APP_NAME } from './config/env'
import { ThemeMode } from './types'
import { loadScript } from './utils'
export async function initMermaid(theme: ThemeMode) {
if (!window.mermaid) {
await loadScript('https://unpkg.com/mermaid@10.9.1/dist/mermaid.min.js')
window.mermaid.initialize({
startOnLoad: true,
theme: theme === ThemeMode.dark ? 'dark' : 'default',
securityLevel: 'loose'
})
window.mermaid.contentLoaded()
}
}
function init() {
localforage.config({
driver: localforage.INDEXEDDB,
name: 'CherryAI',
version: 1.0,
storeName: 'cherryai',
description: `${APP_NAME} Storage`
})
window.keyv = new KeyvStorage()
window.keyv.init()
}

View File

@ -33,6 +33,7 @@ let _agentGroups: Record<string, Agent[]> = {}
const AgentsPage: FC = () => {
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const agentGroups = useMemo(() => {
if (Object.keys(_agentGroups).length === 0) {
@ -44,26 +45,35 @@ const AgentsPage: FC = () => {
const { t, i18n } = useTranslation()
const filteredAgentGroups = useMemo(() => {
const groups = { : [] }
const groups: Record<string, Agent[]> = {
: [],
精选: agentGroups['精选'] || []
}
if (!search.trim()) {
Object.entries(agentGroups).forEach(([group, agents]) => {
groups[group] = agents
if (group !== '精选') {
groups[group] = agents
}
})
return groups
}
Object.entries(agentGroups).forEach(([group, agents]) => {
const filteredAgents = agents.filter(
(agent) =>
agent.name.toLowerCase().includes(search.toLowerCase()) ||
agent.description?.toLowerCase().includes(search.toLowerCase())
)
if (filteredAgents.length > 0) {
groups[group] = filteredAgents
}
const uniqueAgents = new Map<string, Agent>()
Object.entries(agentGroups).forEach(([, agents]) => {
agents.forEach((agent) => {
if (
(agent.name.toLowerCase().includes(search.toLowerCase()) ||
agent.description?.toLowerCase().includes(search.toLowerCase())) &&
!uniqueAgents.has(agent.name)
) {
uniqueAgents.set(agent.name, agent)
}
})
})
return groups
return { 搜索结果: Array.from(uniqueAgents.values()) }
}, [agentGroups, search])
const getAgentName = (agent: Agent) => {
@ -111,9 +121,7 @@ const AgentsPage: FC = () => {
)
const tabItems = useMemo(() => {
let groups = Object.keys(filteredAgentGroups)
groups = groups.includes('办公') ? [groups[0], '办公', ...groups.slice(1)] : groups
const groups = Object.keys(filteredAgentGroups)
return groups.map((group, i) => {
const id = String(i + 1)
@ -133,7 +141,10 @@ const AgentsPage: FC = () => {
) : (
filteredAgentGroups[group]?.map((agent, index) => (
<Col span={6} key={group + index}>
<AgentCard onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent))} agent={agent as any} />
<AgentCard
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent as any))}
agent={agent as any}
/>
</Col>
))
)}
@ -144,6 +155,14 @@ const AgentsPage: FC = () => {
})
}, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search])
const handleSearch = () => {
if (searchInput.trim() === '') {
setSearch('')
} else {
setSearch(searchInput)
}
}
return (
<Container>
<Navbar>
@ -156,18 +175,37 @@ const AgentsPage: FC = () => {
size="small"
variant="filled"
allowClear
suffix={<SearchOutlined />}
value={search}
onClear={() => setSearch('')}
suffix={<SearchOutlined onClick={handleSearch} />}
value={searchInput}
maxLength={50}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => setSearchInput(e.target.value)}
onPressEnter={handleSearch}
/>
<div style={{ width: 80 }} />
</NavbarCenter>
</Navbar>
<ContentContainer id="content-container">
<AssistantsContainer>
{tabItems.length > 0 ? (
<Tabs tabPosition="right" animated items={tabItems} />
{Object.values(filteredAgentGroups).flat().length > 0 ? (
search.trim() ? (
<TabContent>
<Row gutter={[20, 20]}>
{Object.values(filteredAgentGroups)
.flat()
.map((agent, index, array) => (
<Col span={array.length === 1 ? 12 : 6} key={index}>
<AgentCard
onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent as any))}
agent={agent as any}
/>
</Col>
))}
</Row>
</TabContent>
) : (
<Tabs tabPosition="right" animated items={tabItems} $language={i18n.language} />
)
) : (
<EmptyView>
<Empty description={t('agents.search.no_results')} />
@ -226,7 +264,7 @@ const EmptyView = styled.div`
color: var(--color-text-secondary);
`
const Tabs = styled(TabsAntd)`
const Tabs = styled(TabsAntd)<{ $language: string }>`
display: flex;
flex: 1;
flex-direction: row-reverse;
@ -234,8 +272,8 @@ const Tabs = styled(TabsAntd)`
padding-right: 0 !important;
}
.ant-tabs-nav {
min-width: 140px;
max-width: 140px;
min-width: ${({ $language }) => ($language.startsWith('zh') ? '110px' : '140px')};
max-width: ${({ $language }) => ($language.startsWith('zh') ? '110px' : '140px')};
}
.ant-tabs-nav-list {
padding: 10px 8px;
@ -245,19 +283,28 @@ const Tabs = styled(TabsAntd)`
}
.ant-tabs-tab {
margin: 0 !important;
border-radius: 20px;
border-radius: 16px;
margin-bottom: 5px !important;
font-size: 13px;
justify-content: left;
padding: 7px 12px !important;
padding: 7px 15px !important;
border: 0.5px solid transparent;
justify-content: ${({ $language }) => ($language.startsWith('zh') ? 'center' : 'flex-start')};
.ant-tabs-tab-btn {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
&:hover {
color: var(--color-text) !important;
background-color: var(--color-background-soft);
}
}
.ant-tabs-tab-active {
background-color: var(--color-background-mute);
background-color: var(--color-background-soft);
border-right: none;
border: 0.5px solid var(--color-border);
}
.ant-tabs-content-holder {
border-left: 0.5px solid var(--color-border);

View File

@ -3,6 +3,7 @@ export type GroupTranslations = {
'en-US': string
'zh-CN': string
'zh-TW': string
'ru-RU': string
}
}
@ -10,171 +11,205 @@ export const groupTranslations: GroupTranslations = {
: {
'en-US': 'My Agents',
'zh-CN': '我的',
'zh-TW': '我的'
'zh-TW': '我的',
'ru-RU': 'Мои агенты'
},
: {
'en-US': 'Career',
'zh-CN': '职业',
'zh-TW': '職業'
'zh-TW': '職業',
'ru-RU': 'Карьера'
},
: {
'en-US': 'Business',
'zh-CN': '商业',
'zh-TW': '商業'
'zh-TW': '商業',
'ru-RU': 'Бизнес'
},
: {
'en-US': 'Tools',
'zh-CN': '工具',
'zh-TW': '工具'
'zh-TW': '工具',
'ru-RU': 'Инструменты'
},
: {
'en-US': 'Language',
'zh-CN': '语言',
'zh-TW': '語言'
'zh-TW': '語言',
'ru-RU': 'Язык'
},
: {
'en-US': 'Office',
'zh-CN': '办公',
'zh-TW': '辦公'
'zh-TW': '辦公',
'ru-RU': 'Офис'
},
: {
'en-US': 'General',
'zh-CN': '通用',
'zh-TW': '通用'
'zh-TW': '通用',
'ru-RU': 'Общее'
},
: {
'en-US': 'Writing',
'zh-CN': '写作',
'zh-TW': '寫作'
'zh-TW': '寫作',
'ru-RU': 'Письмо'
},
Artifacts: {
'en-US': 'Artifacts',
'zh-CN': 'Artifacts',
'zh-TW': 'Artifacts'
: {
'en-US': 'Featured',
'zh-CN': '精选',
'zh-TW': '精選',
'ru-RU': 'Избранное'
},
: {
'en-US': 'Programming',
'zh-CN': '编程',
'zh-TW': '編程'
'zh-TW': '編程',
'ru-RU': 'Программирование'
},
: {
'en-US': 'Emotion',
'zh-CN': '情感',
'zh-TW': '情感'
'zh-TW': '情感',
'ru-RU': 'Эмоции'
},
: {
'en-US': 'Education',
'zh-CN': '教育',
'zh-TW': '教育'
'zh-TW': '教育',
'ru-RU': 'Образование'
},
: {
'en-US': 'Creative',
'zh-CN': '创意',
'zh-TW': '創意'
'zh-TW': '創意',
'ru-RU': 'Креатив'
},
: {
'en-US': 'Academic',
'zh-CN': '学术',
'zh-TW': '學術'
'zh-TW': '學術',
'ru-RU': 'Академический'
},
: {
'en-US': 'Design',
'zh-CN': '设计',
'zh-TW': '設計'
'zh-TW': '設計',
'ru-RU': 'Дизайн'
},
: {
'en-US': 'Art',
'zh-CN': '艺术',
'zh-TW': '藝術'
'zh-TW': '藝術',
'ru-RU': 'Искусство'
},
: {
'en-US': 'Entertainment',
'zh-CN': '娱乐',
'zh-TW': '娛樂'
'zh-TW': '娛樂',
'ru-RU': 'Развлечения'
},
: {
'en-US': 'Life',
'zh-CN': '生活',
'zh-TW': '生活'
'zh-TW': '生活',
'ru-RU': 'Жизнь'
},
: {
'en-US': 'Medical',
'zh-CN': '医疗',
'zh-TW': '醫療'
'zh-TW': '醫療',
'ru-RU': 'Медицина'
},
: {
'en-US': 'Games',
'zh-CN': '游戏',
'zh-TW': '遊戲'
'zh-TW': '遊戲',
'ru-RU': 'Игры'
},
: {
'en-US': 'Translation',
'zh-CN': '翻译',
'zh-TW': '翻譯'
'zh-TW': '翻譯',
'ru-RU': 'Перевод'
},
: {
'en-US': 'Music',
'zh-CN': '音乐',
'zh-TW': '音樂'
'zh-TW': '音樂',
'ru-RU': 'Музыка'
},
: {
'en-US': 'Review',
'zh-CN': '点评',
'zh-TW': '點評'
'zh-TW': '點評',
'ru-RU': 'Обзор'
},
: {
'en-US': 'Copywriting',
'zh-CN': '文案',
'zh-TW': '文案'
'zh-TW': '文案',
'ru-RU': 'Копирайтинг'
},
: {
'en-US': 'Encyclopedia',
'zh-CN': '百科',
'zh-TW': '百科'
'zh-TW': '百科',
'ru-RU': 'Энциклопедия'
},
: {
'en-US': 'Health',
'zh-CN': '健康',
'zh-TW': '健康'
'zh-TW': '健康',
'ru-RU': 'Здоровье'
},
: {
'en-US': 'Marketing',
'zh-CN': '营销',
'zh-TW': '營銷'
'zh-TW': '營銷',
'ru-RU': 'Маркетинг'
},
: {
'en-US': 'Science',
'zh-CN': '科学',
'zh-TW': '科學'
'zh-TW': '科學',
'ru-RU': 'Наука'
},
: {
'en-US': 'Analysis',
'zh-CN': '分析',
'zh-TW': '分析'
'zh-TW': '分析',
'ru-RU': 'Анализ'
},
: {
'en-US': 'Legal',
'zh-CN': '法律',
'zh-TW': '法律'
'zh-TW': '法律',
'ru-RU': 'Право'
},
: {
'en-US': 'Consulting',
'zh-CN': '咨询',
'zh-TW': '諮詢'
'zh-TW': '諮詢',
'ru-RU': 'Консалтинг'
},
: {
'en-US': 'Finance',
'zh-CN': '金融',
'zh-TW': '金融'
'zh-TW': '金融',
'ru-RU': 'Финансы'
},
: {
'en-US': 'Travel',
'zh-CN': '旅游',
'zh-TW': '旅遊'
'zh-TW': '旅遊',
'ru-RU': 'Путешествия'
},
: {
'en-US': 'Management',
'zh-CN': '管理',
'zh-TW': '管理'
'zh-TW': '管理',
'ru-RU': 'Управление'
}
}

View File

@ -81,7 +81,7 @@ const Container = styled.div`
text-align: center;
gap: 10px;
background-color: var(--color-background);
border-radius: 15px;
border-radius: 16px;
position: relative;
overflow: hidden;
cursor: pointer;
@ -94,8 +94,8 @@ const Container = styled.div`
position: absolute;
top: 0;
left: 0;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
background: var(--color-background-soft);
transition: all 0.5s ease;
border-bottom: none;
@ -133,7 +133,7 @@ const CardInfo = styled.div`
align-items: center;
gap: 5px;
transition: all 0.5s ease;
padding: 0 15px;
padding: 0 8px;
width: 100%;
`

View File

@ -38,19 +38,20 @@ const FilesPage: FC = () => {
{
title: t('files.name'),
dataIndex: 'file',
key: 'file'
key: 'file',
width: '300px'
},
{
title: t('files.size'),
dataIndex: 'size',
key: 'size',
width: '100px'
width: '80px'
},
{
title: t('files.count'),
dataIndex: 'count',
key: 'count',
width: '100px'
width: '60px'
},
{
title: t('files.created_at'),
@ -219,7 +220,7 @@ const ImageInfo = styled.div`
const SideNav = styled.div`
width: var(--assistants-width);
border-right: 0.5px solid var(--color-border);
padding: 15px;
padding: 7px 12px;
.ant-menu {
border-inline-end: none !important;
@ -227,18 +228,22 @@ const SideNav = styled.div`
}
.ant-menu-item {
height: 40px;
line-height: 40px;
height: 36px;
line-height: 36px;
margin: 4px 0;
width: 100%;
border-radius: 16px;
border: 0.5px solid transparent;
&:hover {
background-color: var(--color-background-soft);
background-color: var(--color-background-soft) !important;
}
&.ant-menu-item-selected {
background-color: var(--color-background-soft);
color: var(--color-primary);
border: 0.5px solid var(--color-border);
color: var(--color-text);
}
}
`

View File

@ -1,7 +1,6 @@
import { ArrowLeftOutlined, EnterOutlined, SearchOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { Message, Topic } from '@renderer/types'
import { Divider, Input } from 'antd'
import { Input } from 'antd'
import { last } from 'lodash'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -59,44 +58,38 @@ const TopicsPage: FC = () => {
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'flex-start' }}>{t('history.title')} </NavbarCenter>
</Navbar>
<ContentContainer id="content-container">
<Header>
{stack.length > 1 && (
<HeaderLeft>
<MenuIcon onClick={goBack}>
<ArrowLeftOutlined />
</MenuIcon>
</HeaderLeft>
)}
<SearchInput
placeholder={t('history.search.placeholder')}
type="search"
value={search}
allowClear
onChange={(e) => setSearch(e.target.value.trimStart())}
suffix={search.length >= 2 ? <EnterOutlined /> : <SearchOutlined />}
onPressEnter={onSearch}
/>
</Header>
<Divider style={{ margin: 0 }} />
<TopicsHistory
keywords={search}
onClick={onTopicClick as any}
onSearch={onSearch}
style={{ display: isShow('topics') }}
<Header>
{stack.length > 1 && (
<HeaderLeft>
<MenuIcon onClick={goBack}>
<ArrowLeftOutlined />
</MenuIcon>
</HeaderLeft>
)}
<SearchInput
placeholder={t('history.search.placeholder')}
type="search"
value={search}
allowClear
onChange={(e) => setSearch(e.target.value.trimStart())}
suffix={search.length >= 2 ? <EnterOutlined /> : <SearchOutlined />}
onPressEnter={onSearch}
/>
<TopicMessages topic={topic} style={{ display: isShow('topic') }} />
<SearchResults
keywords={isShow('search') ? search : ''}
onMessageClick={onMessageClick}
onTopicClick={onTopicClick}
style={{ display: isShow('search') }}
/>
<SearchMessage message={message} style={{ display: isShow('message') }} />
</ContentContainer>
</Header>
<TopicsHistory
keywords={search}
onClick={onTopicClick as any}
onSearch={onSearch}
style={{ display: isShow('topics') }}
/>
<TopicMessages topic={topic} style={{ display: isShow('topic') }} />
<SearchResults
keywords={isShow('search') ? search : ''}
onMessageClick={onMessageClick}
onTopicClick={onTopicClick}
style={{ display: isShow('search') }}
/>
<SearchMessage message={message} style={{ display: isShow('message') }} />
</Container>
)
}
@ -108,24 +101,18 @@ const Container = styled.div`
height: 100%;
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
height: 100%;
overflow-y: scroll;
`
const Header = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 8px 20px;
padding-top: 10px;
padding: 12px 0;
width: 100%;
position: relative;
background-color: var(--color-background-mute);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom: 0.5px solid var(--color-frame-border);
`
const HeaderLeft = styled.div`
@ -133,7 +120,7 @@ const HeaderLeft = styled.div`
flex-direction: row;
align-items: center;
position: absolute;
top: 8px;
top: 12px;
left: 15px;
`
@ -143,11 +130,11 @@ const MenuIcon = styled.div`
flex-direction: row;
justify-content: center;
align-items: center;
width: 36px;
height: 36px;
width: 33px;
height: 33px;
border-radius: 50%;
&:hover {
background-color: var(--color-background-mute);
background-color: var(--color-background);
.anticon {
color: var(--color-text-1);
}

View File

@ -2,11 +2,11 @@ import { ArrowRightOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import { default as MessageItem } from '@renderer/pages/home/Messages/Message'
import { locateToMessage } from '@renderer/services/MessagesService'
import NavigationService from '@renderer/services/NavigationService'
import { Message } from '@renderer/types'
import { Button } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
interface Props extends React.HTMLAttributes<HTMLDivElement> {
@ -14,7 +14,7 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
}
const SearchMessage: FC<Props> = ({ message, ...props }) => {
const navigate = useNavigate()
const navigate = NavigationService.navigate!
const { t } = useTranslation()
if (!message) {

View File

@ -1,14 +1,16 @@
import { ArrowRightOutlined, MessageOutlined } from '@ant-design/icons'
import { HStack } from '@renderer/components/Layout'
import SearchPopup from '@renderer/components/Popups/SearchPopup'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
import { useSettings } from '@renderer/hooks/useSettings'
import { getAssistantById } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { locateToMessage } from '@renderer/services/MessagesService'
import { isGenerating, locateToMessage } from '@renderer/services/MessagesService'
import NavigationService from '@renderer/services/NavigationService'
import { Topic } from '@renderer/types'
import { Button, Divider, Empty } from 'antd'
import { t } from 'i18next'
import { FC } from 'react'
import { useNavigate } from 'react-router'
import styled from 'styled-components'
import { default as MessageItem } from '../../home/Messages/Message'
@ -18,8 +20,9 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
}
const TopicMessages: FC<Props> = ({ topic, ...props }) => {
const navigate = useNavigate()
const navigate = NavigationService.navigate!
const { handleScroll, containerRef } = useScrollPosition('TopicMessages')
const { messageStyle } = useSettings()
const isEmpty = (topic?.messages || []).length === 0
@ -27,14 +30,16 @@ const TopicMessages: FC<Props> = ({ topic, ...props }) => {
return null
}
const onContinueChat = (topic: Topic) => {
const onContinueChat = async (topic: Topic) => {
await isGenerating()
SearchPopup.hide()
const assistant = getAssistantById(topic.assistantId)
navigate('/', { state: { assistant, topic } })
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 100)
}
return (
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll}>
<MessagesContainer {...props} ref={containerRef} onScroll={handleScroll} className={messageStyle}>
<ContainerWrapper style={{ paddingTop: 30, paddingBottom: 30 }}>
{topic?.messages.map((message) => (
<div key={message.id} style={{ position: 'relative' }}>

View File

@ -24,7 +24,7 @@ const Chat: FC<Props> = (props) => {
return (
<Container id="chat" className={messageStyle}>
<Main vertical flex={1} justify="space-between">
<Main id="chat-main" vertical flex={1} justify="space-between">
<Messages
key={props.activeTopic.id}
assistant={assistant}
@ -52,35 +52,6 @@ const Container = styled.div`
height: 100%;
flex: 1;
justify-content: space-between;
&.bubble {
background-color: var(--chat-background);
.system-prompt {
background-color: var(--chat-background-assistant);
}
.message-content-container {
margin: 5px 0;
border-radius: 8px;
padding: 10px 15px 0 15px;
}
.message-user {
.markdown,
.anticon,
.iconfont,
.message-tokens {
color: var(--chat-text-user);
}
.message-action-button:hover {
background-color: var(--color-white-soft);
}
}
#inputbar {
border-radius: 0;
margin: 0;
border: none;
border-top: 1px solid var(--color-border-mute);
background: var(--color-background);
}
}
`
const Main = styled(Flex)`

View File

@ -1,9 +1,10 @@
import { useAssistants } from '@renderer/hooks/useAssistant'
import { useShowAssistants } from '@renderer/hooks/useStore'
import { useActiveTopic } from '@renderer/hooks/useTopic'
import NavigationService from '@renderer/services/NavigationService'
import { Assistant } from '@renderer/types'
import { FC, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { FC, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import styled from 'styled-components'
import Chat from './Chat'
@ -14,6 +15,7 @@ let _activeAssistant: Assistant
const HomePage: FC = () => {
const { assistants } = useAssistants()
const navigate = useNavigate()
const location = useLocation()
const state = location.state
@ -24,6 +26,15 @@ const HomePage: FC = () => {
_activeAssistant = activeAssistant
useEffect(() => {
NavigationService.setNavigate(navigate)
}, [navigate])
useEffect(() => {
state?.assistant && setActiveAssistant(state?.assistant)
state?.topic && setActiveTopic(state?.topic)
}, [state])
return (
<Container>
<Navbar activeAssistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />

View File

@ -1,7 +1,7 @@
import { PaperClipOutlined } from '@ant-design/icons'
import { documentExts, imageExts, textExts } from '@renderer/config/constant'
import { isVisionModel } from '@renderer/config/models'
import { FileType, Model } from '@renderer/types'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'

View File

@ -7,21 +7,26 @@ import {
PauseCircleOutlined,
QuestionCircleOutlined
} from '@ant-design/icons'
import { documentExts, imageExts, textExts } from '@renderer/config/constant'
import { PicCenterOutlined } from '@ant-design/icons'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowTopics } from '@renderer/hooks/useStore'
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import FileManager from '@renderer/services/FileManager'
import { estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService'
import { translateText } from '@renderer/services/TranslateService'
import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, FileType, Message, Topic } from '@renderer/types'
import { delay, getFileExtension, uuid } from '@renderer/utils'
import { documentExts, imageExts, textExts } from '@shared/config/constant'
import { Button, Popconfirm, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
@ -47,7 +52,15 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { addTopic, model, setModel } = useAssistant(assistant.id)
const { sendMessageShortcut, fontSize, pasteLongTextAsFile, showInputEstimatedTokens } = useSettings()
const {
sendMessageShortcut,
fontSize,
pasteLongTextAsFile,
showInputEstimatedTokens,
clickAssistantToShowTopic,
language,
autoTranslateWithSpace
} = useSettings()
const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
const [contextCount, setContextCount] = useState(0)
@ -60,6 +73,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const { searching } = useRuntime()
const { isBubbleStyle } = useMessageStyle()
const dispatch = useAppDispatch()
const [spaceClickCount, setSpaceClickCount] = useState(0)
const spaceClickTimer = useRef<NodeJS.Timeout>()
const [isTranslating, setIsTranslating] = useState(false)
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision])
@ -107,9 +123,48 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
setExpend(false)
}, [assistant.id, assistant.topics, generating, files, text])
const translate = async () => {
if (isTranslating) {
return
}
try {
setIsTranslating(true)
const translatedText = await translateText(text, 'english')
translatedText && setText(translatedText)
setTimeout(() => resizeTextArea(), 0)
} catch (error) {
console.error('Translation failed:', error)
} finally {
setIsTranslating(false)
}
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13
if (autoTranslateWithSpace) {
if (event.key === ' ') {
setSpaceClickCount((prev) => prev + 1)
if (spaceClickTimer.current) {
clearTimeout(spaceClickTimer.current)
}
spaceClickTimer.current = setTimeout(() => {
setSpaceClickCount(0)
}, 200)
if (spaceClickCount === 2) {
console.log('Triple space detected - trigger translation')
setSpaceClickCount(0)
setIsTranslating(true)
translate()
return
}
}
}
if (expended) {
if (event.key === 'Escape') {
return setExpend(false)
@ -131,6 +186,11 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
}
const addNewTopic = useCallback(async () => {
if (generating) {
window.message.warning({ content: t('message.switch.disabled'), key: 'generating' })
return
}
const topic = getDefaultTopic(assistant.id)
await db.topics.add({ id: topic.id, messages: [] })
@ -143,7 +203,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
addTopic(topic)
setActiveTopic(topic)
}, [addTopic, assistant, setActiveTopic, setModel])
clickAssistantToShowTopic && setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
}, [addTopic, assistant, clickAssistantToShowTopic, generating, setActiveTopic, setModel, t])
const clearTopic = async () => {
if (generating) {
@ -252,20 +314,18 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
})
}
// Command or Ctrl + N create new topic
useEffect(() => {
const onKeydown = (e) => {
if (!generating) {
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
addNewTopic()
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
textareaRef.current?.focus()
}
}
const onTranslated = (translatedText: string) => {
setText(translatedText)
setTimeout(() => resizeTextArea(), 0)
}
useShortcut('new_topic', () => {
if (!generating) {
addNewTopic()
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
textareaRef.current?.focus()
}
document.addEventListener('keydown', onKeydown)
return () => document.removeEventListener('keydown', onKeydown)
}, [addNewTopic, generating])
})
useEffect(() => {
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
@ -288,6 +348,18 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
textareaRef.current?.focus()
}, [assistant])
useEffect(() => {
setTimeout(() => resizeTextArea(), 0)
}, [])
useEffect(() => {
return () => {
if (spaceClickTimer.current) {
clearTimeout(spaceClickTimer.current)
}
}
}, [])
return (
<Container onDragOver={handleDragOver} onDrop={handleDrop}>
<AttachmentPreview files={files} setFiles={setFiles} />
@ -296,7 +368,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder')}
placeholder={isTranslating ? t('chat.input.translating') : t('chat.input.placeholder')}
autoFocus
contextMenu="true"
variant="borderless"
@ -313,7 +385,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
/>
<Toolbar>
<ToolbarMenu>
<Tooltip placement="top" title={t('chat.input.new_topic')} arrow>
<Tooltip placement="top" title={t('chat.input.new_topic', { Command: isMac ? '⌘' : 'Ctrl' })} arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<FormOutlined />
</ToolbarButton>
@ -342,6 +414,11 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
</ToolbarButton>
</Tooltip>
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
<ToolbarButton type="text" onClick={onNewContext}>
<Tooltip placement="top" title={t('chat.input.new.context')}>
<PicCenterOutlined />
</Tooltip>
</ToolbarButton>
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
@ -356,6 +433,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
/>
</ToolbarMenu>
<ToolbarMenu>
{!language.startsWith('en') && (
<TranslateButton text={text} onTranslated={onTranslated} isLoading={isTranslating} />
)}
{generating && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>

View File

@ -1,7 +1,7 @@
import { ArrowUpOutlined, MenuOutlined, PicCenterOutlined } from '@ant-design/icons'
import { ArrowUpOutlined, MenuOutlined } from '@ant-design/icons'
import { HStack, VStack } from '@renderer/components/Layout'
import { useSettings } from '@renderer/hooks/useSettings'
import { Divider, Popover, Tooltip } from 'antd'
import { Divider, Popover } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -13,7 +13,7 @@ type Props = {
ToolbarButton: any
} & React.HTMLAttributes<HTMLDivElement>
const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCount, ToolbarButton, ...props }) => {
const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCount }) => {
const { t } = useTranslation()
const { showInputEstimatedTokens } = useSettings()
@ -38,21 +38,14 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
}
return (
<>
<ToolbarButton type="text" onClick={props.onClick}>
<Tooltip placement="top" title={t('chat.input.new.context')}>
<PicCenterOutlined />
</Tooltip>
</ToolbarButton>
<Container>
<Popover content={PopoverContent}>
<MenuOutlined /> {contextCount}
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
<ArrowUpOutlined />
{inputTokenCount} / {estimateTokenCount}
</Popover>
</Container>
</>
<Container>
<Popover content={PopoverContent}>
<MenuOutlined /> {contextCount}
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
<ArrowUpOutlined />
{inputTokenCount} / {estimateTokenCount}
</Popover>
</Container>
)
}

View File

@ -32,10 +32,10 @@ const Artifacts: FC<Props> = ({ html }) => {
return (
<Container>
<Button type="primary" icon={<ExpandOutlined />} onClick={onPreview}>
<Button type="primary" icon={<ExpandOutlined />} onClick={onPreview} size="small">
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<DownloadOutlined />} onClick={onDownload}>
<Button icon={<DownloadOutlined />} onClick={onDownload} size="small">
{t('chat.artifacts.button.download')}
</Button>
</Container>

View File

@ -1,13 +1,9 @@
import { CheckOutlined } from '@ant-design/icons'
import { CheckOutlined, DownOutlined, RightOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useSyntaxHighlighter } from '@renderer/context/SyntaxHighlighterProvider'
import { useSettings } from '@renderer/hooks/useSettings'
import { initMermaid } from '@renderer/init'
import { ThemeMode } from '@renderer/types'
import React, { memo, useState } from 'react'
import React, { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
import styled from 'styled-components'
import Artifacts from './Artifacts'
@ -19,38 +15,103 @@ interface CodeBlockProps {
[key: string]: any
}
const CollapseIcon: React.FC<{ expanded: boolean; onClick: () => void }> = ({ expanded, onClick }) => {
return (
<CollapseIconWrapper onClick={onClick}>
{expanded ? <DownOutlined style={{ fontSize: 12 }} /> : <RightOutlined style={{ fontSize: 12 }} />}
</CollapseIconWrapper>
)
}
const ExpandButton: React.FC<{
isExpanded: boolean
onClick: () => void
showButton: boolean
}> = ({ isExpanded, onClick, showButton }) => {
if (!showButton) return null
return (
<ExpandButtonWrapper onClick={onClick}>
<div className="button-text">{isExpanded ? '收起' : '展开'}</div>
</ExpandButtonWrapper>
)
}
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
const match = /language-(\w+)/.exec(className || '')
const showFooterCopyButton = children && children.length > 500
const { codeShowLineNumbers, fontSize } = useSettings()
const { theme } = useTheme()
const language = match?.[1]
const { codeShowLineNumbers, fontSize, codeCollapsible } = useSettings()
const language = match?.[1] ?? 'text'
const [html, setHtml] = useState<string>('')
const { codeToHtml } = useSyntaxHighlighter()
const [isExpanded, setIsExpanded] = useState(!codeCollapsible)
const [shouldShowExpandButton, setShouldShowExpandButton] = useState(false)
const codeContentRef = useRef<HTMLDivElement>(null)
const showFooterCopyButton = children && children.length > 500 && !codeCollapsible
useEffect(() => {
const loadHighlightedCode = async () => {
const highlightedHtml = await codeToHtml(children, language)
setHtml(highlightedHtml)
}
loadHighlightedCode()
}, [children, language, codeToHtml])
useEffect(() => {
if (codeContentRef.current) {
setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350)
}
}, [html])
useEffect(() => {
if (!codeCollapsible) {
setIsExpanded(true)
setShouldShowExpandButton(false)
} else {
setIsExpanded(!codeCollapsible)
if (codeContentRef.current) {
setShouldShowExpandButton(codeContentRef.current.scrollHeight > 350)
}
}
}, [codeCollapsible])
if (language === 'mermaid') {
initMermaid(theme)
return <Mermaid chart={children} />
}
return match ? (
<div className="code-block">
<CodeHeader>
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{codeCollapsible && shouldShowExpandButton && (
<CollapseIcon expanded={isExpanded} onClick={() => setIsExpanded(!isExpanded)} />
)}
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
</div>
<CopyButton text={children} />
</CodeHeader>
<SyntaxHighlighter
language={match[1]}
style={theme === ThemeMode.dark ? atomDark : oneLight}
wrapLongLines={false}
showLineNumbers={codeShowLineNumbers}
customStyle={{
<CodeContent
ref={codeContentRef}
isShowLineNumbers={codeShowLineNumbers}
dangerouslySetInnerHTML={{ __html: html }}
style={{
border: '0.5px solid var(--color-code-background)',
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
marginTop: 0,
fontSize
}}>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
fontSize: fontSize - 1,
maxHeight: codeCollapsible && !isExpanded ? '350px' : 'none',
overflow: codeCollapsible && !isExpanded ? 'auto' : 'visible',
position: 'relative'
}}
/>
{codeCollapsible && (
<ExpandButton
isExpanded={isExpanded}
onClick={() => setIsExpanded(!isExpanded)}
showButton={shouldShowExpandButton}
/>
)}
{showFooterCopyButton && (
<CodeFooter>
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
@ -81,6 +142,31 @@ const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ t
)
}
const CodeContent = styled.div<{ isShowLineNumbers: boolean }>`
.shiki {
padding: 1em;
}
${(props) =>
props.isShowLineNumbers &&
`
code {
counter-reset: step;
counter-increment: step 0;
}
code .line::before {
content: counter(step);
counter-increment: step;
width: 1rem;
margin-right: 1rem;
display: inline-block;
text-align: right;
opacity: 0.35;
}
`}
`
const CodeHeader = styled.div`
display: flex;
align-items: center;
@ -111,6 +197,7 @@ const CodeFooter = styled.div`
flex-direction: row;
justify-content: flex-end;
align-items: center;
position: relative;
.copy {
cursor: pointer;
color: var(--color-text-3);
@ -121,4 +208,45 @@ const CodeFooter = styled.div`
}
`
const ExpandButtonWrapper = styled.div`
position: relative;
cursor: pointer;
height: 25px;
margin-top: -25px;
.button-text {
position: absolute;
bottom: 0;
left: 0;
right: 0;
text-align: center;
padding: 8px;
color: var(--color-text-3);
z-index: 1;
transition: color 0.2s;
font-size: 12px;
}
&:hover .button-text {
color: var(--color-text-1);
}
`
const CollapseIconWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-3);
transition: all 0.2s ease;
&:hover {
background-color: var(--color-background-soft);
color: var(--color-text-1);
}
`
export default memo(CodeBlock)

View File

@ -18,6 +18,9 @@ import CodeBlock from './CodeBlock'
import ImagePreview from './ImagePreview'
import Link from './Link'
const ALLOWED_ELEMENTS =
/<(style|p|div|span|b|i|strong|em|ul|ol|li|table|tr|td|th|thead|tbody|h[1-6]|blockquote|pre|code|br|hr)/i
interface Props {
message: Message
}
@ -36,8 +39,8 @@ const Markdown: FC<Props> = ({ message }) => {
}, [message.content, message.status, t])
const rehypePlugins = useMemo(() => {
const hasUnsafeElements = /<(input|textarea|select)/i.test(messageContent)
return hasUnsafeElements ? [rehypeMath] : [rehypeRaw, rehypeMath]
const hasElements = ALLOWED_ELEMENTS.test(messageContent)
return hasElements ? [rehypeRaw, rehypeMath] : [rehypeMath]
}, [messageContent, rehypeMath])
if (message.role === 'user' && !renderInputMessageAsMarkdown) {

View File

@ -1,5 +1,7 @@
import React, { useEffect } from 'react'
import MermaidPopup from './MermaidPopup'
interface Props {
chart: string
}
@ -9,7 +11,15 @@ const Mermaid: React.FC<Props> = ({ chart }) => {
window?.mermaid?.contentLoaded()
}, [])
return <div className="mermaid">{chart}</div>
const onPreview = () => {
MermaidPopup.show({ chart })
}
return (
<div className="mermaid" onClick={onPreview} style={{ cursor: 'pointer' }}>
{chart}
</div>
)
}
export default Mermaid

View File

@ -0,0 +1,164 @@
import { TopView } from '@renderer/components/TopView'
import { download } from '@renderer/utils/download'
import { Button, Modal, Space, Tabs } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface ShowParams {
chart: string
}
interface Props extends ShowParams {
resolve: (data: any) => void
}
const PopupContainer: React.FC<Props> = ({ resolve, chart }) => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const mermaidId = `mermaid-popup-${Date.now()}`
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = () => {
resolve({})
}
const handleDownload = async (format: 'svg' | 'png') => {
try {
const element = document.getElementById(mermaidId)
if (!element) return
const timestamp = Date.now()
if (format === 'svg') {
const svgElement = element.querySelector('svg')
if (!svgElement) return
const svgData = new XMLSerializer().serializeToString(svgElement)
const blob = new Blob([svgData], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
download(url, `mermaid-diagram-${timestamp}.svg`)
URL.revokeObjectURL(url)
} else if (format === 'png') {
const svgElement = element.querySelector('svg')
if (!svgElement) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'anonymous'
const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
const width = viewBox[2] || svgElement.clientWidth || svgElement.getBoundingClientRect().width
const height = viewBox[3] || svgElement.clientHeight || svgElement.getBoundingClientRect().height
const svgData = new XMLSerializer().serializeToString(svgElement)
const svgBase64 = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svgData)))}`
img.onload = () => {
const scale = 3
canvas.width = width * scale
canvas.height = height * scale
if (ctx) {
ctx.scale(scale, scale)
ctx.drawImage(img, 0, 0, width, height)
}
canvas.toBlob((blob) => {
if (blob) {
const pngUrl = URL.createObjectURL(blob)
download(pngUrl, `mermaid-diagram-${timestamp}.png`)
URL.revokeObjectURL(pngUrl)
}
}, 'image/png')
}
img.src = svgBase64
}
} catch (error) {
console.error('Download failed:', error)
}
}
useEffect(() => {
window?.mermaid?.contentLoaded()
}, [])
return (
<Modal
title={t('mermaid.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
width={1000}
centered
footer={[
<Space key="download-buttons">
<Button onClick={() => handleDownload('svg')}>{t('mermaid.download.svg')}</Button>
<Button onClick={() => handleDownload('png')}>{t('mermaid.download.png')}</Button>
</Space>
]}>
<Tabs
items={[
{
key: 'preview',
label: t('mermaid.tabs.preview'),
children: (
<StyledMermaid id={mermaidId} className="mermaid">
{chart}
</StyledMermaid>
)
},
{
key: 'source',
label: t('mermaid.tabs.source'),
children: (
<pre
style={{
maxHeight: 'calc(80vh - 200px)',
overflowY: 'auto',
padding: '16px'
}}>
{chart}
</pre>
)
}
]}
/>
</Modal>
)
}
export default class MermaidPopup {
static topviewId = 0
static hide() {
TopView.hide('MermaidPopup')
}
static show(props: ShowParams) {
return new Promise<any>((resolve) => {
TopView.show(
<PopupContainer
{...props}
resolve={(v) => {
resolve(v)
this.hide()
}}
/>,
'MermaidPopup'
)
})
}
}
const StyledMermaid = styled.div`
max-height: calc(80vh - 200px);
text-align: center;
overflow-y: auto;
`

View File

@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import MessageContent from './MessageContent'
import MessageErrorBoundary from './MessageErrorBoundary'
import MessageHeader from './MessageHeader'
import MessageMenubar from './MessageMenubar'
import MessageTokens from './MessageTokens'
@ -29,6 +30,9 @@ interface Props {
onDeleteMessage?: (message: Message) => void
}
const getMessageBackground = (isBubbleStyle: boolean, isAssistantMessage: boolean) =>
isBubbleStyle ? (isAssistantMessage ? 'var(--chat-background-assistant)' : 'var(--chat-background-user)') : undefined
const MessageItem: FC<Props> = ({
message: _message,
topic,
@ -56,37 +60,33 @@ const MessageItem: FC<Props> = ({
}, [messageFont])
const messageBorder = showMessageDivider ? undefined : 'none'
const messageBackground = isBubbleStyle
? isAssistantMessage
? 'var(--chat-background-assistant)'
: 'var(--chat-background-user)'
: undefined
const messageBackground = getMessageBackground(isBubbleStyle, isAssistantMessage)
const onEditMessage = useCallback(
(msg: Message) => {
setMessage(msg)
const messages = onGetMessages?.().map((m) => (m.id === message.id ? message : m))
const messages = onGetMessages?.()?.map((m) => (m.id === message.id ? msg : m))
messages && onSetMessages?.(messages)
topic && db.topics.update(topic.id, { messages })
},
[message, onGetMessages, onSetMessages, topic]
[message.id, onGetMessages, onSetMessages, topic]
)
const messageHighlightHandler = (highlight: boolean = true) => {
if (messageContainerRef.current) {
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
if (highlight) {
setTimeout(() => {
const classList = messageContainerRef.current?.classList
classList?.add('message-highlight')
setTimeout(() => classList?.remove('message-highlight'), 2500)
}, 500)
}
}
}
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, (highlight: boolean = true) => {
if (messageContainerRef.current) {
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
if (highlight) {
setTimeout(() => {
const classList = messageContainerRef.current?.classList
classList?.add('message-highlight')
setTimeout(() => classList?.remove('message-highlight'), 2500)
}, 500)
}
}
})
]
const unsubscribes = [EventEmitter.on(EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id, messageHighlightHandler)]
return () => unsubscribes.forEach((unsub) => unsub())
}, [message])
@ -104,11 +104,16 @@ const MessageItem: FC<Props> = ({
useEffect(() => {
if (topic && onGetMessages && onSetMessages) {
if (message.status === 'sending' && index === 0) {
if (message.status === 'sending') {
const messages = onGetMessages()
fetchChatCompletion({
message,
messages: messages.filter((m) => !m.status.includes('ing')),
messages: messages
.filter((m) => !m.status.includes('ing'))
.slice(
0,
messages.findIndex((m) => m.id === message.id)
),
assistant,
topic,
onResponse: (msg) => {
@ -123,7 +128,7 @@ const MessageItem: FC<Props> = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [message.status])
if (hidePresetMessages && message.isPreset) {
return null
@ -147,11 +152,13 @@ const MessageItem: FC<Props> = ({
})}
ref={messageContainerRef}
style={isBubbleStyle ? { alignItems: isAssistantMessage ? 'start' : 'end' } : undefined}>
<MessageHeader message={message} assistant={assistant} model={model} />
<MessageHeader message={message} assistant={assistant} model={model} key={message.modelId} />
<MessageContentContainer
className="message-content-container"
style={{ fontFamily, fontSize, background: messageBackground }}>
<MessageContent message={message} model={model} />
<MessageErrorBoundary>
<MessageContent message={message} model={model} />
</MessageErrorBoundary>
{showMenubar && (
<MessageFooter
style={{
@ -161,6 +168,7 @@ const MessageItem: FC<Props> = ({
<MessageTokens message={message} isLastMessage={isLastMessage} />
<MessageMenubar
message={message}
assistantModel={assistant?.model}
model={model}
index={index}
isLastMessage={isLastMessage}
@ -216,7 +224,7 @@ const MessageFooter = styled.div`
align-items: center;
padding: 2px 0;
margin-top: 2px;
border-top: 0.5px dashed var(--color-border);
border-top: 1px dotted var(--color-border);
gap: 20px;
`

View File

@ -15,7 +15,7 @@ const MessageAttachments: FC<Props> = ({ message }) => {
if (message?.files && message.files[0]?.type === FileTypes.IMAGE) {
return (
<Container>
<Container style={{ marginBottom: 8 }}>
{message.files?.map((image) => <Image src={FileManager.getFileUrl(image)} key={image.id} width="33%" />)}
</Container>
)

View File

@ -1,7 +1,10 @@
import { SyncOutlined } from '@ant-design/icons'
import { SyncOutlined, TranslationOutlined } from '@ant-design/icons'
import { Message, Model } from '@renderer/types'
import { getBriefInfo } from '@renderer/utils'
import { Divider } from 'antd'
import React from 'react'
import { useTranslation } from 'react-i18next'
import BeatLoader from 'react-spinners/BeatLoader'
import styled from 'styled-components'
import Markdown from '../Markdown/Markdown'
@ -12,6 +15,8 @@ const MessageContent: React.FC<{
message: Message
model?: Model
}> = ({ message, model }) => {
const { t } = useTranslation()
if (message.status === 'sending') {
return (
<MessageContentLoading>
@ -32,6 +37,18 @@ const MessageContent: React.FC<{
return (
<>
<Markdown message={message} />
{message.translatedContent && (
<>
<Divider style={{ margin: 0, marginBottom: 10 }}>
<TranslationOutlined />
</Divider>
{message.translatedContent === t('translate.processing') ? (
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginBottom: 15 }} />
) : (
<Markdown message={{ ...message, content: message.translatedContent }} />
)}
</>
)}
<MessageAttachments message={message} />
</>
)

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