Compare commits

...

28 Commits

Author SHA1 Message Date
kangfenmao
c250124043 0.2.4 2024-07-16 20:42:21 +08:00
kangfenmao
7aa6d6ebeb fix: process is not defined 2024-07-16 20:40:25 +08:00
kangfenmao
e962351b13 feat: check update 2024-07-16 20:06:25 +08:00
kangfenmao
80e34688b1 fix(about): changelog overflow 2024-07-16 17:27:16 +08:00
kangfenmao
8c23d6ec55 feat: add website 2024-07-16 17:08:10 +08:00
kangfenmao
2cc09a52f4 feat: add sentry integration 2024-07-16 17:08:04 +08:00
kangfenmao
6b872f58ed 0.2.3 2024-07-16 11:02:53 +08:00
kangfenmao
d5da7e4413 docs: update change log 2024-07-16 11:02:37 +08:00
kangfenmao
3e88aa3c36 fix(i18n): provider name en 2024-07-16 10:52:23 +08:00
kangfenmao
d6fc1cb364 fix: zhipu provider default model data error 2024-07-16 10:40:28 +08:00
亢奋猫
224f23aea0 Update README.md 2024-07-16 10:22:24 +08:00
亢奋猫
8c186b757e Update README.md 2024-07-16 10:21:12 +08:00
kangfenmao
3f32775b98 fix: provider model edit popup title 2024-07-15 18:07:04 +08:00
kangfenmao
067819652b fix(llm): zhipu provider default models is wrong 2024-07-15 18:02:41 +08:00
kangfenmao
b3a023e4ac fix: model checking error 2024-07-15 17:58:58 +08:00
kangfenmao
1da1b6622d 0.2.2 2024-07-15 17:32:04 +08:00
kangfenmao
78e1626e52 feat: update change log styles 2024-07-15 17:31:39 +08:00
kangfenmao
ec49cf61d6 fix: default assistant name empty 2024-07-15 17:22:51 +08:00
kangfenmao
b487c68822 0.2.1 2024-07-15 16:45:01 +08:00
kangfenmao
74aa95339c feat: add changes log 2024-07-15 15:55:51 +08:00
kangfenmao
e90ef9d05f feat: add release note 2024-07-15 15:00:19 +08:00
kangfenmao
e8bdf9d5fd feat: add pause icon to pause chat completion 2024-07-15 14:21:36 +08:00
kangfenmao
a97d6f024b fix(i18n): reload after change language 2024-07-15 14:16:43 +08:00
kangfenmao
b4eb35d86a feat: add keyv-storage 2024-07-15 13:13:42 +08:00
kangfenmao
3fc45187eb ci: remove any yml files from the release files 2024-07-15 12:39:03 +08:00
kangfenmao
f1c5a41fff build: fix artifactName with arch 2024-07-15 11:49:55 +08:00
kangfenmao
5a7f943448 ci(release): remove blockmap files 2024-07-15 11:49:33 +08:00
kangfenmao
b02e83f68e feat: add x64 arch to mac dmg target 2024-07-15 11:25:32 +08:00
39 changed files with 1721 additions and 102 deletions

View File

@@ -2,3 +2,4 @@ node_modules
dist
out
.gitignore

View File

@@ -70,7 +70,6 @@ jobs:
dist/*.deb
dist/*.rpm
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
dist/latest*.yml
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}

View File

@@ -4,3 +4,4 @@ pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
CHANGELOG*.md

View File

@@ -2,6 +2,22 @@
Cherry Studio is a desktop client for multiple cutting-edge LLM models, available on Windows, Mac and Linux.
# Screenshot
![image.png](https://s2.loli.net/2024/07/16/IAVSOorsfFQyGhM.png)
![image.png](https://s2.loli.net/2024/07/16/IQPz12OajfNoBTV.png)
# Feature
1. Supports multiple large language model service providers.
2. Allows creation of multiple Assistants.
3. Enables creation of multiple topics.
4. Allows using multiple models to answer questions in the same conversation.
5. Supports drag-and-drop sorting.
6. Code highlighting.
# Develop
## Recommended IDE Setup
- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)

View File

@@ -1,3 +1,6 @@
provider: generic
url: http://127.0.0.1:8080
updaterCacheDirName: cherry-studio-updater
# provider: generic
# url: http://127.0.0.1:8080
# updaterCacheDirName: cherry-studio-updater
provider: github
repo: cherry-studio
owner: kangfenmao

View File

@@ -26,8 +26,17 @@ mac:
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
notarize: false
target:
- target: dmg
arch:
- arm64
- x64
- target: zip
arch:
- arm64
- x64
dmg:
artifactName: ${productName}-${version}.${ext}
artifactName: ${productName}-${version}-${arch}.${ext}
linux:
target:
- AppImage
@@ -45,3 +54,7 @@ publish:
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
- 修复更新日志页面不能滚动问题
- 新增检查更新按钮

View File

@@ -16,6 +16,7 @@ export default defineConfig({
}
},
plugins: [react()],
assetsInclude: ['**/*.md'],
server: {
host: '0.0.0.0'
}

View File

@@ -1,6 +1,6 @@
{
"name": "cherry-studio",
"version": "0.2.0",
"version": "0.2.4",
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
"author": "kangfenmao@qq.com",
@@ -23,6 +23,7 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"@sentry/electron": "^5.2.0",
"electron-log": "^5.1.5",
"electron-updater": "^6.1.7",
"electron-window-state": "^5.0.3"
@@ -33,6 +34,7 @@
"@electron-toolkit/tsconfig": "^1.0.1",
"@fontsource/inter": "^5.0.18",
"@hello-pangea/dnd": "^16.6.0",
"@kangfenmao/keyv-storage": "^0.1.0",
"@reduxjs/toolkit": "^2.2.5",
"@types/lodash": "^4.17.5",
"@types/node": "^18.19.9",

View File

@@ -6,9 +6,14 @@ exports.default = async function notarizing(context) {
return
}
if (!process.env.APPLE_ID || !process.env.APPLE_APP_SPECIFIC_PASSWORD || !process.env.APPLE_TEAM_ID) {
console.log('Skipping notarization')
return
}
const appName = context.packager.appInfo.productFilename
const notarized = await notarize({
await notarize({
appPath: `${context.appOutDir}/${appName}.app`,
appBundleId: 'com.kangfenmao.CherryStudio',
appleId: process.env.APPLE_ID,
@@ -16,7 +21,5 @@ exports.default = async function notarizing(context) {
teamId: process.env.APPLE_TEAM_ID
})
console.log('Notarized:', notarized)
return notarized
console.log('Notarized app:', appName)
}

View File

@@ -1,12 +1,13 @@
import { electronApp, is, optimizer } from '@electron-toolkit/utils'
import * as Sentry from '@sentry/electron/main'
import { app, BrowserWindow, ipcMain, Menu, MenuItem, shell } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import windowStateKeeper from 'electron-window-state'
import { join } from 'path'
import icon from '../../resources/icon.png?asset'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import AppUpdater from './updater'
function createWindow(): void {
function createWindow() {
// Load the previous state with fallback to defaults
const mainWindowState = windowStateKeeper({
defaultWidth: 1080,
@@ -61,6 +62,8 @@ function createWindow(): void {
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
return mainWindow
}
// This method will be called when Electron has finished
@@ -77,24 +80,36 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// IPC
ipcMain.handle('get-app-info', () => ({
version: app.getVersion()
}))
createWindow()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
installExtension(REDUX_DEVTOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
const mainWindow = createWindow()
setTimeout(() => new AppUpdater(), 5000)
const { autoUpdater } = new AppUpdater(mainWindow)
// IPC
ipcMain.handle('get-app-info', () => ({
version: app.getVersion(),
isPackaged: app.isPackaged
}))
ipcMain.handle('open-website', (_, url: string) => {
shell.openExternal(url)
})
// 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法)
ipcMain.handle('check-for-update', async () => {
autoUpdater.logger?.info('触发检查更新')
return {
currentVersion: autoUpdater.currentVersion,
update: await autoUpdater.checkForUpdates()
}
})
installExtension(REDUX_DEVTOOLS)
})
// Quit when all windows are closed, except on macOS. There, it's common
@@ -108,3 +123,6 @@ 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'
})

View File

@@ -1,24 +1,20 @@
import { autoUpdater, UpdateInfo } from 'electron-updater'
import { AppUpdater as _AppUpdater, autoUpdater, UpdateInfo } from 'electron-updater'
import logger from 'electron-log'
import { dialog, ipcMain } from 'electron'
import { BrowserWindow, dialog } from 'electron'
export default class AppUpdater {
constructor() {
autoUpdater: _AppUpdater = autoUpdater
constructor(mainWindow: BrowserWindow) {
logger.transports.file.level = 'debug'
autoUpdater.logger = logger
autoUpdater.forceDevUpdateConfig = true
autoUpdater.autoDownload = false
autoUpdater.checkForUpdatesAndNotify()
// 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法)
ipcMain.on('check-for-update', () => {
logger.info('触发检查更新')
autoUpdater.checkForUpdates()
})
// 检测下载错误
autoUpdater.on('error', (error) => {
logger.error('更新异常', error)
mainWindow.webContents.send('update-error', error)
})
// 检测是否需要更新
@@ -28,6 +24,7 @@ export default class AppUpdater {
autoUpdater.on('update-available', (releaseInfo: UpdateInfo) => {
autoUpdater.logger?.info('检测到新版本,确认是否下载')
mainWindow.webContents.send('update-available', releaseInfo)
const releaseNotes = releaseInfo.releaseNotes
let releaseContent = ''
if (releaseNotes) {
@@ -49,10 +46,12 @@ export default class AppUpdater {
title: '应用有新的更新',
detail: releaseContent,
message: '发现新版本,是否现在更新?',
buttons: ['', '']
buttons: ['下次再说', '更新']
})
.then(({ response }) => {
if (response === 1) {
logger.info('用户选择更新,准备下载更新')
mainWindow.webContents.send('download-update')
autoUpdater.downloadUpdate()
}
})
@@ -61,11 +60,13 @@ export default class AppUpdater {
// 检测到不需要更新时
autoUpdater.on('update-not-available', () => {
logger.info('现在使用的就是最新版本,不用更新')
mainWindow.webContents.send('update-not-available')
})
// 更新下载进度
autoUpdater.on('download-progress', (progress) => {
logger.info('下载进度', progress)
mainWindow.webContents.send('download-progress', progress)
})
// 当需要更新的内容下载完成后
@@ -80,5 +81,7 @@ export default class AppUpdater {
setImmediate(() => autoUpdater.quitAndInstall())
})
})
this.autoUpdater = autoUpdater
}
}

View File

@@ -6,7 +6,10 @@ declare global {
api: {
getAppInfo: () => Promise<{
version: string
isPackaged: boolean
}>
checkForUpdate: () => void
openWebsite: (url: string) => void
}
}
}

View File

@@ -3,7 +3,9 @@ import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {
getAppInfo: () => ipcRenderer.invoke('get-app-info')
getAppInfo: () => ipcRenderer.invoke('get-app-info'),
checkForUpdate: () => ipcRenderer.invoke('check-for-update'),
openWebsite: (url: string) => ipcRenderer.invoke('open-website', url)
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

@@ -7,7 +7,6 @@ import { PersistGate } from 'redux-persist/integration/react'
import Sidebar from './components/app/Sidebar'
import TopViewContainer from './components/TopView'
import { AntdThemeConfig, getAntdLocale } from './config/antd'
import './i18n'
import AppsPage from './pages/apps/AppsPage'
import HomePage from './pages/home/HomePage'
import SettingsPage from './pages/settings/SettingsPage'

View File

@@ -0,0 +1,25 @@
# CHANGES LOG
### v0.2.4 - 2024-07-16
- Fixed the issue of the update log page cannot be scrolled
- Added a check for updates button
### v0.2.3 - 2024-07-16
- Fixed multi-language prompt errors
- Fixed default model error issues with ZHIPU AI
- Fixed OpenRouter API detection error issues
- Fixed multi-language translation errors with model providers
### v0.2.2 - 2024-07-15
- Fix the issue where the default assistant name is empty.
- Fix the problem with default language detection during the first installation.
- Adjust the changelog style.
### v0.2.1 - 2024-07-15
- **Feature**: Add new feature for pausing message sending
- **Fix**: Resolve incomplete translation issue upon language switch
- **Build**: Support for macOS Intel architecture

View File

@@ -0,0 +1,26 @@
# 更新日志
### v0.2.4 - 2024-07-16
- 修复更新日志页面不能滚动问题
- 新增检查更新按钮
### v0.2.3 - 2024-07-16
- 修复多语言提示错误
- 修复智谱AI默认模型错误问题
- 修复 OpenRouter API 检测出错问题
- 修复模型提供商多语言翻译错误问题
### v0.2.2 - 2024-07-15
- 修复默认助理名称为空的问题
- 修复首次安装默认语言检测问题
- 更新日志样式微调
### v0.2.1 - 2024-07-15
- 【功能】新增消息暂停发送功能
- 【修复】修复多语言切换不彻底问题
- 【构建】支持 macOS Intel 架构

View File

@@ -2,10 +2,12 @@
import { MessageInstance } from 'antd/es/message/interface'
import { HookAPI } from 'antd/es/modal/useModal'
import type KeyvStorage from '@kangfenmao/keyv-storage'
declare global {
interface Window {
message: MessageInstance
modal: HookAPI
keyv: KeyvStorage
}
}

View File

@@ -1,3 +1,4 @@
import { i18nInit } from '@renderer/i18n'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime'
@@ -12,5 +13,13 @@ export function useAppInitEffect() {
const storedImage = await LocalStorage.getImage('avatar')
storedImage && dispatch(setAvatar(storedImage))
})
i18nInit()
}, [dispatch])
useEffect(() => {
runAsyncFunction(async () => {
const { isPackaged } = await window.api.getAppInfo()
isPackaged && setTimeout(window.api.checkForUpdate, 3000)
})
}, [])
}

View File

@@ -39,7 +39,8 @@ const resources = {
'error.enter.api.host': 'Please enter your API host first',
'error.enter.model': 'Please select a model first',
'api.connection.failed': 'Connection failed',
'api.connection.success': 'Connection successful'
'api.connection.success': 'Connection successful',
'chat.completion.paused': 'Chat completion paused'
},
assistant: {
'default.name': 'Default Assistant',
@@ -61,7 +62,8 @@ const resources = {
'input.clear.title': 'Clear all messages?',
'input.clear.content': 'Are you sure to clear all messages?',
'input.placeholder': 'Type your message here...',
'input.send': 'Send'
'input.send': 'Send',
'input.pause': 'Pause'
},
apps: {
title: 'Agents'
@@ -72,8 +74,8 @@ const resources = {
moonshot: 'Moonshot',
silicon: 'SiliconFlow',
openrouter: 'OpenRouter',
yi: 'Lingyiwanwu',
zhipu: 'BigModel',
yi: 'Yi',
zhipu: 'ZHIPU AI',
groq: 'Groq',
ollama: 'Ollama'
},
@@ -106,7 +108,12 @@ const resources = {
'models.add.group_name.placeholder': 'Optional e.g. ChatGPT',
'models.empty': 'No models found',
'assistant.title': 'Default Assistant',
'about.description': 'A powerful AI assistant for producer'
'about.description': 'A powerful AI assistant for producer',
'about.updateNotAvailable': 'You are using the latest version',
'about.checkingUpdate': 'Checking for updates...',
'about.updateError': 'Update error',
'about.checkUpdate': 'Check Update',
'about.downloading': 'Downloading...'
}
}
},
@@ -146,7 +153,8 @@ const resources = {
'error.enter.api.host': '请输入您的 API 地址',
'error.enter.model': '请选择一个模型',
'api.connection.failed': '连接失败',
'api.connection.successful': '连接成功'
'api.connection.successful': '连接成功',
'chat.completion.paused': '会话已停止'
},
assistant: {
'default.name': '默认助手',
@@ -168,7 +176,8 @@ const resources = {
'input.clear.title': '清除所有消息?',
'input.clear.content': '确定要清除所有消息吗?',
'input.placeholder': '在这里输入消息...',
'input.send': '发送'
'input.send': '发送',
'input.pause': '暂停'
},
apps: {
title: '智能体'
@@ -213,7 +222,12 @@ const resources = {
'models.add.group_name.placeholder': '例如 ChatGPT',
'models.empty': '没有模型',
'assistant.title': '默认助手',
'about.description': '一个为创造者而生的 AI 助手'
'about.description': '一个为创造者而生的 AI 助手',
'about.updateNotAvailable': '你的软件已是最新版本',
'about.checkingUpdate': '正在检查更新...',
'about.updateError': '更新出错',
'about.checkUpdate': '检查更新',
'about.downloading': '正在下载更新...'
}
}
}
@@ -221,11 +235,15 @@ const resources = {
i18n.use(initReactI18next).init({
resources,
lng: store.getState().settings.language || 'en-US',
lng: localStorage.getItem('language') || navigator.language || 'en-US',
fallbackLng: 'en-US',
interpolation: {
escapeValue: false
}
})
export function i18nInit() {
i18n.changeLanguage(store.getState().settings.language || 'en-US')
}
export default i18n

View File

@@ -1,4 +1,25 @@
import localforage from 'localforage'
import KeyvStorage from '@kangfenmao/keyv-storage'
import * as Sentry from '@sentry/electron/renderer'
import { isProduction } 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
})
}
}
function init() {
localforage.config({
@@ -8,6 +29,11 @@ function init() {
storeName: 'cherryai',
description: 'Cherry Studio Storage'
})
window.keyv = new KeyvStorage()
window.keyv.init()
initSentry()
}
init()

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import App from './App'
import './assets/styles/index.scss'
import './init'
import './i18n'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>

View File

@@ -12,6 +12,7 @@ import {
FullscreenExitOutlined,
FullscreenOutlined,
HistoryOutlined,
PauseCircleOutlined,
PlusCircleOutlined
} from '@ant-design/icons'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
@@ -19,9 +20,10 @@ import { isEmpty } from 'lodash'
import SendMessageSetting from './SendMessageSetting'
import { useSettings } from '@renderer/hooks/useSettings'
import dayjs from 'dayjs'
import { useAppSelector } from '@renderer/store'
import store, { useAppSelector } from '@renderer/store'
import { getDefaultTopic } from '@renderer/services/assistant'
import { useTranslation } from 'react-i18next'
import { setGenerating } from '@renderer/store/runtime'
interface Props {
assistant: Assistant
@@ -86,6 +88,11 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const clearTopic = () => EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES)
const onPause = () => {
window.keyv.set(EVENT_NAMES.CHAT_COMPLETION_PAUSED, true)
store.dispatch(setGenerating(false))
}
// Command or Ctrl + N create new topic
useEffect(() => {
const onKeydown = (e) => {
@@ -148,6 +155,13 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
</Tooltip>
</ToolbarMenu>
<ToolbarMenu>
{generating && (
<Tooltip placement="top" title={t('assistant.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause}>
<PauseCircleOutlined />
</ToolbarButton>
</Tooltip>
)}
<SendMessageSetting>
<ToolbarButton type="text" style={{ marginRight: 0 }}>
<MoreOutlined />

View File

@@ -12,6 +12,7 @@ import Logo from '@renderer/assets/images/logo.png'
import { SyncOutlined } from '@ant-design/icons'
import { firstLetter } from '@renderer/utils'
import { useTranslation } from 'react-i18next'
import { isEmpty } from 'lodash'
interface Props {
message: Message
@@ -53,6 +54,13 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
setTimeout(() => EventEmitter.emit(EVENT_NAMES.REGENERATE_MESSAGE), 100)
}
const getMessageContent = (message: Message) => {
if (isEmpty(message.content) && message.status === 'paused') {
return t('message.chat.completion.paused')
}
return message.content
}
return (
<MessageContainer key={message.id}>
<AvatarWrapper>
@@ -72,7 +80,7 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
)}
{message.status !== 'sending' && (
<Markdown className="markdown" components={{ code: CodeBlock as any }}>
{message.content}
{getMessageContent(message)}
</Markdown>
)}
{showMenu && (

View File

@@ -1,13 +1,34 @@
import { Avatar } from 'antd'
import { Avatar, Button, Progress } from 'antd'
import { FC, useEffect, useState } from 'react'
import styled from 'styled-components'
import Logo from '@renderer/assets/images/logo.png'
import { runAsyncFunction } from '@renderer/utils'
import { useTranslation } from 'react-i18next'
import Changelog from './components/Changelog'
import { debounce } from 'lodash'
import { ProgressInfo } from 'electron-updater'
const AboutSettings: FC = () => {
const [version, setVersion] = useState('')
const { t } = useTranslation()
const [percent, setPercent] = useState(0)
const [checkUpdateLoading, setCheckUpdateLoading] = useState(false)
const [downloading, setDownloading] = useState(false)
const onCheckUpdate = debounce(
async () => {
if (checkUpdateLoading || downloading) return
setCheckUpdateLoading(true)
await window.api.checkForUpdate()
setCheckUpdateLoading(false)
},
2000,
{ leading: true, trailing: false }
)
const onOpenWebsite = (suffix = '') => {
window.api.openWebsite('https://github.com/kangfenmao/cherry-studio' + suffix)
}
useEffect(() => {
runAsyncFunction(async () => {
@@ -16,23 +37,74 @@ const AboutSettings: FC = () => {
})
}, [])
useEffect(() => {
const ipcRenderer = window.electron.ipcRenderer
const removers = [
ipcRenderer.on('update-not-available', () => {
setCheckUpdateLoading(false)
window.message.success(t('settings.about.updateNotAvailable'))
}),
ipcRenderer.on('update-available', () => {
setCheckUpdateLoading(false)
}),
ipcRenderer.on('download-update', () => {
setCheckUpdateLoading(false)
setDownloading(true)
}),
ipcRenderer.on('download-progress', (_, progress: ProgressInfo) => {
setPercent(progress.percent)
}),
ipcRenderer.on('update-error', (_, error) => {
setCheckUpdateLoading(false)
setDownloading(false)
setPercent(0)
window.modal.info({
title: t('settings.about.updateError'),
content: error?.message || t('settings.about.updateError'),
icon: null
})
})
]
return () => removers.forEach((remover) => remover())
}, [t])
return (
<Container>
<Avatar src={Logo} size={100} style={{ marginTop: 50 }} />
<AvatarWrapper onClick={() => onOpenWebsite()}>
{percent > 0 && (
<ProgressCircle
type="circle"
size={104}
percent={percent}
showInfo={false}
strokeLinecap="butt"
strokeColor="#67ad5b"
/>
)}
<Avatar src={Logo} size={100} style={{ marginTop: 50, minHeight: 100 }} />
</AvatarWrapper>
<Title>
Cherry Studio <Version>(v{version})</Version>
Cherry Studio <Version onClick={() => onOpenWebsite('/releases')}>(v{version})</Version>
</Title>
<Description>{t('settings.about.description')}</Description>
<CheckUpdateButton onClick={onCheckUpdate} loading={checkUpdateLoading}>
{downloading ? t('settings.about.downloading') : t('settings.about.checkUpdate')}
</CheckUpdateButton>
<Changelog />
</Container>
)
}
const Container = styled.div`
padding: 20px;
display: flex;
width: 100%;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: calc(100vh - var(--navbar-height));
overflow-y: scroll;
padding: 0;
padding-bottom: 50px;
`
const Title = styled.div`
@@ -47,6 +119,7 @@ const Version = styled.span`
color: var(--color-text-2);
margin: 10px 0;
text-align: center;
cursor: pointer;
`
const Description = styled.div`
@@ -55,4 +128,19 @@ const Description = styled.div`
text-align: center;
`
const CheckUpdateButton = styled(Button)`
margin-top: 10px;
`
const AvatarWrapper = styled.div`
position: relative;
cursor: pointer;
`
const ProgressCircle = styled(Progress)`
position: absolute;
top: 48px;
left: -2px;
`
export default AboutSettings

View File

@@ -1,6 +1,6 @@
import { FC } from 'react'
import { SettingContainer, SettingDivider, SettingRow, SettingRowTitle, SettingTitle } from './components'
import { Avatar, message, Select, Upload } from 'antd'
import { Avatar, Select, Upload } from 'antd'
import styled from 'styled-components'
import LocalStorage from '@renderer/services/storage'
import { compressImage } from '@renderer/utils'
@@ -14,7 +14,6 @@ import i18next from 'i18next'
const GeneralSettings: FC = () => {
const avatar = useAvatar()
const [messageApi, contextHolder] = message.useMessage()
const { language } = useSettings()
const dispatch = useAppDispatch()
const { t } = useTranslation()
@@ -22,14 +21,26 @@ const GeneralSettings: FC = () => {
const onSelectLanguage = (value: string) => {
dispatch(setLanguage(value))
i18next.changeLanguage(value)
// window.location.reload()
localStorage.setItem('language', value)
}
return (
<SettingContainer>
{contextHolder}
<SettingTitle>{t('settings.general.title')}</SettingTitle>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('common.language')}</SettingRowTitle>
<Select
defaultValue={language || 'en-US'}
style={{ width: 120 }}
onChange={onSelectLanguage}
options={[
{ value: 'zh-CN', label: '中文' },
{ value: 'en-US', label: 'English' }
]}
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('common.avatar')}</SettingRowTitle>
<Upload
@@ -44,29 +55,13 @@ const GeneralSettings: FC = () => {
await LocalStorage.storeImage('avatar', compressedFile)
dispatch(setAvatar(await LocalStorage.getImage('avatar')))
} catch (error: any) {
messageApi.open({
type: 'error',
content: error.message
})
window.message.error(error.message)
}
}}>
<UserAvatar src={avatar} size="large" />
</Upload>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitle>{t('common.language')}</SettingRowTitle>
<Select
defaultValue={language || 'en-US'}
style={{ width: 120 }}
onChange={onSelectLanguage}
options={[
{ value: 'zh-CN', label: '中文' },
{ value: 'en-US', label: 'English' }
]}
/>
</SettingRow>
<SettingDivider />
</SettingContainer>
)
}

View File

@@ -0,0 +1,29 @@
import changelogEn from '@renderer/CHANGELOG.en.md?raw'
import changelogZh from '@renderer/CHANGELOG.zh.md?raw'
import { FC } from 'react'
import Markdown from 'react-markdown'
import styled from 'styled-components'
import styles from './changelog.module.scss'
import i18n from '@renderer/i18n'
const Changelog: FC = () => {
const language = i18n.language
const changelog = language === 'zh-CN' ? changelogZh : changelogEn
return (
<Container>
<Markdown className={styles.markdown}>{changelog}</Markdown>
</Container>
)
}
const Container = styled.div`
font-size: 14px;
background-color: var(--color-background-soft);
margin-top: 40px;
padding: 20px;
border-radius: 5px;
width: 650px;
`
export default Changelog

View File

@@ -86,7 +86,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
return (
<Flex>
<ModelHeaderTitle>
{provider.name} {t('common.models')}
{t(`provider.${provider.id}`)} {t('common.models')}
</ModelHeaderTitle>
{loading && <LoadingOutlined size={20} />}
</Flex>

View File

@@ -18,7 +18,8 @@ interface Props {
provider: Provider
}
const ProviderSetting: FC<Props> = ({ provider }) => {
const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
const { provider } = useProvider(_provider.id)
const [apiKey, setApiKey] = useState(provider.apiKey)
const [apiHost, setApiHost] = useState(provider.apiHost)
const [apiValid, setApiValid] = useState(false)

View File

@@ -0,0 +1,79 @@
$background-color: #121212;
$text-color: #ffffff;
$heading-color: #00b96b;
$link-color: #3498db;
$code-background: #1e1e1e;
$code-color: #f0e7db;
.markdown {
body {
background-color: $background-color;
color: $text-color;
font-family: Arial, sans-serif;
padding: 20px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: $heading-color;
}
h1 {
font-size: 22px;
font-weight: 700;
}
h3 {
margin: 10px 0;
font-weight: 500;
font-family: Arial, sans-serif;
}
a {
color: $link-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
strong {
font-weight: bold;
}
pre {
background-color: $code-background;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
}
code {
background-color: $code-background;
color: $code-color;
padding: 2px 4px;
border-radius: 3px;
}
blockquote {
border-left: 4px solid $heading-color;
padding-left: 10px;
margin-left: 0;
color: #b3b3b3;
}
ul,
ol {
padding-left: 20px;
list-style: disc;
}
li {
margin-bottom: 5px;
}
}

View File

@@ -8,7 +8,7 @@ import { takeRight } from 'lodash'
import dayjs from 'dayjs'
import store from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import { t } from 'i18next'
import i18n from '@renderer/i18n'
interface FetchChatCompletionParams {
messages: Message[]
@@ -27,6 +27,8 @@ const getOpenAiProvider = (provider: Provider) => {
}
export async function fetchChatCompletion({ messages, topic, assistant, onResponse }: FetchChatCompletionParams) {
window.keyv.set(EVENT_NAMES.CHAT_COMPLETION_PAUSED, false)
const provider = getAssistantProvider(assistant)
const openaiProvider = getOpenAiProvider(provider)
const defaultModel = getDefaultModel()
@@ -34,7 +36,7 @@ export async function fetchChatCompletion({ messages, topic, assistant, onRespon
store.dispatch(setGenerating(true))
const _message: Message = {
const message: Message = {
id: uuid(),
role: 'assistant',
content: '',
@@ -45,7 +47,7 @@ export async function fetchChatCompletion({ messages, topic, assistant, onRespon
status: 'sending'
}
onResponse({ ..._message })
onResponse({ ...message })
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
@@ -54,12 +56,10 @@ export async function fetchChatCompletion({ messages, topic, assistant, onRespon
content: message.content
}))
const _messages = [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[]
try {
const stream = await openaiProvider.chat.completions.create({
model: model.id,
messages: _messages,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
stream: true
})
@@ -67,22 +67,27 @@ export async function fetchChatCompletion({ messages, topic, assistant, onRespon
let usage: OpenAI.Completions.CompletionUsage | undefined = undefined
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
break
}
content = content + (chunk.choices[0]?.delta?.content || '')
chunk.usage && (usage = chunk.usage)
onResponse({ ..._message, content, status: 'pending' })
onResponse({ ...message, content, status: 'pending' })
}
_message.content = content
_message.usage = usage
message.content = content
message.usage = usage
} catch (error: any) {
_message.content = `Error: ${error.message}`
message.content = `Error: ${error.message}`
}
_message.status = 'success'
EventEmitter.emit(EVENT_NAMES.AI_CHAT_COMPLETION, _message)
const paused = window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)
message.status = paused ? 'paused' : 'success'
EventEmitter.emit(EVENT_NAMES.AI_CHAT_COMPLETION, message)
store.dispatch(setGenerating(false))
return _message
return message
}
interface FetchMessagesSummaryParams {
@@ -122,17 +127,17 @@ export async function checkApi(provider: Provider) {
const style = { marginTop: '3vh' }
if (!provider.apiKey) {
window.message.error({ content: t('error.enter.api.key'), key, style })
window.message.error({ content: i18n.t('message.error.enter.api.key'), key, style })
return false
}
if (!provider.apiHost) {
window.message.error({ content: t('error.enter.api.host'), key, style })
window.message.error({ content: i18n.t('message.error.enter.api.host'), key, style })
return false
}
if (!model) {
window.message.error({ content: t('error.enter.model'), key, style })
window.message.error({ content: i18n.t('message.error.enter.model'), key, style })
return false
}
@@ -142,7 +147,8 @@ export async function checkApi(provider: Provider) {
try {
const response = await openaiProvider.chat.completions.create({
model: model.id,
messages: [{ role: 'user', content: 'hello' }],
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 100,
stream: false
})
@@ -156,7 +162,9 @@ export async function checkApi(provider: Provider) {
key: 'api-check',
style: { marginTop: '3vh' },
duration: valid ? 2 : 8,
content: valid ? t('message.api.connection.successful') : t('message.api.connection.failed') + ' ' + errorMessage
content: valid
? i18n.t('message.api.connection.success')
: i18n.t('message.api.connection.failed') + ' ' + errorMessage
})
return valid

View File

@@ -1,12 +1,12 @@
import { Assistant, Model, Provider, Topic } from '@renderer/types'
import store from '@renderer/store'
import { uuid } from '@renderer/utils'
import i18next from 'i18next'
import i18n from '@renderer/i18n'
export function getDefaultAssistant(): Assistant {
return {
id: 'default',
name: i18next.t('assistant.default.name'),
name: i18n.t('assistant.default.name'),
prompt: '',
topics: [getDefaultTopic()]
}
@@ -15,7 +15,7 @@ export function getDefaultAssistant(): Assistant {
export function getDefaultTopic(): Topic {
return {
id: uuid(),
name: i18next.t('assistant.default.topic.name'),
name: i18n.t('assistant.default.topic.name'),
messages: []
}
}

View File

@@ -9,5 +9,6 @@ export const EVENT_NAMES = {
CLEAR_MESSAGES: 'CLEAR_MESSAGES',
ADD_ASSISTANT: 'ADD_ASSISTANT',
EDIT_MESSAGE: 'EDIT_MESSAGE',
REGENERATE_MESSAGE: 'REGENERATE_MESSAGE'
REGENERATE_MESSAGE: 'REGENERATE_MESSAGE',
CHAT_COMPLETION_PAUSED: 'CHAT_COMPLETION_PAUSED'
}

View File

@@ -19,7 +19,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 7,
version: 9,
blacklist: ['runtime'],
migrate
},

View File

@@ -54,7 +54,7 @@ const initialState: LlmState = {
name: 'ZhiPu',
apiKey: '',
apiHost: 'https://open.bigmodel.cn/api/paas/v4/',
models: SYSTEM_MODELS.groq.filter((m) => m.defaultEnabled),
models: SYSTEM_MODELS.zhipu.filter((m) => m.defaultEnabled),
isSystem: true,
enabled: false
},

View File

@@ -1,6 +1,9 @@
import { createMigrate } from 'redux-persist'
import { RootState } from '.'
import { SYSTEM_MODELS } from '@renderer/config/models'
import { isEmpty } from 'lodash'
import i18n from '@renderer/i18n'
import { Assistant } from '@renderer/types'
const migrate = createMigrate({
// @ts-ignore store type is unknown
@@ -112,6 +115,47 @@ const migrate = createMigrate({
language: navigator.language
}
}
},
// @ts-ignore store type is unknown
'8': (state: RootState) => {
const fixAssistantName = (assistant: Assistant) => {
if (isEmpty(assistant.name)) {
assistant.name = i18n.t(`assistant.${assistant.id}.name`)
}
assistant.topics = assistant.topics.map((topic) => {
if (isEmpty(topic.name)) {
topic.name = i18n.t(`assistant.${assistant.id}.topic.name`)
}
return topic
})
return assistant
}
return {
...state,
assistants: {
...state.assistants,
defaultAssistant: fixAssistantName(state.assistants.defaultAssistant),
assistants: state.assistants.assistants.map((assistant) => fixAssistantName(assistant))
}
}
},
// @ts-ignore store type is unknown
'9': (state: RootState) => {
return {
...state,
llm: {
...state.llm,
providers: state.llm.providers.map((provider) => {
if (provider.id === 'zhipu' && provider.models[0] && provider.models[0].id === 'llama3-70b-8192') {
provider.models = SYSTEM_MODELS.zhipu.filter((m) => m.defaultEnabled)
}
return provider
})
}
}
}
})

View File

@@ -16,7 +16,7 @@ export type Message = {
topicId: string
modelId?: string
createdAt: string
status: 'sending' | 'pending' | 'success' | 'error'
status: 'sending' | 'pending' | 'success' | 'paused' | 'error'
usage?: OpenAI.Completions.CompletionUsage
}

View File

@@ -98,3 +98,13 @@ export const firstLetter = (str?: string) => {
export function isFreeModel(model: Model) {
return (model.id + model.name).toLocaleLowerCase().includes('free')
}
export async function isProduction() {
const { isPackaged } = await window.api.getAppInfo()
return isPackaged
}
export async function isDev() {
const isProd = await isProduction()
return !isProd
}

176
src/website/index.html Normal file
View File

@@ -0,0 +1,176 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Cherry Studio 是一款强大的多模型 AI 助手,支持 iOS、macOS 和 Windows 平台。快速切换多个先进的 LLM 模型,提升工作学习效率。" />
<meta name="keywords" content="Cherry Studio, AI 助手, GPT 客户端, 多模型, iOS, macOS, Windows, LLM" />
<meta name="author" content="kangfenmao" />
<link rel="canonical" href="https://easys.run/cherry-studio" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://easys.run/cherry-studio" />
<meta property="og:title" content="Cherry Studio - 多模型 AI 助手" />
<meta
property="og:description"
content="Cherry Studio 是一款强大的多模型 AI 助手,支持 iOS、macOS 和 Windows 平台。快速切换多个先进的 LLM 模型,提升工作学习效率。" />
<meta property="og:image" content="https://github.com/kangfenmao/cherry-studio/blob/main/build/icon.png?raw=true" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://x.com/kangfenmao" />
<meta property="twitter:title" content="Cherry Studio - 多模型 AI 助手" />
<meta
property="twitter:description"
content="Cherry Studio 是一款强大的多模型 AI 助手,支持 iOS、macOS 和 Windows 平台。快速切换多个先进的 LLM 模型,提升工作学习效率。" />
<meta
property="twitter:image"
content="https://github.com/kangfenmao/cherry-studio/blob/main/build/icon.png?raw=true" />
<title>Cherry Studio - 多模型AI助手</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
background-color: #000000;
color: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
text-align: center;
}
.logo {
width: 100px;
height: 100px;
margin-bottom: 20px;
border-radius: 10%;
}
h1 {
font-size: 48px;
margin-bottom: 10px;
}
.description {
font-size: 18px;
margin-bottom: 30px;
color: #a0a0a0;
}
.download-buttons {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 30px;
}
.download-btn {
background-color: #ffffff;
color: #000000;
padding: 10px 20px;
border-radius: 25px;
text-decoration: none;
font-weight: bold;
transition: background-color 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.download-btn:hover {
background-color: #e0e0e0;
}
.download-btn svg {
margin-right: 10px;
width: 24px;
height: 24px;
}
.new-app {
margin-top: 20px;
font-size: 14px;
color: #a0a0a0;
}
.footer {
position: absolute;
bottom: 20px;
font-size: 14px;
color: #a0a0a0;
}
.footer a {
color: #a0a0a0;
text-decoration: none;
margin: 0 10px;
}
a {
color: #ffffff;
text-decoration: underline;
}
</style>
</head>
<body>
<img
src="https://github.com/kangfenmao/cherry-studio/blob/main/resources/icon.png?raw=true"
alt="Cherry Studio Logo"
class="logo" />
<h1>Cherry Studio</h1>
<p class="description">Windows/macOS GPT 客户端</p>
<div class="download-buttons">
<a
href="https://github.com/kangfenmao/cherry-studio/releases/download/v0.2.3/Cherry-Studio-0.2.3-x64.dmg"
class="download-btn">
<svg viewBox="0 0 384 512" width="24" height="24">
<path
fill="currentColor"
d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
macOS Intel
</a>
<a
href="https://github.com/kangfenmao/cherry-studio/releases/download/v0.2.3/Cherry-Studio-0.2.3-arm64.dmg"
class="download-btn">
<svg viewBox="0 0 384 512" width="24" height="24">
<path
fill="currentColor"
d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z" />
</svg>
macOS Apple Silicon
</a>
<a
href="https://github.com/kangfenmao/cherry-studio/releases/download/v0.2.3/Cherry-Studio-0.2.3-setup.exe"
class="download-btn">
<svg viewBox="0 0 448 512" width="24" height="24">
<path
fill="currentColor"
d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z" />
</svg>
下载 Windows 版本
</a>
</div>
<p class="new-app">
🎉 Cherry Studio 最新版本
<a href="https://github.com/kangfenmao/cherry-studio/releases/tag/v0.2.3" target="_blank">v0.2.3</a> 发布啦!
</p>
<div class="footer">
<a href="https://github.com/kangfenmao/cherry-studio" target="_blank">开源</a> |
<a href="https://github.com/kangfenmao/cherry-studio/blob/main/README.md" target="_blank">帮助</a> |
<a href="mailto:kangfenmao@qq.com" target="_blank">联系</a>
</div>
<!-- 结构化数据 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Cherry Studio",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "iOS, macOS, Windows",
"description": "Cherry Studio 是一款强大的多模型 AI 助手,支持 iOS、macOS 和 Windows 平台。快速切换多个先进的 LLM 模型,提升工作学习效率。",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
}
</script>
</body>
</html>

997
yarn.lock

File diff suppressed because it is too large Load Diff