Compare commits

...

19 Commits

Author SHA1 Message Date
kangfenmao
89bdab58f7 feat: hide entry for local ai 2024-08-28 18:11:35 +08:00
kangfenmao
d42ee59335 fix: https://github.com/electron/notarize/issues/193 2024-08-27 19:42:39 +08:00
kangfenmao
88e7ab211d fix: electron-builder files path 2024-08-27 19:42:32 +08:00
kangfenmao
5347bdfa83 refactor: change env file path 2024-08-27 11:58:19 +08:00
kangfenmao
c8711c5804 feat: add local module 2024-08-27 11:31:05 +08:00
kangfenmao
24cf3bb043 chore(version): 0.6.2 2024-08-26 18:30:05 +08:00
kangfenmao
0531ecf3cf fix: electron builder ignore files 2024-08-26 18:19:01 +08:00
kangfenmao
0cbfd26883 build: remove sentry 2024-08-26 18:06:07 +08:00
kangfenmao
ee398489de build: remove electron-devtools-installer 2024-08-26 18:02:20 +08:00
kangfenmao
71d7c2c738 fix: workspace config 2024-08-26 17:49:19 +08:00
kangfenmao
b98f7298a2 build: add yarn workspace config 2024-08-25 22:12:31 +08:00
kangfenmao
de4f2599be refactor: remove unnecessary logs 2024-08-25 21:37:13 +08:00
kangfenmao
93b32e8e21 feat: update user data path 2024-08-25 18:39:53 +08:00
kangfenmao
e353d0f8ee fix: default assistant name 2024-08-23 21:41:16 +08:00
kangfenmao
dfd42fe9a6 feat: add devv referral code 2024-08-23 20:57:54 +08:00
kangfenmao
a2dc325896 chore(version): 0.6.1 2024-08-22 19:17:35 +08:00
kangfenmao
b131d320ea feat: more ai minapp 2024-08-22 18:45:06 +08:00
kangfenmao
b88f4a869e wip 2024-08-22 16:36:04 +08:00
kangfenmao
461458e5ec refactor: remove minapp.html 2024-08-22 13:04:24 +08:00
55 changed files with 915 additions and 1618 deletions

3
.gitignore vendored
View File

@@ -45,3 +45,6 @@ out
# ENV
.env
.env.*
# Local
local

View 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}`);
}

View File

@@ -1,2 +1,5 @@
nodeLinker: node-modules
enableImmutableInstalls: false
httpTimeout: 300000
nodeLinker: node-modules

View File

@@ -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小程序

View File

@@ -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: {

View File

@@ -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"
}

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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
View 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()
}
})
}

View File

@@ -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')
})

View File

@@ -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
View 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
View 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
View 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
View 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
}
}

View File

@@ -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
}

View File

@@ -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>
}
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -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)

View File

@@ -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>

View 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

View File

@@ -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
}
]
}

View File

@@ -354,7 +354,7 @@ export const PROVIDER_CONFIG = {
},
app: {
name: 'Groq',
url: 'https://groq.com/',
url: 'https://chat.groq.com/',
logo: GroqProviderLogo
}
},

View File

@@ -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

View File

@@ -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
}, [])
}

View File

@@ -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: '知识图谱',

View File

@@ -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()

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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;
}
`

View File

@@ -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);
}
`

View File

@@ -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>

View File

@@ -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[]> {

View File

@@ -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 })

View 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()
}
})
}
})
}

View File

@@ -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))

View File

@@ -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

View File

@@ -1,4 +1,11 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.web.json"
}
]
}

View File

@@ -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"
]
}
}

View File

@@ -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,

1295
yarn.lock

File diff suppressed because it is too large Load Diff