Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89bdab58f7 | ||
|
|
d42ee59335 | ||
|
|
88e7ab211d | ||
|
|
5347bdfa83 | ||
|
|
c8711c5804 | ||
|
|
24cf3bb043 | ||
|
|
0531ecf3cf | ||
|
|
0cbfd26883 | ||
|
|
ee398489de | ||
|
|
71d7c2c738 | ||
|
|
b98f7298a2 | ||
|
|
de4f2599be | ||
|
|
93b32e8e21 | ||
|
|
e353d0f8ee | ||
|
|
dfd42fe9a6 | ||
|
|
a2dc325896 | ||
|
|
b131d320ea | ||
|
|
b88f4a869e | ||
|
|
461458e5ec |
3
.gitignore
vendored
@@ -45,3 +45,6 @@ out
|
||||
# ENV
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Local
|
||||
local
|
||||
|
||||
53
.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch
Normal file
@@ -0,0 +1,53 @@
|
||||
diff --git a/lib/check-signature.js b/lib/check-signature.js
|
||||
index 324568af71bcc4372c9f959131ecd24122848c86..677348e0a138ff608b2ac41f592d813b15ee4956 100644
|
||||
--- a/lib/check-signature.js
|
||||
+++ b/lib/check-signature.js
|
||||
@@ -41,16 +41,12 @@ const spawn_1 = require("./spawn");
|
||||
const debug_1 = __importDefault(require("debug"));
|
||||
const d = (0, debug_1.default)('electron-notarize');
|
||||
const codesignDisplay = (opts) => __awaiter(void 0, void 0, void 0, function* () {
|
||||
- const result = yield (0, spawn_1.spawn)('codesign', ['-dv', '-vvvv', '--deep', path.basename(opts.appPath)], {
|
||||
- cwd: path.dirname(opts.appPath),
|
||||
- });
|
||||
+ const result = yield (0, spawn_1.spawn)('codesign', ['-dv', '-vvvv', '--deep', opts.appPath]);
|
||||
return result;
|
||||
});
|
||||
const codesign = (opts) => __awaiter(void 0, void 0, void 0, function* () {
|
||||
d('attempting to check codesign of app:', opts.appPath);
|
||||
- const result = yield (0, spawn_1.spawn)('codesign', ['-vvv', '--deep', '--strict', path.basename(opts.appPath)], {
|
||||
- cwd: path.dirname(opts.appPath),
|
||||
- });
|
||||
+ const result = yield (0, spawn_1.spawn)('codesign', ['-vvv', '--deep', '--strict', opts.appPath]);
|
||||
return result;
|
||||
});
|
||||
function checkSignatures(opts) {
|
||||
diff --git a/lib/notarytool.js b/lib/notarytool.js
|
||||
index 1ab090efb2101fc8bee5553445e0349c54474421..a5ddfd922197449fc56078e4a7e9a2ee5d8d207d 100644
|
||||
--- a/lib/notarytool.js
|
||||
+++ b/lib/notarytool.js
|
||||
@@ -92,9 +92,7 @@ function notarizeAndWaitForNotaryTool(opts) {
|
||||
else {
|
||||
filePath = path.resolve(dir, `${path.parse(opts.appPath).name}.zip`);
|
||||
d('zipping application to:', filePath);
|
||||
- const zipResult = yield (0, spawn_1.spawn)('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', path.basename(opts.appPath), filePath], {
|
||||
- cwd: path.dirname(opts.appPath),
|
||||
- });
|
||||
+ const zipResult = yield (0, spawn_1.spawn)('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', opts.appPath, filePath]);
|
||||
if (zipResult.code !== 0) {
|
||||
throw new Error(`Failed to zip application, exited with code: ${zipResult.code}\n\n${zipResult.output}`);
|
||||
}
|
||||
diff --git a/lib/staple.js b/lib/staple.js
|
||||
index 47dbd85b2fc279d999b57f47fb8171e1cc674436..f8829e6ac54fcd630a730d12d75acc1591b953b6 100644
|
||||
--- a/lib/staple.js
|
||||
+++ b/lib/staple.js
|
||||
@@ -43,9 +43,7 @@ const d = (0, debug_1.default)('electron-notarize:staple');
|
||||
function stapleApp(opts) {
|
||||
return __awaiter(this, void 0, void 0, function* () {
|
||||
d('attempting to staple app:', opts.appPath);
|
||||
- const result = yield (0, spawn_1.spawn)('xcrun', ['stapler', 'staple', '-v', path.basename(opts.appPath)], {
|
||||
- cwd: path.dirname(opts.appPath),
|
||||
- });
|
||||
+ const result = yield (0, spawn_1.spawn)('xcrun', ['stapler', 'staple', '-v', opts.appPath]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Failed to staple your application with code: ${result.code}\n\n${result.output}`);
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
nodeLinker: node-modules
|
||||
enableImmutableInstalls: false
|
||||
|
||||
httpTimeout: 300000
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
@@ -3,12 +3,13 @@ productName: Cherry Studio
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!{.vscode,.yarn,.github}'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!src/*'
|
||||
- '!local'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
@@ -56,4 +57,5 @@ electronDownload:
|
||||
afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
新增AI小程序模块
|
||||
增加应用备份和恢复功能
|
||||
增加更多AI小程序
|
||||
|
||||
@@ -4,18 +4,15 @@ import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
ollama: resolve('ollama/src')
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/preload/index.ts'),
|
||||
minapp: resolve(__dirname, 'src/preload/minapp.ts')
|
||||
}
|
||||
}
|
||||
}
|
||||
plugins: [externalizeDepsPlugin()]
|
||||
},
|
||||
renderer: {
|
||||
resolve: {
|
||||
|
||||
22
package.json
@@ -1,10 +1,16 @@
|
||||
{
|
||||
"name": "cherry-studio",
|
||||
"version": "0.6.0",
|
||||
"name": "CherryStudio",
|
||||
"version": "0.6.2",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "kangfenmao@qq.com",
|
||||
"homepage": "https://github.com/kangfenmao/cherry-studio",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"local"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
|
||||
@@ -25,12 +31,10 @@
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.0",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@sentry/electron": "^5.2.0",
|
||||
"electron-log": "^5.1.5",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.1.7",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1"
|
||||
"electron-window-state": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.24.3",
|
||||
@@ -51,15 +55,15 @@
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"electron": "^28.2.0",
|
||||
"electron": "^28.3.3",
|
||||
"electron-builder": "^24.9.1",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-vite": "^2.0.0",
|
||||
"emittery": "^1.0.3",
|
||||
"emoji-picker-element": "^1.22.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react": "^7.34.3",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.0.0",
|
||||
"gpt-tokens": "^1.3.6",
|
||||
"i18next": "^23.11.5",
|
||||
@@ -76,6 +80,7 @@
|
||||
"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.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
@@ -91,7 +96,8 @@
|
||||
"react-dom": "^17.0.0 || ^18.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"@electron/notarize": "2.3.2"
|
||||
"@electron/notarize": "2.3.2",
|
||||
"@electron/notarize@npm:2.2.1": "patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch"
|
||||
},
|
||||
"packageManager": "yarn@4.3.1"
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MinApp</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
header {
|
||||
height: 40px;
|
||||
background-color: #303030;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
#header-left {
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
}
|
||||
#header-center {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
#header-right {
|
||||
margin-left: auto;
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
border-radius: 3px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div id="header-left"></div>
|
||||
<div id="header-center"></div>
|
||||
<div id="header-right"></div>
|
||||
</header>
|
||||
<script type="module">
|
||||
import { getQueryParam } from './js/utils.js'
|
||||
const title = getQueryParam('title')
|
||||
document.getElementById('header-center').innerHTML = title
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,24 +0,0 @@
|
||||
import { dialog, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { writeFile } from 'fs'
|
||||
|
||||
export async function saveFile(_: Electron.IpcMainInvokeEvent, fileName: string, content: string): Promise<void> {
|
||||
try {
|
||||
const options: SaveDialogOptions = {
|
||||
title: '保存文件',
|
||||
defaultPath: fileName
|
||||
}
|
||||
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog(options)
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
writeFile(result.filePath, content, { encoding: 'utf-8' }, (err) => {
|
||||
if (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { electronApp, optimizer } from '@electron-toolkit/utils'
|
||||
import * as Sentry from '@sentry/electron/main'
|
||||
import { app, BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
|
||||
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import { saveFile } from './event'
|
||||
import AppUpdater from './updater'
|
||||
import { createMainWindow, createMinappWindow } from './window'
|
||||
import { registerIpc } from './ipc'
|
||||
import { updateUserDataPath } from './utils/upgrade'
|
||||
import { createMainWindow } from './window'
|
||||
|
||||
// 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(() => {
|
||||
app.whenReady().then(async () => {
|
||||
await updateUserDataPath()
|
||||
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.kangfenmao.CherryStudio')
|
||||
|
||||
@@ -30,45 +29,7 @@ app.whenReady().then(() => {
|
||||
|
||||
const mainWindow = createMainWindow()
|
||||
|
||||
const { autoUpdater } = new AppUpdater(mainWindow)
|
||||
|
||||
// IPC
|
||||
ipcMain.handle('get-app-info', () => ({
|
||||
version: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
appPath: app.getAppPath()
|
||||
}))
|
||||
|
||||
ipcMain.handle('open-website', (_, url: string) => {
|
||||
shell.openExternal(url)
|
||||
})
|
||||
|
||||
ipcMain.handle('set-proxy', (_, proxy: string) => {
|
||||
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
|
||||
})
|
||||
|
||||
ipcMain.handle('save-file', saveFile)
|
||||
|
||||
ipcMain.handle('minapp', (_, args) => {
|
||||
createMinappWindow(args)
|
||||
})
|
||||
|
||||
ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => {
|
||||
appConfig.set('theme', theme)
|
||||
mainWindow?.setTitleBarOverlay &&
|
||||
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
})
|
||||
|
||||
// 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法)
|
||||
ipcMain.handle('check-for-update', async () => {
|
||||
autoUpdater.logger?.info('触发检查更新')
|
||||
return {
|
||||
currentVersion: autoUpdater.currentVersion,
|
||||
update: await autoUpdater.checkForUpdates()
|
||||
}
|
||||
})
|
||||
|
||||
installExtension(REDUX_DEVTOOLS)
|
||||
registerIpc(mainWindow, app)
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
@@ -82,6 +43,3 @@ app.on('window-all-closed', () => {
|
||||
|
||||
// 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.
|
||||
Sentry.init({
|
||||
dsn: 'https://f0e972deff79c2df3e887e232d8a46a3@o4507610668007424.ingest.us.sentry.io/4507610670563328'
|
||||
})
|
||||
|
||||
58
src/main/ipc.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { BrowserWindow, ipcMain, session, shell } from 'electron'
|
||||
|
||||
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './updater'
|
||||
import { openFile, saveFile } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
import { createMinappWindow } from './window'
|
||||
|
||||
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
const { autoUpdater } = new AppUpdater(mainWindow)
|
||||
|
||||
// IPC
|
||||
ipcMain.handle('get-app-info', () => ({
|
||||
version: app.getVersion(),
|
||||
isPackaged: app.isPackaged,
|
||||
appPath: app.getAppPath()
|
||||
}))
|
||||
|
||||
ipcMain.handle('open-website', (_, url: string) => {
|
||||
shell.openExternal(url)
|
||||
})
|
||||
|
||||
ipcMain.handle('set-proxy', (_, proxy: string) => {
|
||||
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
|
||||
})
|
||||
|
||||
ipcMain.handle('save-file', saveFile)
|
||||
ipcMain.handle('open-file', openFile)
|
||||
ipcMain.handle('reload', () => mainWindow.reload())
|
||||
|
||||
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
|
||||
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
|
||||
|
||||
ipcMain.handle('minapp', (_, args) => {
|
||||
createMinappWindow({
|
||||
url: args.url,
|
||||
parent: mainWindow,
|
||||
windowOptions: {
|
||||
...mainWindow.getBounds(),
|
||||
...args.windowOptions
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => {
|
||||
appConfig.set('theme', theme)
|
||||
mainWindow?.setTitleBarOverlay &&
|
||||
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
|
||||
})
|
||||
|
||||
// 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法)
|
||||
ipcMain.handle('check-for-update', async () => {
|
||||
return {
|
||||
currentVersion: autoUpdater.currentVersion,
|
||||
update: await autoUpdater.checkForUpdates()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -17,11 +17,6 @@ export default class AppUpdater {
|
||||
mainWindow.webContents.send('update-error', error)
|
||||
})
|
||||
|
||||
// 检测是否需要更新
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
logger.info('正在检查更新……')
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
|
||||
autoUpdater.logger?.info('检测到新版本,确认是否下载')
|
||||
mainWindow.webContents.send('update-available', releaseInfo)
|
||||
@@ -59,7 +54,6 @@ export default class AppUpdater {
|
||||
|
||||
// 检测到不需要更新时
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
logger.info('现在使用的就是最新版本,不用更新')
|
||||
mainWindow.webContents.send('update-not-available')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 将 JavaScript 对象转换为 URL 查询参数字符串
|
||||
* @param obj - 要转换的对象
|
||||
* @param options - 配置选项
|
||||
* @returns 转换后的查询参数字符串
|
||||
*/
|
||||
export function objectToQueryParams(
|
||||
obj: Record<string, string | number | boolean | null | undefined | object>,
|
||||
options: {
|
||||
skipNull?: boolean
|
||||
skipUndefined?: boolean
|
||||
} = {}
|
||||
): string {
|
||||
const { skipNull = false, skipUndefined = false } = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (skipNull && value === null) continue
|
||||
if (skipUndefined && value === undefined) continue
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => params.append(key, String(item)))
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
params.append(key, JSON.stringify(value))
|
||||
} else if (value !== undefined && value !== null) {
|
||||
params.append(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
24
src/main/utils/aes.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// 定义密钥和初始化向量(IV)
|
||||
const secretKey = 'kDQvWz5slot3syfucoo53X6KKsEUJoeFikpiUWRJTLIo3zcUPpFvEa009kK13KCr'
|
||||
const iv = Buffer.from('Cherry Studio', 'hex')
|
||||
|
||||
// 加密函数
|
||||
export function encrypt(text: string): { iv: string; encryptedData: string } {
|
||||
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(secretKey), iv)
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return {
|
||||
iv: iv.toString('hex'),
|
||||
encryptedData: encrypted
|
||||
}
|
||||
}
|
||||
|
||||
// 解密函数
|
||||
export function decrypt(encryptedData: string, iv: string): string {
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(secretKey), Buffer.from(iv, 'hex'))
|
||||
let decrypted = decipher.update(encryptedData, 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
55
src/main/utils/file.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { writeFile } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
export async function saveFile(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
defaultPath: fileName,
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
writeFile(result.filePath, content, { encoding: 'utf-8' }, (err) => {
|
||||
if (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFile(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options: OpenDialogOptions
|
||||
): Promise<{ fileName: string; content: Buffer } | null> {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '打开文件',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, content }
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
77
src/main/utils/upgrade.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { spawn } from 'child_process'
|
||||
import { app, dialog } from 'electron'
|
||||
import Logger from 'electron-log'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export async function updateUserDataPath() {
|
||||
const currentPath = app.getPath('userData')
|
||||
const oldPath = currentPath.replace('CherryStudio', 'cherry-studio')
|
||||
|
||||
if (currentPath !== oldPath && fs.existsSync(oldPath)) {
|
||||
Logger.log('Update userData path')
|
||||
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows 系统:创建 bat 文件
|
||||
const batPath = await createWindowsBatFile(oldPath, currentPath)
|
||||
await promptRestartAndExecute(batPath)
|
||||
} else {
|
||||
// 其他系统:直接更新
|
||||
fs.rmSync(currentPath, { recursive: true, force: true })
|
||||
fs.renameSync(oldPath, currentPath)
|
||||
Logger.log(`Directory renamed: ${currentPath}`)
|
||||
await promptRestart()
|
||||
}
|
||||
} catch (error: any) {
|
||||
Logger.error('Error updating userData path:', error)
|
||||
dialog.showErrorBox('错误', `更新用户数据目录时发生错误: ${error.message}`)
|
||||
}
|
||||
} else {
|
||||
Logger.log('userData path does not need to be updated')
|
||||
}
|
||||
}
|
||||
|
||||
async function createWindowsBatFile(oldPath: string, currentPath: string): Promise<string> {
|
||||
const batPath = path.join(app.getPath('temp'), 'rename_userdata.bat')
|
||||
const appPath = app.getPath('exe')
|
||||
const batContent = `
|
||||
@echo off
|
||||
timeout /t 2 /nobreak
|
||||
rmdir /s /q "${currentPath}"
|
||||
rename "${oldPath}" "${path.basename(currentPath)}"
|
||||
start "" "${appPath}"
|
||||
del "%~f0"
|
||||
`
|
||||
fs.writeFileSync(batPath, batContent)
|
||||
return batPath
|
||||
}
|
||||
|
||||
async function promptRestartAndExecute(batPath: string) {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: '应用需要重启',
|
||||
message: '用户数据目录将在重启后更新。请重启应用以应用更改。',
|
||||
buttons: ['手动重启']
|
||||
})
|
||||
|
||||
// 执行 bat 文件
|
||||
spawn('cmd.exe', ['/c', batPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
async function promptRestart() {
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: '应用需要重启',
|
||||
message: '用户数据目录已更新。请重启应用以应用更改。',
|
||||
buttons: ['重启']
|
||||
})
|
||||
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
}
|
||||
39
src/main/utils/zip.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import util from 'node:util'
|
||||
import zlib from 'node:zlib'
|
||||
|
||||
import logger from 'electron-log'
|
||||
|
||||
// 将 zlib 的 gzip 和 gunzip 方法转换为 Promise 版本
|
||||
const gzipPromise = util.promisify(zlib.gzip)
|
||||
const gunzipPromise = util.promisify(zlib.gunzip)
|
||||
|
||||
/**
|
||||
* 压缩字符串
|
||||
* @param {string} string - 要压缩的 JSON 字符串
|
||||
* @returns {Promise<Buffer>} 压缩后的 Buffer
|
||||
*/
|
||||
export async function compress(str) {
|
||||
try {
|
||||
const buffer = Buffer.from(str, 'utf-8')
|
||||
const compressedBuffer = await gzipPromise(buffer)
|
||||
return compressedBuffer
|
||||
} catch (error) {
|
||||
logger.error('Compression failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压缩 Buffer 到 JSON 字符串
|
||||
* @param {Buffer} compressedBuffer - 压缩的 Buffer
|
||||
* @returns {Promise<string>} 解压缩后的 JSON 字符串
|
||||
*/
|
||||
export async function decompress(compressedBuffer) {
|
||||
try {
|
||||
const buffer = await gunzipPromise(compressedBuffer)
|
||||
return buffer.toString('utf-8')
|
||||
} catch (error) {
|
||||
logger.error('Decompression failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { app, BrowserView, BrowserWindow, Menu, MenuItem, shell } from 'electron'
|
||||
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 { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import { objectToQueryParams } from './utils'
|
||||
|
||||
export function createMainWindow() {
|
||||
// Load the previous state with fallback to defaults
|
||||
@@ -62,14 +61,7 @@ export function createMainWindow() {
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
const websiteReg = /accounts.google.com/i
|
||||
|
||||
if (websiteReg.test(details.url)) {
|
||||
createMinappWindow({ url: details.url, windowOptions: { width: 1000, height: 680 } })
|
||||
} else {
|
||||
shell.openExternal(details.url)
|
||||
}
|
||||
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
@@ -102,50 +94,31 @@ export function createMainWindow() {
|
||||
|
||||
export function createMinappWindow({
|
||||
url,
|
||||
parent,
|
||||
windowOptions
|
||||
}: {
|
||||
url: string
|
||||
parent?: BrowserWindow
|
||||
windowOptions?: Electron.BrowserWindowConstructorOptions
|
||||
}) {
|
||||
const width = 1000
|
||||
const height = 680
|
||||
const headerHeight = 40
|
||||
const width = windowOptions?.width || 1000
|
||||
const height = windowOptions?.height || 680
|
||||
|
||||
const minappWindow = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
autoHideMenuBar: true,
|
||||
alwaysOnTop: true,
|
||||
titleBarOverlay: titleBarOverlayDark,
|
||||
titleBarStyle: 'hidden',
|
||||
title: 'Cherry Studio',
|
||||
...windowOptions,
|
||||
parent,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/minapp.js'),
|
||||
sandbox: false
|
||||
sandbox: false,
|
||||
contextIsolation: false
|
||||
}
|
||||
})
|
||||
|
||||
const view = new BrowserView()
|
||||
view.setBounds({ x: 0, y: headerHeight, width, height: height - headerHeight })
|
||||
view.webContents.loadURL(url)
|
||||
|
||||
const minappWindowParams = {
|
||||
title: windowOptions?.title || 'CherryStudio'
|
||||
}
|
||||
|
||||
const appPath = app.getAppPath()
|
||||
const minappHtmlPath = appPath + '/resources/minapp.html'
|
||||
|
||||
minappWindow.loadURL('file://' + minappHtmlPath + '?' + objectToQueryParams(minappWindowParams))
|
||||
minappWindow.setBrowserView(view)
|
||||
minappWindow.on('resize', () => {
|
||||
view.setBounds({
|
||||
x: 0,
|
||||
y: headerHeight,
|
||||
width: minappWindow.getBounds().width,
|
||||
height: minappWindow.getBounds().height - headerHeight
|
||||
})
|
||||
})
|
||||
minappWindow.loadURL(url)
|
||||
|
||||
return minappWindow
|
||||
}
|
||||
|
||||
9
src/preload/index.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { OpenDialogOptions } from 'electron'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -12,9 +13,13 @@ declare global {
|
||||
checkForUpdate: () => void
|
||||
openWebsite: (url: string) => void
|
||||
setProxy: (proxy: string | undefined) => void
|
||||
saveFile: (path: string, content: string) => void
|
||||
saveFile: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
|
||||
openFile: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null>
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
minApp: (url: string) => void
|
||||
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
|
||||
reload: () => void
|
||||
compress: (text: string) => Promise<Buffer>
|
||||
decompress: (text: Buffer) => Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,15 @@ const api = {
|
||||
checkForUpdate: () => ipcRenderer.invoke('check-for-update'),
|
||||
openWebsite: (url: string) => ipcRenderer.invoke('open-website', url),
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy),
|
||||
saveFile: (path: string, content: string) => ipcRenderer.invoke('save-file', path, content),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url)
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||
openFile: (options?: { decompress: boolean }) => ipcRenderer.invoke('open-file', options),
|
||||
reload: () => ipcRenderer.invoke('reload'),
|
||||
saveFile: (path: string, content: string, options?: { compress: boolean }) => {
|
||||
ipcRenderer.invoke('save-file', path, content, options)
|
||||
},
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
|
||||
}
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { contextBridge } from 'electron'
|
||||
|
||||
const api = {}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Cherry Studio</title>
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/ai-search.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src/renderer/src/assets/images/apps/devv.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src/renderer/src/assets/images/apps/metaso.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src/renderer/src/assets/images/apps/sensetime.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src/renderer/src/assets/images/apps/sparkdesk.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/renderer/src/assets/images/apps/tiangong.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src/renderer/src/assets/images/apps/yuanbao.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@@ -25,24 +25,30 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
() => ({
|
||||
id: defaultAssistant.id,
|
||||
name: defaultAssistant.name,
|
||||
emoji: '',
|
||||
emoji: defaultAssistant.emoji || '',
|
||||
prompt: defaultAssistant.prompt,
|
||||
group: 'system'
|
||||
}),
|
||||
[defaultAssistant.id, defaultAssistant.name, defaultAssistant.prompt]
|
||||
[defaultAssistant.emoji, defaultAssistant.id, defaultAssistant.name, defaultAssistant.prompt]
|
||||
)
|
||||
|
||||
const agents = useMemo(() => {
|
||||
const allAgents = [defaultAgent, ...userAgents, ...systemAgents] as Agent[]
|
||||
const list = allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))
|
||||
const allAgents = [...userAgents, ...systemAgents] as Agent[]
|
||||
const list = [defaultAgent, ...allAgents.filter((agent) => !assistants.map((a) => a.id).includes(agent.id))]
|
||||
return searchText
|
||||
? list.filter((agent) => agent.name.toLowerCase().includes(searchText.trim().toLocaleLowerCase()))
|
||||
: list
|
||||
}, [assistants, defaultAgent, searchText, userAgents])
|
||||
|
||||
const onCreateAssistant = (agent: Agent) => {
|
||||
if (assistants.map((a) => a.id).includes(String(agent.id))) return
|
||||
if (agent.id !== 'default') {
|
||||
if (assistants.map((a) => a.id).includes(String(agent.id))) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const assistant = covertAgentToAssistant(agent)
|
||||
|
||||
addAssistant(assistant)
|
||||
resolve(assistant)
|
||||
setOpen(false)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TranslationOutlined } from '@ant-design/icons'
|
||||
import Logo from '@renderer/assets/images/logo.png'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import { AppLogo, isLocalAi } from '@renderer/config/env'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useRuntime } from '@renderer/hooks/useStore'
|
||||
import { Avatar } from 'antd'
|
||||
@@ -25,7 +25,7 @@ const Sidebar: FC = () => {
|
||||
|
||||
return (
|
||||
<Container style={{ backgroundColor: minappShow ? 'var(--navbar-background)' : sidebarBackgroundColor }}>
|
||||
<AvatarImg src={avatar || Logo} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||
<AvatarImg src={avatar || AppLogo} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||
<MainMenus>
|
||||
<Menus>
|
||||
<StyledLink to="/">
|
||||
@@ -51,7 +51,7 @@ const Sidebar: FC = () => {
|
||||
</Menus>
|
||||
</MainMenus>
|
||||
<Menus>
|
||||
<StyledLink to="/settings/provider">
|
||||
<StyledLink to={isLocalAi ? '/settings/assistant' : '/settings/provider'}>
|
||||
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
|
||||
<i className="iconfont icon-setting"></i>
|
||||
</Icon>
|
||||
|
||||
5
src/renderer/src/config/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as AppLogo } from '@renderer/assets/images/logo.png'
|
||||
|
||||
export const APP_NAME = 'Cherry Studio'
|
||||
|
||||
export const isLocalAi = false
|
||||
@@ -3,6 +3,7 @@ import { Model } from '@renderer/types'
|
||||
type SystemModel = Model & { enabled: boolean }
|
||||
|
||||
export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
|
||||
ollama: [],
|
||||
openai: [
|
||||
{
|
||||
id: 'gpt-4o',
|
||||
@@ -49,6 +50,36 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-3-5-sonnet-20240620',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
group: 'Claude 3.5',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'claude-3-opus-20240229',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Opus',
|
||||
group: 'Claude 3',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'claude-3-sonnet-20240229',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Sonnet',
|
||||
group: 'Claude 3',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'claude-3-haiku-20240307',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Haiku',
|
||||
group: 'Claude 3',
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
silicon: [
|
||||
{
|
||||
id: 'Qwen/Qwen2-7B-Instruct',
|
||||
@@ -465,35 +496,5 @@ export const SYSTEM_MODELS: Record<string, SystemModel[]> = {
|
||||
group: 'Gemma',
|
||||
enabled: false
|
||||
}
|
||||
],
|
||||
anthropic: [
|
||||
{
|
||||
id: 'claude-3-5-sonnet-20240620',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3.5 Sonnet',
|
||||
group: 'Claude 3.5',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'claude-3-opus-20240229',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Opus',
|
||||
group: 'Claude 3',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'claude-3-sonnet-20240229',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Sonnet',
|
||||
group: 'Claude 3',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'claude-3-haiku-20240307',
|
||||
provider: 'anthropic',
|
||||
name: 'Claude 3 Haiku',
|
||||
group: 'Claude 3',
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ export const PROVIDER_CONFIG = {
|
||||
},
|
||||
app: {
|
||||
name: 'Groq',
|
||||
url: 'https://groq.com/',
|
||||
url: 'https://chat.groq.com/',
|
||||
logo: GroqProviderLogo
|
||||
}
|
||||
},
|
||||
|
||||
8
src/renderer/src/env.d.ts
vendored
@@ -4,6 +4,14 @@ import type KeyvStorage from '@kangfenmao/keyv-storage'
|
||||
import { MessageInstance } from 'antd/es/message/interface'
|
||||
import { HookAPI } from 'antd/es/modal/useModal'
|
||||
|
||||
interface ImportMetaEnv {
|
||||
VITE_RENDERER_INTEGRATED_MODEL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
message: MessageInstance
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import i18n from '@renderer/i18n'
|
||||
import LocalStorage from '@renderer/services/storage'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@@ -5,12 +6,14 @@ import { setAvatar } from '@renderer/store/runtime'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useDefaultModel } from './useAssistant'
|
||||
import { useSettings } from './useSettings'
|
||||
|
||||
export function useAppInit() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { proxyUrl } = useSettings()
|
||||
const { language } = useSettings()
|
||||
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
|
||||
|
||||
useEffect(() => {
|
||||
runAsyncFunction(async () => {
|
||||
@@ -33,4 +36,14 @@ export function useAppInit() {
|
||||
useEffect(() => {
|
||||
i18n.changeLanguage(language || navigator.language || 'en-US')
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalAi) {
|
||||
const model = JSON.parse(import.meta.env.VITE_RENDERER_INTEGRATED_MODEL)
|
||||
setDefaultModel(model)
|
||||
setTopicNamingModel(model)
|
||||
setTranslateModel(model)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ const resources = {
|
||||
footnotes: 'References',
|
||||
select: 'Select',
|
||||
search: 'Search',
|
||||
default: 'Default'
|
||||
default: 'Default',
|
||||
warning: 'Warning'
|
||||
},
|
||||
button: {
|
||||
add: 'Add',
|
||||
@@ -48,11 +49,15 @@ const resources = {
|
||||
'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'
|
||||
'switch.disabled': 'Switching is disabled while the assistant is generating',
|
||||
'restore.success': 'Restored successfully',
|
||||
'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?'
|
||||
},
|
||||
chat: {
|
||||
save: 'Save',
|
||||
'default.name': '🔆 Default Assistant',
|
||||
'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',
|
||||
@@ -141,6 +146,9 @@ const resources = {
|
||||
'general.title': 'General Settings',
|
||||
'general.user_name': 'User Name',
|
||||
'general.user_name.placeholder': 'Enter your name',
|
||||
'general.backup.title': 'Data Backup and Recovery',
|
||||
'general.reset.title': 'Data Reset',
|
||||
'general.reset.button': 'Reset',
|
||||
'provider.api_key': 'API Key',
|
||||
'provider.check': 'Check',
|
||||
'provider.get_api_key': 'Get API Key',
|
||||
@@ -219,8 +227,12 @@ const resources = {
|
||||
'keep_alive_time.placeholder': 'Minutes',
|
||||
'keep_alive_time.description': 'The time in minutes to keep the connection alive, default is 5 minutes.'
|
||||
},
|
||||
minapp: {
|
||||
title: 'MinApp'
|
||||
},
|
||||
error: {
|
||||
'chat.response': 'Something went wrong. Please check if you have set your API key in the Settings > Providers'
|
||||
'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'
|
||||
},
|
||||
words: {
|
||||
knowledgeGraph: 'Knowledge Graph',
|
||||
@@ -253,7 +265,8 @@ const resources = {
|
||||
footnote: '引用内容',
|
||||
select: '选择',
|
||||
search: '搜索',
|
||||
default: '默认'
|
||||
default: '默认',
|
||||
warning: '警告'
|
||||
},
|
||||
button: {
|
||||
add: '添加',
|
||||
@@ -273,11 +286,15 @@ const resources = {
|
||||
'api.connection.failed': '连接失败',
|
||||
'api.connection.success': '连接成功',
|
||||
'chat.completion.paused': '会话已停止',
|
||||
'switch.disabled': '模型回复完成后才能切换'
|
||||
'switch.disabled': '模型回复完成后才能切换',
|
||||
'restore.success': '恢复成功',
|
||||
'reset.confirm.content': '确定要重置所有数据吗?',
|
||||
'reset.double.confirm.title': '数据丢失!!!',
|
||||
'reset.double.confirm.content': '你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?'
|
||||
},
|
||||
chat: {
|
||||
save: '保存',
|
||||
'default.name': '🔆 默认助手 - Assistant',
|
||||
'default.name': '默认助手',
|
||||
'default.description': '你好,我是默认助手。你可以立刻开始跟我聊天。',
|
||||
'default.topic.name': '默认话题',
|
||||
'topics.title': '话题',
|
||||
@@ -367,6 +384,9 @@ const resources = {
|
||||
'general.title': '常规设置',
|
||||
'general.user_name': '用户名',
|
||||
'general.user_name.placeholder': '请输入用户名',
|
||||
'general.backup.title': '数据备份与恢复',
|
||||
'general.reset.title': '重置数据',
|
||||
'general.reset.button': '重置',
|
||||
'provider.api_key': 'API 密钥',
|
||||
'provider.check': '检查',
|
||||
'provider.get_api_key': '点击这里获取密钥',
|
||||
@@ -445,8 +465,12 @@ const resources = {
|
||||
'keep_alive_time.placeholder': '分钟',
|
||||
'keep_alive_time.description': '对话后模型在内存中保持的时间(默认:5分钟)'
|
||||
},
|
||||
minapp: {
|
||||
title: '小程序'
|
||||
},
|
||||
error: {
|
||||
'chat.response': '出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥'
|
||||
'chat.response': '出错了,如果没有配置 API 密钥,请前往设置 > 模型提供商中配置密钥',
|
||||
'backup.file_format': '备份文件格式错误'
|
||||
},
|
||||
words: {
|
||||
knowledgeGraph: '知识图谱',
|
||||
|
||||
@@ -1,27 +1,9 @@
|
||||
import KeyvStorage from '@kangfenmao/keyv-storage'
|
||||
import * as Sentry from '@sentry/electron/renderer'
|
||||
import localforage from 'localforage'
|
||||
|
||||
import { APP_NAME } from './config/env'
|
||||
import { ThemeMode } from './store/settings'
|
||||
import { isProduction, loadScript } from './utils'
|
||||
|
||||
async function initSentry() {
|
||||
if (await isProduction()) {
|
||||
Sentry.init({
|
||||
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
// Capture Replay for 10% of all sessions,
|
||||
// plus for 100% of sessions with an error
|
||||
replaysSessionSampleRate: 0.1,
|
||||
replaysOnErrorSampleRate: 1.0
|
||||
})
|
||||
}
|
||||
}
|
||||
import { loadScript } from './utils'
|
||||
|
||||
export async function initMermaid(theme: ThemeMode) {
|
||||
if (!window.mermaid) {
|
||||
@@ -41,13 +23,11 @@ function init() {
|
||||
name: 'CherryAI',
|
||||
version: 1.0,
|
||||
storeName: 'cherryai',
|
||||
description: 'Cherry Studio Storage'
|
||||
description: `${APP_NAME} Storage`
|
||||
})
|
||||
|
||||
window.keyv = new KeyvStorage()
|
||||
window.keyv.init()
|
||||
|
||||
initSentry()
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
@@ -12,12 +12,19 @@ const App: FC<Props> = ({ app }) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const onClick = () => {
|
||||
const websiteReg = /claude|chatgpt|groq/i
|
||||
|
||||
if (websiteReg.test(app.url)) {
|
||||
window.api.minApp({ url: app.url, windowOptions: { title: app.name } })
|
||||
return
|
||||
}
|
||||
|
||||
MinApp.start(app)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container onClick={onClick}>
|
||||
<AppIcon src={app.logo} style={{ border: theme === 'dark' ? 'none' : '1px solid var(--color-border' }} />
|
||||
<AppIcon src={app.logo} style={{ border: theme === 'dark' ? 'none' : '0.5px solid var(--color-border' }} />
|
||||
<AppTitle>{app.name}</AppTitle>
|
||||
</Container>
|
||||
)
|
||||
@@ -29,6 +36,7 @@ const Container = styled.div`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
width: 65px;
|
||||
`
|
||||
|
||||
const AppIcon = styled.img`
|
||||
@@ -44,6 +52,7 @@ const AppTitle = styled.div`
|
||||
margin-top: 5px;
|
||||
color: var(--color-text-soft);
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
export default App
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import AiAssistantAppLogo from '@renderer/assets/images/apps/360-ai.png'
|
||||
import AiSearchAppLogo from '@renderer/assets/images/apps/ai-search.png'
|
||||
import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png'
|
||||
import DevvAppLogo from '@renderer/assets/images/apps/devv.png'
|
||||
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp'
|
||||
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png'
|
||||
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png'
|
||||
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png'
|
||||
import TencentYuanbaoAppLogo from '@renderer/assets/images/apps/yuanbao.png'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { Center } from '@renderer/components/Layout'
|
||||
import { PROVIDER_CONFIG } from '@renderer/config/provider'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { FC } from 'react'
|
||||
import { Empty, Input } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@@ -19,27 +30,87 @@ const _apps: MinAppType[] = [
|
||||
name: '文心一言',
|
||||
logo: BaiduAiAppLogo,
|
||||
url: 'https://yiyan.baidu.com/'
|
||||
},
|
||||
{
|
||||
name: 'SparkDesk',
|
||||
logo: SparkDeskAppLogo,
|
||||
url: 'https://xinghuo.xfyun.cn/desk'
|
||||
},
|
||||
{
|
||||
name: '腾讯元宝',
|
||||
logo: TencentYuanbaoAppLogo,
|
||||
url: 'https://yuanbao.tencent.com/chat'
|
||||
},
|
||||
{
|
||||
name: '商量',
|
||||
logo: SensetimeAppLogo,
|
||||
url: 'https://chat.sensetime.com/wb/chat'
|
||||
},
|
||||
{
|
||||
name: '360AI搜索',
|
||||
logo: AiSearchAppLogo,
|
||||
url: 'https://so.360.com/'
|
||||
},
|
||||
{
|
||||
name: '秘塔AI搜索',
|
||||
logo: MetasoAppLogo,
|
||||
url: 'https://metaso.cn/'
|
||||
},
|
||||
{
|
||||
name: '天工AI',
|
||||
logo: TiangongAiLogo,
|
||||
url: 'https://www.tiangong.cn/'
|
||||
},
|
||||
{
|
||||
name: 'DEVV_',
|
||||
logo: DevvAppLogo,
|
||||
url: 'https://devv.ai/referral?code=dvl5am34asqo'
|
||||
}
|
||||
]
|
||||
|
||||
const AppsPage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const apps: MinAppType[] = (Object.entries(PROVIDER_CONFIG) as any[])
|
||||
const list: MinAppType[] = (Object.entries(PROVIDER_CONFIG) as any[])
|
||||
.filter(([, config]) => config.app)
|
||||
.map(([key, config]) => ({ id: key, ...config.app }))
|
||||
.concat(_apps)
|
||||
|
||||
const apps = search
|
||||
? list.filter(
|
||||
(app) => app.name.toLowerCase().includes(search.toLowerCase()) || app.url.includes(search.toLowerCase())
|
||||
)
|
||||
: list
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
<NavbarCenter style={{ borderRight: 'none' }}>{t('agents.title')}</NavbarCenter>
|
||||
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'space-between' }}>
|
||||
{t('minapp.title')}
|
||||
<Input
|
||||
placeholder={t('common.search')}
|
||||
className="nodrag"
|
||||
style={{ width: '30%', height: 28 }}
|
||||
size="small"
|
||||
variant="filled"
|
||||
suffix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div style={{ width: 80 }} />
|
||||
</NavbarCenter>
|
||||
</Navbar>
|
||||
<ContentContainer>
|
||||
<AppsContainer>
|
||||
{apps.map((app) => (
|
||||
<App key={app.name} app={app} />
|
||||
))}
|
||||
{isEmpty(apps) && (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Empty />
|
||||
</Center>
|
||||
)}
|
||||
</AppsContainer>
|
||||
</ContentContainer>
|
||||
</Container>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { Assistant } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
@@ -17,6 +18,10 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
|
||||
const { model, setModel } = useAssistant(assistant.id)
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (isLocalAi) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectModelDropdown model={model} onSelect={setModel}>
|
||||
<DropdownButton size="small" type="default">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { GithubOutlined } from '@ant-design/icons'
|
||||
import { FileProtectOutlined, GlobalOutlined, MailOutlined, SoundOutlined } from '@ant-design/icons'
|
||||
import Logo from '@renderer/assets/images/logo.png'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { APP_NAME, AppLogo } from '@renderer/config/env'
|
||||
import { runAsyncFunction } from '@renderer/utils'
|
||||
import { Avatar, Button, Progress, Row, Tag } from 'antd'
|
||||
import { ProgressInfo } from 'electron-updater'
|
||||
@@ -43,7 +44,7 @@ const AboutSettings: FC = () => {
|
||||
|
||||
const mailto = async () => {
|
||||
const email = 'kangfenmao@qq.com'
|
||||
const subject = 'Cherry Studio Feedback'
|
||||
const subject = `${APP_NAME} Feedback`
|
||||
const version = (await window.api.getAppInfo()).version
|
||||
const platform = window.electron.process.platform
|
||||
const url = `mailto:${email}?subject=${subject}&body=%0A%0AVersion: ${version} | Platform: ${platform}`
|
||||
@@ -116,10 +117,10 @@ const AboutSettings: FC = () => {
|
||||
strokeColor="#67ad5b"
|
||||
/>
|
||||
)}
|
||||
<Avatar src={Logo} size={80} style={{ minHeight: 80 }} />
|
||||
<Avatar src={AppLogo} size={80} style={{ minHeight: 80 }} />
|
||||
</AvatarWrapper>
|
||||
<VersionWrapper>
|
||||
<Title>Cherry Studio</Title>
|
||||
<Title>{APP_NAME}</Title>
|
||||
<Description>{t('settings.about.description')}</Description>
|
||||
<Tag
|
||||
onClick={() => onOpenWebsite('https://github.com/kangfenmao/cherry-studio/releases')}
|
||||
@@ -139,7 +140,14 @@ const AboutSettings: FC = () => {
|
||||
<SoundOutlined />
|
||||
{t('settings.about.releases.title')}
|
||||
</SettingRowTitle>
|
||||
<Button onClick={() => onOpenWebsite('https://github.com/kangfenmao/cherry-studio/releases')}>
|
||||
<Button
|
||||
onClick={() =>
|
||||
MinApp.start({
|
||||
name: t('settings.about.releases.title'),
|
||||
url: 'https://github.com/kangfenmao/cherry-studio/releases',
|
||||
logo: ''
|
||||
})
|
||||
}>
|
||||
{t('settings.about.releases.button')}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { FolderOpenOutlined, SaveOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import { backup, reset, restore } from '@renderer/services/backup'
|
||||
import LocalStorage from '@renderer/services/storage'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { setAvatar } from '@renderer/store/runtime'
|
||||
import { setFontSize, setLanguage, setUserName, ThemeMode } from '@renderer/store/settings'
|
||||
import { setLanguage, setUserName, ThemeMode } from '@renderer/store/settings'
|
||||
import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings'
|
||||
import { compressImage, isValidProxyUrl } from '@renderer/utils'
|
||||
import { Avatar, Input, Select, Slider, Upload } from 'antd'
|
||||
import { Avatar, Button, Input, Select, Upload } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
@@ -16,8 +19,7 @@ import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingT
|
||||
|
||||
const GeneralSettings: FC = () => {
|
||||
const avatar = useAvatar()
|
||||
const { language, proxyUrl: storeProxyUrl, userName, theme, setTheme, fontSize } = useSettings()
|
||||
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
|
||||
const { language, proxyUrl: storeProxyUrl, userName, theme, setTheme } = useSettings()
|
||||
const [proxyUrl, setProxyUrl] = useState<string | undefined>(storeProxyUrl)
|
||||
const dispatch = useAppDispatch()
|
||||
const { t } = useTranslation()
|
||||
@@ -101,27 +103,6 @@ const GeneralSettings: FC = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.font_size.title')}</SettingRowTitle>
|
||||
<Slider
|
||||
style={{ width: 290 }}
|
||||
value={fontSizeValue}
|
||||
onChange={(value) => setFontSizeValue(value)}
|
||||
onChangeComplete={(value) => {
|
||||
dispatch(setFontSize(value))
|
||||
console.debug('set font size', value)
|
||||
}}
|
||||
min={12}
|
||||
max={18}
|
||||
step={1}
|
||||
marks={{
|
||||
12: <span style={{ fontSize: '12px' }}>A</span>,
|
||||
14: <span style={{ fontSize: '14px' }}>{t('common.default')}</span>,
|
||||
18: <span style={{ fontSize: '18px' }}>A</span>
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.proxy.title')}</SettingRowTitle>
|
||||
<Input
|
||||
@@ -134,6 +115,27 @@ const GeneralSettings: FC = () => {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.backup.title')}</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
<Button onClick={backup} icon={<SaveOutlined />}>
|
||||
备份
|
||||
</Button>
|
||||
<Button onClick={restore} icon={<FolderOpenOutlined />}>
|
||||
恢复
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
<SettingRow>
|
||||
<SettingRowTitle>{t('settings.general.reset.title')}</SettingRowTitle>
|
||||
<HStack gap="5px">
|
||||
<Button onClick={reset} danger>
|
||||
{t('settings.general.reset.button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</SettingRow>
|
||||
<SettingDivider />
|
||||
</SettingContainer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,11 +182,10 @@ const ProviderListItem = styled.div`
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
&:hover {
|
||||
background: var(--color-primary-mute);
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
background: var(--color-background-mute);
|
||||
font-weight: bold !important;
|
||||
}
|
||||
`
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, Route, Routes, useLocation } from 'react-router-dom'
|
||||
@@ -30,6 +31,8 @@ const SettingsPage: FC = () => {
|
||||
</Navbar>
|
||||
<ContentContainer>
|
||||
<SettingMenus>
|
||||
{!isLocalAi && (
|
||||
<>
|
||||
<MenuItemLink to="/settings/provider">
|
||||
<MenuItem className={isRoute('/settings/provider')}>
|
||||
<CloudOutlined />
|
||||
@@ -42,6 +45,8 @@ const SettingsPage: FC = () => {
|
||||
{t('settings.model')}
|
||||
</MenuItem>
|
||||
</MenuItemLink>
|
||||
</>
|
||||
)}
|
||||
<MenuItemLink to="/settings/assistant">
|
||||
<MenuItem className={isRoute('/settings/assistant')}>
|
||||
<MessageOutlined />
|
||||
@@ -118,11 +123,10 @@ const MenuItem = styled.li`
|
||||
opacity: 0.8;
|
||||
}
|
||||
&:hover {
|
||||
background: var(--color-primary-soft);
|
||||
background: var(--color-background-soft);
|
||||
}
|
||||
&.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-white);
|
||||
background: var(--color-background-mute);
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CheckOutlined, SendOutlined, SettingOutlined, SwapOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { useDefaultModel } from '@renderer/hooks/useAssistant'
|
||||
import { fetchTranslate } from '@renderer/services/api'
|
||||
import { getDefaultAssistant } from '@renderer/services/assistant'
|
||||
@@ -133,6 +134,31 @@ const TranslatePage: FC = () => {
|
||||
isEmpty(text) && setResult('')
|
||||
}, [text])
|
||||
|
||||
const SettingButton = () => {
|
||||
if (isLocalAi) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (translateModel) {
|
||||
return (
|
||||
<Link to="/settings/model" style={{ color: 'var(--color-text-2)' }}>
|
||||
<SettingOutlined />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to="/settings/model" style={{ marginLeft: -10 }}>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
|
||||
icon={<WarningOutlined />}>
|
||||
{t('translate.error.not_configured')}
|
||||
</Button>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar>
|
||||
@@ -165,21 +191,7 @@ const TranslatePage: FC = () => {
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
{translateModel && (
|
||||
<Link to="/settings/model" style={{ color: 'var(--color-text-2)' }}>
|
||||
<SettingOutlined />
|
||||
</Link>
|
||||
)}
|
||||
{!translateModel && (
|
||||
<Link to="/settings/model" style={{ marginLeft: -10 }}>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ color: 'var(--color-error)', textDecoration: 'underline' }}
|
||||
icon={<WarningOutlined />}>
|
||||
{t('translate.error.not_configured')}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<SettingButton />
|
||||
</MenuContainer>
|
||||
<TranslateInputWrapper>
|
||||
<InputContainer>
|
||||
|
||||
@@ -2,11 +2,12 @@ import Anthropic from '@anthropic-ai/sdk'
|
||||
import { MessageCreateParamsNonStreaming, MessageParam } from '@anthropic-ai/sdk/resources'
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||||
import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { getOllamaKeepAliveTime } from '@renderer/hooks/useOllama'
|
||||
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
|
||||
import { removeQuotes } from '@renderer/utils'
|
||||
import axios from 'axios'
|
||||
import { isEmpty, sum, takeRight } from 'lodash'
|
||||
import { first, isEmpty, sum, takeRight } from 'lodash'
|
||||
import OpenAI from 'openai'
|
||||
import { ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam } from 'openai/resources'
|
||||
|
||||
@@ -239,13 +240,13 @@ export default class ProviderSDK {
|
||||
// @ts-ignore key is not typed
|
||||
const response = await this.openaiSdk.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [systemMessage, ...userMessages] as ChatCompletionMessageParam[],
|
||||
messages: [systemMessage, ...(isLocalAi ? [first(userMessages)] : userMessages)] as ChatCompletionMessageParam[],
|
||||
stream: false,
|
||||
max_tokens: 50,
|
||||
keep_alive: this.keepAliveTime
|
||||
})
|
||||
|
||||
return removeQuotes(response.choices[0].message?.content || '')
|
||||
return removeQuotes(response.choices[0].message?.content?.substring(0, 50) || '')
|
||||
}
|
||||
|
||||
public async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> {
|
||||
|
||||
@@ -166,10 +166,12 @@ export async function checkApi(provider: Provider) {
|
||||
const key = 'api-check'
|
||||
const style = { marginTop: '3vh' }
|
||||
|
||||
if (provider.id !== 'ollama') {
|
||||
if (!provider.apiKey) {
|
||||
window.message.error({ content: i18n.t('message.error.enter.api.key'), key, style })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.apiHost) {
|
||||
window.message.error({ content: i18n.t('message.error.enter.api.host'), key, style })
|
||||
|
||||
74
src/renderer/src/services/backup.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import i18n from '@renderer/i18n'
|
||||
import dayjs from 'dayjs'
|
||||
import localforage from 'localforage'
|
||||
|
||||
export async function backup() {
|
||||
const indexedKeys = await localforage.keys()
|
||||
const version = 1
|
||||
const time = new Date().getTime()
|
||||
|
||||
const data = {
|
||||
time,
|
||||
version,
|
||||
localStorage,
|
||||
indexedDB: [] as { key: string; value: any }[]
|
||||
}
|
||||
|
||||
for (const key of indexedKeys) {
|
||||
data.indexedDB.push({
|
||||
key,
|
||||
value: await localforage.getItem(key)
|
||||
})
|
||||
}
|
||||
|
||||
const filename = `cherry-studio.${dayjs().format('YYYYMMDD')}.bak`
|
||||
const fileContnet = JSON.stringify(data)
|
||||
const file = await window.api.compress(fileContnet)
|
||||
|
||||
window.api.saveFile(filename, file)
|
||||
}
|
||||
|
||||
export async function restore() {
|
||||
const file = await window.api.openFile()
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
const content = await window.api.decompress(file.content)
|
||||
const data = JSON.parse(content)
|
||||
|
||||
if (data.version === 1) {
|
||||
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
|
||||
|
||||
for (const { key, value } of data.indexedDB) {
|
||||
await localforage.setItem(key, value)
|
||||
}
|
||||
|
||||
window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' })
|
||||
setTimeout(() => window.api.reload(), 1500)
|
||||
} else {
|
||||
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reset() {
|
||||
window.modal.confirm({
|
||||
title: i18n.t('common.warning'),
|
||||
content: i18n.t('message.reset.confirm.content'),
|
||||
onOk: async () => {
|
||||
window.modal.confirm({
|
||||
title: i18n.t('message.reset.double.confirm.title'),
|
||||
content: i18n.t('message.reset.double.confirm.content'),
|
||||
onOk: async () => {
|
||||
await localStorage.clear()
|
||||
await localforage.clear()
|
||||
window.api.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { isLocalAi } from '@renderer/config/env'
|
||||
import { SYSTEM_MODELS } from '@renderer/config/models'
|
||||
import { Model, Provider } from '@renderer/types'
|
||||
import { uniqBy } from 'lodash'
|
||||
@@ -40,6 +41,24 @@ const initialState: LlmState = {
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.anthropic.com/',
|
||||
models: SYSTEM_MODELS.anthropic.filter((m) => m.enabled),
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
apiKey: '',
|
||||
apiHost: 'http://localhost:11434/v1/',
|
||||
models: SYSTEM_MODELS.ollama.filter((m) => m.enabled),
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'silicon',
|
||||
name: 'Silicon',
|
||||
@@ -165,24 +184,6 @@ const initialState: LlmState = {
|
||||
models: SYSTEM_MODELS.groq.filter((m) => m.enabled),
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
apiKey: '',
|
||||
apiHost: 'https://api.anthropic.com/',
|
||||
models: SYSTEM_MODELS.anthropic.filter((m) => m.enabled),
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
apiKey: '',
|
||||
apiHost: 'http://localhost:11434/v1/',
|
||||
models: [],
|
||||
isSystem: true,
|
||||
enabled: false
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
@@ -192,9 +193,35 @@ const initialState: LlmState = {
|
||||
}
|
||||
}
|
||||
|
||||
const getIntegratedInitialState = () => {
|
||||
const model = JSON.parse(import.meta.env.VITE_RENDERER_INTEGRATED_MODEL)
|
||||
|
||||
return {
|
||||
defaultModel: model,
|
||||
topicNamingModel: model,
|
||||
translateModel: model,
|
||||
providers: [
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
apiKey: 'ollama',
|
||||
apiHost: 'http://localhost:15537/v1/',
|
||||
models: [model],
|
||||
isSystem: true,
|
||||
enabled: true
|
||||
}
|
||||
],
|
||||
settings: {
|
||||
ollama: {
|
||||
keepAliveTime: 3600
|
||||
}
|
||||
}
|
||||
} as LlmState
|
||||
}
|
||||
|
||||
const settingsSlice = createSlice({
|
||||
name: 'llm',
|
||||
initialState,
|
||||
initialState: isLocalAi ? getIntegratedInitialState() : initialState,
|
||||
reducers: {
|
||||
updateProvider: (state, action: PayloadAction<Provider>) => {
|
||||
state.providers = state.providers.map((p) => (p.id === action.payload.id ? { ...p, ...action.payload } : p))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import Logo from '@renderer/assets/images/logo.png'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
|
||||
export interface RuntimeState {
|
||||
avatar: string
|
||||
@@ -8,7 +8,7 @@ export interface RuntimeState {
|
||||
}
|
||||
|
||||
const initialState: RuntimeState = {
|
||||
avatar: Logo,
|
||||
avatar: AppLogo,
|
||||
generating: false,
|
||||
minappShow: false
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const runtimeSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
setAvatar: (state, action: PayloadAction<string | null>) => {
|
||||
state.avatar = action.payload || Logo
|
||||
state.avatar = action.payload || AppLogo
|
||||
},
|
||||
setGenerating: (state, action: PayloadAction<boolean>) => {
|
||||
state.generating = action.payload
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.web.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"include": [
|
||||
"electron.vite.config.*",
|
||||
"src/main/**/*",
|
||||
"src/preload/**/*",
|
||||
"src/main/env.d.ts",
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
"types": [
|
||||
"electron-vite/node"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts"
|
||||
"src/preload/*.d.ts",
|
||||
"local/src/renderer/**/*",
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
||||