Compare commits

...

21 Commits

Author SHA1 Message Date
kangfenmao
934ab1a374 chore(version): 0.7.2 2024-09-19 16:56:58 +08:00
kangfenmao
33ac0937df feat: Added translations, new column, and UI improvements.
- Added translations for a new field.
- Added new column for file count in the FilesPage view.
- Improved handling of message tokens in the UI.
- Added functionality to display message tokens for messages with specific roles.
- Added window style selection and styling adjustments to the General Settings page.
- Added support for vision models in OpenAIProvider.
2024-09-19 16:56:44 +08:00
kangfenmao
f1c8922752 fix: openai sdk request error 2024-09-19 15:21:24 +08:00
kangfenmao
03bdbdb412 fix: support \(...\) and \[...\] style math formula #78 2024-09-19 15:21:06 +08:00
kangfenmao
cf9d4c5370 feat: add click assistant switch to topics settings 2024-09-19 13:55:44 +08:00
kangfenmao
bfa6bfa196 feat: enhanced user experience with layout adjustments.
- This commit addresses key feature enhancements and minor optimizations for improved user experience and functionality.
- Adjusted margin top for upload container to a positive value.
- Adjusted the max-height of the container to improve rendering on smaller screens.
2024-09-19 12:04:06 +08:00
kangfenmao
af8144d45e feat: Improved file management and added new features.
- Updated file manager to use FileManager class instead of File class.
- Improved file management functionality with features for finding duplicate files, file uploading, and storage management.
- Added styles to wrap and truncate text in a no-drag area.
- Added explicit file extensions to imageExts constant.
- Added the 'paste long text as file' input setting.
- Added image file display and UI improvements for file names and overflow.
- Improved file paste and long text handling functionality.
- awaited onSendMessage function call and added message to chat completion.
- Implemented new option to paste long text as file in the Settings page.
- Updated content display logic to include file origin name along with the file content for text files.
- Improved functionality for handling image and text file contents in the Gemini chat provider.
- Updated file content formatting logic for text files with origin name and content prefix.
- Added a new setting "pasteLongTextAsFile" and its corresponding action to the application settings.
2024-09-19 10:51:30 +08:00
kangfenmao
29605fbcdb feat: copy and paste files or images 2024-09-18 21:18:42 +08:00
kangfenmao
6e7e5cb1f1 feat: add file attachment 2024-09-18 18:00:49 +08:00
kangfenmao
6f5dccd595 feat: estimate completion usage calculation added to chat.
- Estimated usage calculation has been added to chat completion fetching to track message usage.
- Added functionality to estimate completion usage tokens based on input and prompt data.
2024-09-17 14:56:10 +08:00
kangfenmao
0af35b9f10 feat: Added functionality to move topics between assistants.
- Added functionality to move topics between assistants.
- Updated i18n translations to improve user interface clarity and accessibility.
- Improved code organization and functionality to support moving topics between assistants.
2024-09-17 14:37:42 +08:00
kangfenmao
8350ac037e fix: dexie data upgrade 2024-09-16 18:04:46 +08:00
kangfenmao
74b80b474e chore(version): 0.7.1 2024-09-16 16:56:38 +08:00
kangfenmao
be4bf5b510 fix: clear database and restore specific data from backup
- Updated restore function now clears database and restores specific data from backup.
- Removed unused imports and refactored logic for item transformation in the '24' migration step.
2024-09-16 16:44:41 +08:00
kangfenmao
fdb610736d fix: backup and restore 2024-09-16 14:59:42 +08:00
kangfenmao
82e9baf211 fix: Improved user experience by adding timeout to text area resize.
- Added timeout before resizing text area to improve user experience.
- Removed import of the unused `useProviderByAssistant` hook.
2024-09-16 13:03:29 +08:00
kangfenmao
e34d4be6f2 feat: new message branch 2024-09-16 12:56:00 +08:00
kangfenmao
e7f7f8509e feat: add copy button on message footer 2024-09-16 11:51:20 +08:00
kangfenmao
fa1f00f4f5 refactor: add topics and settings table
dexie
2024-09-16 10:19:06 +08:00
kangfenmao
cee373bb6f chore: Update package manager to yarn 4.5.0 and re-add notarize dependency.
- Updated the package manager to yarn version 4.5.0.
- Removed and re-added "electron/notarize" dependency with a specific patch version.
2024-09-15 14:20:58 +08:00
kangfenmao
01acdeb777 feat: added vite_main_bundle_id config and improved code cleanliness 2024-09-15 10:35:02 +08:00
56 changed files with 1471 additions and 492 deletions

View File

@@ -60,13 +60,10 @@ afterSign: scripts/notarize.js
releaseInfo: releaseInfo:
releaseNotes: | releaseNotes: |
本次更新: 本次更新:
支持行内公式 增加了30多种文本文档格式选择
支持编辑所有集成的服务商API地址 支持粘贴图片和文件到聊天输入框
新增智能体搜索功能(>10个) 支持将对话移动到其他智能体了
修复正则表达式显示错误
修复默认模型参数不生效
修复暗黑模式下分界线不明显问题
近期更新: 近期更新:
智能助理和消息列表合并 支持 Vision 模型
优化输入框样式 新增文件功能
提升小程序稳定性 支持从特定消息创建新分支

View File

@@ -1,6 +1,6 @@
{ {
"name": "CherryStudio", "name": "CherryStudio",
"version": "0.7.0", "version": "0.7.2",
"private": true, "private": true,
"description": "A powerful AI assistant for producer.", "description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js", "main": "./out/main/index.js",
@@ -59,6 +59,7 @@
"dotenv-cli": "^7.4.2", "dotenv-cli": "^7.4.2",
"electron": "^28.3.3", "electron": "^28.3.3",
"electron-builder": "^24.9.1", "electron-builder": "^24.9.1",
"electron-devtools-installer": "^3.2.0",
"electron-vite": "^2.0.0", "electron-vite": "^2.0.0",
"emittery": "^1.0.3", "emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1", "emoji-picker-element": "^1.22.1",
@@ -67,7 +68,7 @@
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.0.0", "eslint-plugin-unused-imports": "^4.0.0",
"gpt-tokens": "^1.3.6", "gpt-tokens": "^1.3.10",
"i18next": "^23.11.5", "i18next": "^23.11.5",
"localforage": "^1.10.0", "localforage": "^1.10.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
@@ -99,8 +100,7 @@
"react-dom": "^17.0.0 || ^18.0.0" "react-dom": "^17.0.0 || ^18.0.0"
}, },
"resolutions": { "resolutions": {
"@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" "@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" "packageManager": "yarn@4.5.0"
} }

View File

@@ -4,6 +4,10 @@ import { app } from 'electron'
import Store from 'electron-store' import Store from 'electron-store'
import path from 'path' import path from 'path'
const isDev = process.env.NODE_ENV === 'development'
isDev && app.setPath('userData', app.getPath('userData') + 'Dev')
const getDataPath = () => { const getDataPath = () => {
const dataPath = path.join(app.getPath('userData'), 'Data') const dataPath = path.join(app.getPath('userData'), 'Data')
if (!fs.existsSync(dataPath)) { if (!fs.existsSync(dataPath)) {

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

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
VITE_MAIN_BUNDLE_ID: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -1,5 +1,6 @@
import { electronApp, optimizer } from '@electron-toolkit/utils' import { electronApp, optimizer } from '@electron-toolkit/utils'
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc' import { registerIpc } from './ipc'
import { updateUserDataPath } from './utils/upgrade' import { updateUserDataPath } from './utils/upgrade'
@@ -12,7 +13,7 @@ app.whenReady().then(async () => {
await updateUserDataPath() await updateUserDataPath()
// Set app user model id for windows // Set app user model id for windows
electronApp.setAppUserModelId('com.kangfenmao.CherryStudio') electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')
// Default open or close DevTools by F12 in development // Default open or close DevTools by F12 in development
// and ignore CommandOrControl + R in production. // and ignore CommandOrControl + R in production.
@@ -30,6 +31,12 @@ app.whenReady().then(async () => {
const mainWindow = createMainWindow() const mainWindow = createMainWindow()
registerIpc(mainWindow, app) registerIpc(mainWindow, app)
if (process.env.NODE_ENV === 'development') {
installExtension(REDUX_DEVTOOLS)
.then((name) => console.log(`Added Extension: ${name}`))
.catch((err) => console.log('An error occurred: ', err))
}
}) })
// Quit when all windows are closed, except on macOS. There, it's common // Quit when all windows are closed, except on macOS. There, it's common

View File

@@ -1,17 +1,14 @@
import { FileType } from '@types' import { FileType } from '@types'
import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'electron' import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'electron'
import Logger from 'electron-log'
import fs from 'fs'
import path from 'path'
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config' import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
import AppUpdater from './services/AppUpdater' import AppUpdater from './services/AppUpdater'
import File from './services/File' import FileManager from './services/FileManager'
import { openFile, saveFile } from './utils/file' import { openFile, saveFile } from './utils/file'
import { compress, decompress } from './utils/zip' import { compress, decompress } from './utils/zip'
import { createMinappWindow } from './window' import { createMinappWindow } from './window'
const fileManager = new File() const fileManager = new FileManager()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const { autoUpdater } = new AppUpdater(mainWindow) const { autoUpdater } = new AppUpdater(mainWindow)
@@ -38,28 +35,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('zip:compress', (_, text: string) => compress(text)) ipcMain.handle('zip:compress', (_, text: string) => compress(text))
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text)) ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
ipcMain.handle('image:base64', async (_, filePath) => { ipcMain.handle('file:base64Image', async (_, id) => await fileManager.base64Image(id))
try {
const data = await fs.promises.readFile(filePath)
const base64 = data.toString('base64')
const mime = `image/${path.extname(filePath).slice(1)}`
return {
mime,
base64,
data: `data:${mime};base64,${base64}`
}
} catch (error) {
Logger.error('Error reading file:', error)
return ''
}
})
ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options)) ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options))
ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file)) ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file))
ipcMain.handle('file:delete', async (_, fileId: string) => { ipcMain.handle('file:clear', async () => await fileManager.clear())
await fileManager.deleteFile(fileId) ipcMain.handle('file:read', async (_, id: string) => await fileManager.readFile(id))
return { success: true } ipcMain.handle('file:delete', async (_, id: string) => await fileManager.deleteFile(id))
}) ipcMain.handle('file:get', async (_, filePath: string) => await fileManager.getFile(filePath))
ipcMain.handle('file:create', async (_, fileName: string) => await fileManager.createTempFile(fileName))
ipcMain.handle(
'file:write',
async (_, filePath: string, data: Uint8Array | string) => await fileManager.writeFile(filePath, data)
)
ipcMain.handle('minapp', (_, args) => { ipcMain.handle('minapp', (_, args) => {
createMinappWindow({ createMinappWindow({
url: args.url, url: args.url,

View File

@@ -6,7 +6,7 @@ import * as fs from 'fs'
import * as path from 'path' import * as path from 'path'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
class File { class FileManager {
private storageDir: string private storageDir: string
constructor() { constructor() {
@@ -131,9 +131,68 @@ class File {
return fileMetadata return fileMetadata
} }
async getFile(filePath: string): Promise<FileType | null> {
if (!fs.existsSync(filePath)) {
return null
}
const stats = fs.statSync(filePath)
const ext = path.extname(filePath)
const fileType = getFileType(ext)
const fileInfo: FileType = {
id: uuidv4(),
origin_name: path.basename(filePath),
name: path.basename(filePath),
path: filePath,
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,
count: 1
}
return fileInfo
}
async deleteFile(id: string): Promise<void> { async deleteFile(id: string): Promise<void> {
await fs.promises.unlink(path.join(this.storageDir, id)) await fs.promises.unlink(path.join(this.storageDir, id))
} }
async readFile(id: string): Promise<string> {
const filePath = path.join(this.storageDir, id)
return fs.readFileSync(filePath, 'utf8')
}
async createTempFile(fileName: string): Promise<string> {
const tempDir = path.join(app.getPath('temp'), 'CherryStudio')
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true })
}
const tempFilePath = path.join(tempDir, `temp_file_${uuidv4()}_${fileName}`)
return tempFilePath
}
async writeFile(filePath: string, data: Uint8Array | string): Promise<void> {
await fs.promises.writeFile(filePath, data)
}
async base64Image(id: string): Promise<{ mime: string; base64: string; data: string }> {
const filePath = path.join(this.storageDir, id)
const data = await fs.promises.readFile(filePath)
const base64 = data.toString('base64')
const mime = `image/${path.extname(filePath).slice(1)}`
return {
mime,
base64,
data: `data:${mime};base64,${base64}`
}
}
async clear(): Promise<void> {
await fs.promises.rmdir(this.storageDir, { recursive: true })
await this.initStorageDir()
}
} }
export default File export default FileManager

View File

@@ -1,6 +1,6 @@
import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
import logger from 'electron-log' import logger from 'electron-log'
import { writeFile } from 'fs' import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import { FileTypes } from '../../renderer/src/types' import { FileTypes } from '../../renderer/src/types'
@@ -19,11 +19,7 @@ export async function saveFile(
}) })
if (!result.canceled && result.filePath) { if (!result.canceled && result.filePath) {
writeFile(result.filePath, content, { encoding: 'utf-8' }, (err) => { await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
if (err) {
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
}
})
} }
} catch (err) { } catch (err) {
logger.error('[IPC - Error]', 'An error occurred saving the file:', err) logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
@@ -60,12 +56,103 @@ export function getFileType(ext: string): FileTypes {
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'] const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv'] const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac'] const audioExts = ['.mp3', '.wav', '.ogg', '.flac', '.aac']
const documentExts = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt'] const documentExts = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx']
const textExts = [
'.txt', // 普通文本文件
'.md', // Markdown 文件
'.mdx', // Markdown 文件
'.html', // HTML 文件
'.htm', // HTML 文件的另一种扩展名
'.xml', // XML 文件
'.json', // JSON 文件
'.yaml', // YAML 文件
'.yml', // YAML 文件的另一种扩展名
'.csv', // 逗号分隔值文件
'.tsv', // 制表符分隔值文件
'.ini', // 配置文件
'.log', // 日志文件
'.rtf', // 富文本格式文件
'.tex', // LaTeX 文件
'.srt', // 字幕文件
'.xhtml', // XHTML 文件
'.nfo', // 信息文件(主要用于场景发布)
'.conf', // 配置文件
'.config', // 配置文件
'.env', // 环境变量文件
'.properties', // 配置属性文件
'.latex', // LaTeX 文档文件
'.rst', // reStructuredText 文件
'.php', // PHP 脚本文件,包含嵌入的 HTML
'.js', // JavaScript 文件(部分是文本,部分可能包含代码)
'.ts', // TypeScript 文件
'.jsp', // JavaServer Pages 文件
'.aspx', // ASP.NET 文件
'.bat', // Windows 批处理文件
'.sh', // Unix/Linux Shell 脚本文件
'.py', // Python 脚本文件
'.rb', // Ruby 脚本文件
'.pl', // Perl 脚本文件
'.sql', // SQL 脚本文件
'.css', // Cascading Style Sheets 文件
'.less', // Less CSS 预处理器文件
'.scss', // Sass CSS 预处理器文件
'.sass', // Sass 文件
'.styl', // Stylus CSS 预处理器文件
'.coffee', // CoffeeScript 文件
'.ino', // Arduino 代码文件
'.ino', // Arduino 代码文件
'.asm', // Assembly 语言文件
'.go', // Go 语言文件
'.scala', // Scala 语言文件
'.swift', // Swift 语言文件
'.kt', // Kotlin 语言文件
'.rs', // Rust 语言文件
'.lua', // Lua 语言文件
'.groovy', // Groovy 语言文件
'.dart', // Dart 语言文件
'.hs', // Haskell 语言文件
'.clj', // Clojure 语言文件
'.cljs', // ClojureScript 语言文件
'.elm', // Elm 语言文件
'.erl', // Erlang 语言文件
'.ex', // Elixir 语言文件
'.exs', // Elixir 脚本文件
'.pug', // Pug (formerly Jade) 模板文件
'.haml', // Haml 模板文件
'.slim', // Slim 模板文件
'.tpl', // 模板文件(通用)
'.ejs', // Embedded JavaScript 模板文件
'.hbs', // Handlebars 模板文件
'.mustache', // Mustache 模板文件
'.jade', // Jade 模板文件 (已重命名为 Pug)
'.twig', // Twig 模板文件
'.blade', // Blade 模板文件 (Laravel)
'.vue', // Vue.js 单文件组件
'.jsx', // React JSX 文件
'.tsx', // React TSX 文件
'.graphql', // GraphQL 查询语言文件
'.gql', // GraphQL 查询语言文件
'.proto', // Protocol Buffers 文件
'.thrift', // Thrift 文件
'.toml', // TOML 配置文件
'.edn', // Clojure 数据表示文件
'.cake', // CakePHP 配置文件
'.ctp', // CakePHP 视图文件
'.cfm', // ColdFusion 标记语言文件
'.cfc', // ColdFusion 组件文件
'.m', // Objective-C 源文件
'.mm', // Objective-C++ 源文件
'.gradle', // Gradle 构建文件
'.groovy', // Gradle 构建文件
'.gradle', // Gradle 构建文件
'.kts' // Kotlin Script 文件
]
ext = ext.toLowerCase() ext = ext.toLowerCase()
if (imageExts.includes(ext)) return FileTypes.IMAGE if (imageExts.includes(ext)) return FileTypes.IMAGE
if (videoExts.includes(ext)) return FileTypes.VIDEO if (videoExts.includes(ext)) return FileTypes.VIDEO
if (audioExts.includes(ext)) return FileTypes.AUDIO if (audioExts.includes(ext)) return FileTypes.AUDIO
if (textExts.includes(ext)) return FileTypes.TEXT
if (documentExts.includes(ext)) return FileTypes.DOCUMENT if (documentExts.includes(ext)) return FileTypes.DOCUMENT
return FileTypes.OTHER return FileTypes.OTHER
} }

View File

@@ -24,10 +24,13 @@ declare global {
file: { file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null> select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
upload: (file: FileType) => Promise<FileType> upload: (file: FileType) => Promise<FileType>
delete: (fileId: string) => Promise<{ success: boolean }> delete: (fileId: string) => Promise<void>
} read: (fileId: string) => Promise<string>
image: { base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
base64: (filePath: string) => Promise<{ mime: string; base64: string; data: string }> clear: () => Promise<void>
get: (filePath: string) => Promise<FileType | null>
create: (fileName: string) => Promise<string>
write: (filePath: string, data: Uint8Array | string) => Promise<void>
} }
} }
} }

View File

@@ -12,17 +12,20 @@ const api = {
openFile: (options?: { decompress: boolean }) => ipcRenderer.invoke('open-file', options), openFile: (options?: { decompress: boolean }) => ipcRenderer.invoke('open-file', options),
reload: () => ipcRenderer.invoke('reload'), reload: () => ipcRenderer.invoke('reload'),
saveFile: (path: string, content: string, options?: { compress: boolean }) => { saveFile: (path: string, content: string, options?: { compress: boolean }) => {
ipcRenderer.invoke('save-file', path, content, options) return ipcRenderer.invoke('save-file', path, content, options)
}, },
compress: (text: string) => ipcRenderer.invoke('zip:compress', text), compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text), decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
file: { file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options), select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath), upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId) delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
}, read: (fileId: string) => ipcRenderer.invoke('file:read', fileId),
image: { base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
base64: (filePath: string) => ipcRenderer.invoke('image:base64', filePath) clear: () => ipcRenderer.invoke('file:clear'),
get: (filePath: string) => ipcRenderer.invoke('file:get', filePath),
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data)
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -182,6 +182,12 @@ body,
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.text-nowrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.minapp-drawer { .minapp-drawer {
.ant-drawer-content-wrapper { .ant-drawer-content-wrapper {
box-shadow: none; box-shadow: none;

View File

@@ -130,6 +130,10 @@
} }
} }
pre + pre {
margin-top: 10px;
}
blockquote { blockquote {
margin: 1em 0; margin: 1em 0;
padding-left: 1em; padding-left: 1em;

View File

@@ -1,6 +1,6 @@
import useAvatar from '@renderer/hooks/useAvatar' import useAvatar from '@renderer/hooks/useAvatar'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import LocalStorage from '@renderer/services/storage' import ImageStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime' import { setAvatar } from '@renderer/store/runtime'
import { setUserName } from '@renderer/store/settings' import { setUserName } from '@renderer/store/settings'
@@ -55,8 +55,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
try { try {
const _file = file.originFileObj as File const _file = file.originFileObj as File
const compressedFile = await compressImage(_file) const compressedFile = await compressImage(_file)
await LocalStorage.storeImage('avatar', compressedFile) await ImageStorage.set('avatar', compressedFile)
dispatch(setAvatar(await LocalStorage.getImage('avatar'))) dispatch(setAvatar(await ImageStorage.get('avatar')))
} catch (error: any) { } catch (error: any) {
window.message.error(error.message) window.message.error(error.message)
} }

View File

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

View File

@@ -2,5 +2,4 @@ export { default as UserAvatar } from '@renderer/assets/images/avatar.png'
export { default as AppLogo } from '@renderer/assets/images/logo.png' export { default as AppLogo } from '@renderer/assets/images/logo.png'
export const APP_NAME = 'Cherry Studio' export const APP_NAME = 'Cherry Studio'
export const isLocalAi = false export const isLocalAi = false

View File

@@ -14,6 +14,7 @@ import GemmaModelLogo from '@renderer/assets/images/models/gemma.jpeg'
import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png' import HailuoModelLogo from '@renderer/assets/images/models/hailuo.png'
import LlamaModelLogo from '@renderer/assets/images/models/llama.jpeg' import LlamaModelLogo from '@renderer/assets/images/models/llama.jpeg'
import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png' import MicrosoftModelLogo from '@renderer/assets/images/models/microsoft.png'
import MinicpmModelLogo from '@renderer/assets/images/models/minicpm.webp'
import MixtralModelLogo from '@renderer/assets/images/models/mixtral.jpeg' import MixtralModelLogo from '@renderer/assets/images/models/mixtral.jpeg'
import PalmModelLogo from '@renderer/assets/images/models/palm.svg' import PalmModelLogo from '@renderer/assets/images/models/palm.svg'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png' import QwenModelLogo from '@renderer/assets/images/models/qwen.png'
@@ -91,6 +92,7 @@ export function getModelLogo(modelId: string) {
} }
const logoMap = { const logoMap = {
o1: OpenAiProviderLogo,
gpt: ChatGPTModelLogo, gpt: ChatGPTModelLogo,
glm: ChatGLMModelLogo, glm: ChatGLMModelLogo,
deepseek: DeepSeekModelLogo, deepseek: DeepSeekModelLogo,
@@ -112,7 +114,8 @@ export function getModelLogo(modelId: string) {
abab: HailuoModelLogo, abab: HailuoModelLogo,
'ep-202': DoubaoModelLogo, 'ep-202': DoubaoModelLogo,
cohere: CohereModelLogo, cohere: CohereModelLogo,
command: CohereModelLogo command: CohereModelLogo,
minicpm: MinicpmModelLogo
} }
for (const key in logoMap) { for (const key in logoMap) {

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import { isLocalAi } from '@renderer/config/env' import { isLocalAi } from '@renderer/config/env'
import db from '@renderer/databases'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime' import { setAvatar } from '@renderer/store/runtime'
import { runAsyncFunction } from '@renderer/utils' import { runAsyncFunction } from '@renderer/utils'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant' import { useDefaultModel } from './useAssistant'
@@ -13,13 +14,11 @@ export function useAppInit() {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { proxyUrl, language } = useSettings() const { proxyUrl, language } = useSettings()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel() const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { avatar?.value && dispatch(setAvatar(avatar.value))
const storedImage = await LocalStorage.getImage('avatar') }, [avatar, dispatch])
storedImage && dispatch(setAvatar(storedImage))
})
}, [dispatch])
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { runAsyncFunction(async () => {

View File

@@ -1,5 +1,4 @@
import { getDefaultTopic } from '@renderer/services/assistant' import { getDefaultTopic } from '@renderer/services/assistant'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
import { import {
addAssistant, addAssistant,
@@ -18,6 +17,8 @@ import {
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm' import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { TopicManager } from './useTopic'
export function useAssistants() { export function useAssistants() {
const { assistants } = useAppSelector((state) => state.assistants) const { assistants } = useAppSelector((state) => state.assistants)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -30,7 +31,7 @@ export function useAssistants() {
dispatch(removeAssistant({ id })) dispatch(removeAssistant({ id }))
const assistant = assistants.find((a) => a.id === id) const assistant = assistants.find((a) => a.id === id)
const topics = assistant?.topics || [] const topics = assistant?.topics || []
topics.forEach(({ id }) => LocalStorage.removeTopic(id)) topics.forEach(({ id }) => TopicManager.removeTopic(id))
} }
} }
} }
@@ -45,7 +46,11 @@ export function useAssistant(id: string) {
model: assistant?.model ?? defaultModel, model: assistant?.model ?? defaultModel,
addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })), addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })),
removeTopic: (topic: Topic) => { removeTopic: (topic: Topic) => {
LocalStorage.removeTopic(topic.id) TopicManager.removeTopic(topic.id)
dispatch(removeTopic({ assistantId: assistant.id, topic }))
},
moveTopic: (topic: Topic, toAssistant: Assistant) => {
dispatch(addTopic({ assistantId: toAssistant.id, topic: { ...topic } }))
dispatch(removeTopic({ assistantId: assistant.id, topic })) dispatch(removeTopic({ assistantId: assistant.id, topic }))
}, },
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })), updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),

View File

@@ -1,3 +1,5 @@
import db from '@renderer/databases'
import { deleteMessageFiles } from '@renderer/services/messages'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { find } from 'lodash' import { find } from 'lodash'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -25,3 +27,38 @@ export function useActiveTopic(_assistant: Assistant) {
export function getTopic(assistant: Assistant, topicId: string) { export function getTopic(assistant: Assistant, topicId: string) {
return assistant?.topics.find((topic) => topic.id === topicId) return assistant?.topics.find((topic) => topic.id === topicId)
} }
export class TopicManager {
static async getTopic(id: string) {
return await db.topics.get(id)
}
static async getTopicMessages(id: string) {
const topic = await this.getTopic(id)
return topic ? topic.messages : []
}
static async removeTopic(id: string) {
const messages = await this.getTopicMessages(id)
for (const message of messages) {
await deleteMessageFiles(message)
}
db.topics.delete(id)
}
static async clearTopicMessages(id: string) {
const topic = await this.getTopic(id)
if (topic) {
for (const message of topic?.messages ?? []) {
await deleteMessageFiles(message)
}
topic.messages = []
await db.topics.update(id, topic)
}
}
}

View File

@@ -55,9 +55,13 @@ const resources = {
'chat.completion.paused': 'Chat completion paused', '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', 'restore.success': 'Restored successfully',
'backup.success': 'Backup successful',
'reset.confirm.content': 'Are you sure you want to clear all data?', 'reset.confirm.content': 'Are you sure you want to clear all data?',
'reset.double.confirm.title': 'DATA LOST !!!', 'reset.double.confirm.title': 'DATA LOST !!!',
'reset.double.confirm.content': 'All data will be lost, do you want to continue?' 'reset.double.confirm.content': 'All data will be lost, do you want to continue?',
'upgrade.success.title': 'Upgrade successfully',
'upgrade.success.content': 'Please restart the application to complete the upgrade',
'upgrade.success.button': 'Restart'
}, },
chat: { chat: {
save: 'Save', save: 'Save',
@@ -66,10 +70,11 @@ const resources = {
'default.topic.name': 'Default Topic', 'default.topic.name': 'Default Topic',
'topics.title': 'Topics', 'topics.title': 'Topics',
'topics.auto_rename': 'Auto Rename', 'topics.auto_rename': 'Auto Rename',
'topics.edit.title': 'Rename', 'topics.edit.title': 'Edit Name',
'topics.edit.placeholder': 'Enter new name', 'topics.edit.placeholder': 'Enter new name',
'topics.delete.all.title': 'Delete all topics', 'topics.delete.all.title': 'Delete all topics',
'topics.delete.all.content': 'Are you sure you want to delete all topics?', 'topics.delete.all.content': 'Are you sure you want to delete all topics?',
'topics.move_to': 'Move to',
'topics.list': 'Topic List', 'topics.list': 'Topic List',
'input.new_topic': 'New Topic', 'input.new_topic': 'New Topic',
'input.topics': ' Topics ', 'input.topics': ' Topics ',
@@ -83,7 +88,7 @@ const resources = {
'input.send': 'Send', 'input.send': 'Send',
'input.pause': 'Pause', 'input.pause': 'Pause',
'input.settings': 'Settings', 'input.settings': 'Settings',
'input.upload': 'Upload image png、jpg、jpeg', 'input.upload': 'Upload image or text file',
'input.context_count.tip': 'Context Count', 'input.context_count.tip': 'Context Count',
'input.estimated_tokens.tip': 'Estimated tokens', 'input.estimated_tokens.tip': 'Estimated tokens',
'settings.temperature': 'Temperature', 'settings.temperature': 'Temperature',
@@ -100,6 +105,7 @@ const resources = {
'suggestions.title': 'Suggested Questions', 'suggestions.title': 'Suggested Questions',
'add.assistant.title': 'Add Assistant', 'add.assistant.title': 'Add Assistant',
'message.new.context': 'New Context', 'message.new.context': 'New Context',
'message.new.branch': 'New Branch',
'assistant.search.placeholder': 'Search' 'assistant.search.placeholder': 'Search'
}, },
files: { files: {
@@ -107,6 +113,7 @@ const resources = {
file: 'File', file: 'File',
name: 'Name', name: 'Name',
size: 'Size', size: 'Size',
count: 'Count',
created_at: 'Created At' created_at: 'Created At'
}, },
agents: { agents: {
@@ -160,6 +167,7 @@ const resources = {
'messages.input.title': 'Input Settings', 'messages.input.title': 'Input Settings',
'messages.input.show_estimated_tokens': 'Show estimated input tokens', 'messages.input.show_estimated_tokens': 'Show estimated input tokens',
'messages.input.send_shortcuts': 'Send shortcuts', 'messages.input.send_shortcuts': 'Send shortcuts',
'messages.input.paste_long_text_as_file': 'Paste long text as file',
'general.title': 'General Settings', 'general.title': 'General Settings',
'general.user_name': 'User Name', 'general.user_name': 'User Name',
'general.user_name.placeholder': 'Enter your name', 'general.user_name.placeholder': 'Enter your name',
@@ -168,6 +176,8 @@ const resources = {
'general.restore.button': 'Restore', 'general.restore.button': 'Restore',
'general.reset.title': 'Data Reset', 'general.reset.title': 'Data Reset',
'general.reset.button': 'Reset', 'general.reset.button': 'Reset',
'advanced.title': 'Advanced Settings',
'advanced.click_assistant_switch_to_topics': 'Auto switch to topic',
'provider.api_key': 'API Key', 'provider.api_key': 'API Key',
'provider.check': 'Check', 'provider.check': 'Check',
'provider.get_api_key': 'Get API Key', 'provider.get_api_key': 'Get API Key',
@@ -317,9 +327,13 @@ const resources = {
'chat.completion.paused': '会话已停止', 'chat.completion.paused': '会话已停止',
'switch.disabled': '模型回复完成后才能切换', 'switch.disabled': '模型回复完成后才能切换',
'restore.success': '恢复成功', 'restore.success': '恢复成功',
'backup.success': '备份成功',
'reset.confirm.content': '确定要重置所有数据吗?', 'reset.confirm.content': '确定要重置所有数据吗?',
'reset.double.confirm.title': '数据丢失!!!', 'reset.double.confirm.title': '数据丢失!!!',
'reset.double.confirm.content': '你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?' 'reset.double.confirm.content': '你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?',
'upgrade.success.title': '升级成功',
'upgrade.success.content': '重启应用以完成升级',
'upgrade.success.button': '重启'
}, },
chat: { chat: {
save: '保存', save: '保存',
@@ -332,6 +346,7 @@ const resources = {
'topics.edit.placeholder': '输入新名称', 'topics.edit.placeholder': '输入新名称',
'topics.delete.all.title': '删除所有话题', 'topics.delete.all.title': '删除所有话题',
'topics.delete.all.content': '确定要删除所有话题吗?', 'topics.delete.all.content': '确定要删除所有话题吗?',
'topics.move_to': '移动到',
'topics.list': '话题列表', 'topics.list': '话题列表',
'input.new_topic': '新话题', 'input.new_topic': '新话题',
'input.topics': ' 话题 ', 'input.topics': ' 话题 ',
@@ -345,7 +360,7 @@ const resources = {
'input.send': '发送', 'input.send': '发送',
'input.pause': '暂停', 'input.pause': '暂停',
'input.settings': '设置', 'input.settings': '设置',
'input.upload': '上传图片 png、jpg、jpeg', 'input.upload': '上传图片或纯文本文件',
'input.context_count.tip': '上下文数', 'input.context_count.tip': '上下文数',
'input.estimated_tokens.tip': '预估 token 数', 'input.estimated_tokens.tip': '预估 token 数',
'settings.temperature': '模型温度', 'settings.temperature': '模型温度',
@@ -363,6 +378,7 @@ const resources = {
'suggestions.title': '建议的问题', 'suggestions.title': '建议的问题',
'add.assistant.title': '添加智能体', 'add.assistant.title': '添加智能体',
'message.new.context': '清除上下文', 'message.new.context': '清除上下文',
'message.new.branch': '新分支',
'assistant.search.placeholder': '搜索' 'assistant.search.placeholder': '搜索'
}, },
files: { files: {
@@ -370,6 +386,7 @@ const resources = {
file: '文件', file: '文件',
name: '文件名', name: '文件名',
size: '大小', size: '大小',
count: '文件数',
created_at: '创建时间' created_at: '创建时间'
}, },
agents: { agents: {
@@ -423,6 +440,7 @@ const resources = {
'messages.input.title': '输入设置', 'messages.input.title': '输入设置',
'messages.input.show_estimated_tokens': '状态显示', 'messages.input.show_estimated_tokens': '状态显示',
'messages.input.send_shortcuts': '发送快捷键', 'messages.input.send_shortcuts': '发送快捷键',
'messages.input.paste_long_text_as_file': '长文本粘贴为文件',
'general.title': '常规设置', 'general.title': '常规设置',
'general.user_name': '用户名', 'general.user_name': '用户名',
'general.user_name.placeholder': '请输入用户名', 'general.user_name.placeholder': '请输入用户名',
@@ -431,6 +449,8 @@ const resources = {
'general.restore.button': '恢复', 'general.restore.button': '恢复',
'general.reset.title': '重置数据', 'general.reset.title': '重置数据',
'general.reset.button': '重置', 'general.reset.button': '重置',
'advanced.title': '高级设置',
'advanced.click_assistant_switch_to_topics': '点击助手切换到话题',
'provider.api_key': 'API 密钥', 'provider.api_key': 'API 密钥',
'provider.check': '检查', 'provider.check': '检查',
'provider.get_api_key': '点击这里获取密钥', 'provider.get_api_key': '点击这里获取密钥',

View File

@@ -1,7 +1,8 @@
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import { VStack } from '@renderer/components/Layout' import { VStack } from '@renderer/components/Layout'
import db from '@renderer/databases' import db from '@renderer/databases'
import { FileType } from '@renderer/types' import { FileType, FileTypes } from '@renderer/types'
import { getFileDirectory } from '@renderer/utils'
import { Image, Table } from 'antd' import { Image, Table } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
@@ -11,15 +12,20 @@ import styled from 'styled-components'
const FilesPage: FC = () => { const FilesPage: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const files = useLiveQuery<FileType[]>(() => db.files.toArray()) const files = useLiveQuery<FileType[]>(() => db.files.orderBy('created_at').reverse().toArray())
const dataSource = files?.map((file) => ({ const dataSource = files?.map((file) => {
key: file.id, const isImage = file.type === FileTypes.IMAGE
file: <Image src={'file://' + file.path} preview={false} style={{ maxHeight: '40px' }} />, const ImageView = <Image src={'file://' + file.path} preview={false} style={{ maxHeight: '40px' }} />
name: <a href={'file://' + file.path}>{file.origin_name}</a>, return {
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`, key: file.id,
created_at: dayjs(file.created_at).format('MM-DD HH:mm') file: isImage ? ImageView : <FileNameText className="text-nowrap">{file.origin_name}</FileNameText>,
})) name: <a href={'file://' + getFileDirectory(file.path)}>{file.origin_name}</a>,
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`,
count: file.count,
created_at: dayjs(file.created_at).format('MM-DD HH:mm')
}
})
const columns = [ const columns = [
{ {
@@ -39,6 +45,12 @@ const FilesPage: FC = () => {
key: 'size', key: 'size',
width: '100px' width: '100px'
}, },
{
title: t('files.count'),
dataIndex: 'count',
key: 'count',
width: '100px'
},
{ {
title: t('files.created_at'), title: t('files.created_at'),
dataIndex: 'created_at', dataIndex: 'created_at',
@@ -79,4 +91,10 @@ const ContentContainer = styled.div`
padding: 20px; padding: 20px;
` `
const FileNameText = styled.div`
font-size: 14px;
color: var(--color-text);
max-width: 300px;
`
export default FilesPage export default FilesPage

View File

@@ -3,6 +3,7 @@ import DragableList from '@renderer/components/DragableList'
import CopyIcon from '@renderer/components/Icons/CopyIcon' import CopyIcon from '@renderer/components/Icons/CopyIcon'
import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup' import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup'
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { getDefaultTopic, syncAsistantToAgent } from '@renderer/services/assistant' import { getDefaultTopic, syncAsistantToAgent } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { useAppDispatch, useAppSelector } from '@renderer/store' import { useAppDispatch, useAppSelector } from '@renderer/store'
@@ -27,6 +28,7 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
const generating = useAppSelector((state) => state.runtime.generating) const generating = useAppSelector((state) => state.runtime.generating)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const { updateAssistant, removeAllTopics } = useAssistant(activeAssistant.id) const { updateAssistant, removeAllTopics } = useAssistant(activeAssistant.id)
const { clickAssistantToShowTopic, topicPosition } = useSettings()
const searchRef = useRef<InputRef>(null) const searchRef = useRef<InputRef>(null)
const { t } = useTranslation() const { t } = useTranslation()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
@@ -103,9 +105,13 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
}) })
} }
if (topicPosition === 'left' && clickAssistantToShowTopic) {
EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)
}
setActiveAssistant(assistant) setActiveAssistant(assistant)
}, },
[generating, setActiveAssistant, t] [clickAssistantToShowTopic, generating, setActiveAssistant, t, topicPosition]
) )
const list = assistants.filter((assistant) => assistant.name?.toLowerCase().includes(search.toLowerCase().trim())) const list = assistants.filter((assistant) => assistant.name?.toLowerCase().includes(search.toLowerCase().trim()))

View File

@@ -1,4 +1,5 @@
import { PaperClipOutlined } from '@ant-design/icons' import { PaperClipOutlined } from '@ant-design/icons'
import { imageExts, textExts } from '@renderer/config/constant'
import { isVisionModel } from '@renderer/config/models' import { isVisionModel } from '@renderer/config/models'
import { FileType, Model } from '@renderer/types' import { FileType, Model } from '@renderer/types'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
@@ -14,18 +15,16 @@ interface Props {
const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton }) => { const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton }) => {
const { t } = useTranslation() const { t } = useTranslation()
const extensions = isVisionModel(model) ? [...imageExts, ...textExts] : [...textExts]
const onSelectFile = async () => { const onSelectFile = async () => {
const _files = await window.api.file.select({ if (files.length > 0) {
filters: [{ name: 'Files', extensions: ['jpg', 'png', 'jpeg'] }] return setFiles([])
}) }
const _files = await window.api.file.select({ filters: [{ name: 'Files', extensions }] })
_files && setFiles(_files) _files && setFiles(_files)
} }
if (!isVisionModel(model)) {
return null
}
return ( return (
<Tooltip placement="top" title={t('chat.input.upload')} arrow> <Tooltip placement="top" title={t('chat.input.upload')} arrow>
<ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile}> <ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile}>

View File

@@ -8,17 +8,20 @@ import {
PauseCircleOutlined, PauseCircleOutlined,
QuestionCircleOutlined QuestionCircleOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import { imageExts, textExts } from '@renderer/config/constant'
import { isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime, useShowTopics } from '@renderer/hooks/useStore' import { useRuntime, useShowTopics } from '@renderer/hooks/useStore'
import { getDefaultTopic } from '@renderer/services/assistant' import { getDefaultTopic } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import FileManager from '@renderer/services/file' import FileManager from '@renderer/services/file'
import { estimateInputTokenCount } from '@renderer/services/messages' import { estimateTextTokens } from '@renderer/services/tokens'
import store, { useAppDispatch, useAppSelector } from '@renderer/store' import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime' import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, FileType, Message, Topic } from '@renderer/types' import { Assistant, FileType, Message, Topic } from '@renderer/types'
import { delay, uuid } from '@renderer/utils' import { delay, getFileExtension, uuid } from '@renderer/utils'
import { Button, Popconfirm, Tooltip } from 'antd' import { Button, Popconfirm, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -43,7 +46,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const [text, setText] = useState(_text) const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false) const [inputFocus, setInputFocus] = useState(false)
const { addTopic, model } = useAssistant(assistant.id) const { addTopic, model } = useAssistant(assistant.id)
const { sendMessageShortcut, fontSize } = useSettings() const { sendMessageShortcut, fontSize, pasteLongTextAsFile } = useSettings()
const [expended, setExpend] = useState(false) const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0) const [estimateTokenCount, setEstimateTokenCount] = useState(0)
const [contextCount, setContextCount] = useState(0) const [contextCount, setContextCount] = useState(0)
@@ -56,6 +59,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const { searching } = useRuntime() const { searching } = useRuntime()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...(isVision ? imageExts : [])], [isVision])
_text = text _text = text
const sendMessage = useCallback(async () => { const sendMessage = useCallback(async () => {
@@ -91,7 +97,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
setExpend(false) setExpend(false)
}, [assistant.id, assistant.topics, generating, files, text]) }, [assistant.id, assistant.topics, generating, files, text])
const inputTokenCount = useMemo(() => estimateInputTokenCount(text), [text]) const inputTokenCount = useMemo(() => estimateTextTokens(text), [text])
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
const isEnterPressed = event.keyCode == 13 const isEnterPressed = event.keyCode == 13
@@ -120,6 +126,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const topic = getDefaultTopic() const topic = getDefaultTopic()
addTopic(topic) addTopic(topic)
setActiveTopic(topic) setActiveTopic(topic)
db.topics.add({ id: topic.id, messages: [] })
}, [addTopic, setActiveTopic]) }, [addTopic, setActiveTopic])
const clearTopic = async () => { const clearTopic = async () => {
@@ -169,6 +176,53 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const onInput = () => !expended && resizeTextArea() const onInput = () => !expended && resizeTextArea()
const onPaste = useCallback(
async (event: ClipboardEvent) => {
for (const file of event.clipboardData?.files || []) {
event.preventDefault()
if (file.path === '') {
if (file.type.startsWith('image/')) {
const tempFilePath = await window.api.file.create(file.name)
const arrayBuffer = await file.arrayBuffer()
const uint8Array = new Uint8Array(arrayBuffer)
await window.api.file.write(tempFilePath, uint8Array)
const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
break
}
}
if (file.path) {
if (supportExts.includes(getFileExtension(file.path))) {
const selectedFile = await window.api.file.get(file.path)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
}
}
}
if (pasteLongTextAsFile) {
const item = event.clipboardData?.items[0]
if (item && item.kind === 'string' && item.type === 'text/plain') {
event.preventDefault()
item.getAsString(async (text) => {
if (text.length > 1500) {
console.debug(item.getAsFile())
const tempFilePath = await window.api.file.create('pasted_text.txt')
await window.api.file.write(tempFilePath, text)
const selectedFile = await window.api.file.get(tempFilePath)
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
} else {
setText((prevText) => prevText + text)
setTimeout(() => resizeTextArea(), 0)
}
})
}
}
},
[supportExts, pasteLongTextAsFile]
)
// Command or Ctrl + N create new topic // Command or Ctrl + N create new topic
useEffect(() => { useEffect(() => {
const onKeydown = (e) => { const onKeydown = (e) => {
@@ -190,6 +244,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
EventEmitter.on(EVENT_NAMES.EDIT_MESSAGE, (message: Message) => { EventEmitter.on(EVENT_NAMES.EDIT_MESSAGE, (message: Message) => {
setText(message.content) setText(message.content)
textareaRef.current?.focus() textareaRef.current?.focus()
setTimeout(() => resizeTextArea(), 0)
}), }),
EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => { EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => {
_setEstimateTokenCount(tokensCount) _setEstimateTokenCount(tokensCount)
@@ -203,6 +258,11 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
textareaRef.current?.focus() textareaRef.current?.focus()
}, [assistant]) }, [assistant])
useEffect(() => {
document.addEventListener('paste', onPaste)
return () => document.removeEventListener('paste', onPaste)
}, [onPaste])
return ( return (
<Container> <Container>
<AttachmentPreview files={files} setFiles={setFiles} /> <AttachmentPreview files={files} setFiles={setFiles} />

View File

@@ -44,8 +44,8 @@ const TokenCount: FC<Props> = ({ estimateTokenCount, inputTokenCount, contextCou
<PicCenterOutlined /> <PicCenterOutlined />
</Tooltip> </Tooltip>
</ToolbarButton> </ToolbarButton>
<Container {...props}> <Container>
<Popover content={PopoverContent} title="" mouseEnterDelay={0.6}> <Popover content={PopoverContent}>
<MenuOutlined /> {contextCount} <MenuOutlined /> {contextCount}
<Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} /> <Divider type="vertical" style={{ marginTop: 0, marginLeft: 5, marginRight: 5 }} />
<ArrowUpOutlined /> <ArrowUpOutlined />

View File

@@ -3,7 +3,7 @@ import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { initMermaid } from '@renderer/init' import { initMermaid } from '@renderer/init'
import { ThemeMode } from '@renderer/types' import { ThemeMode } from '@renderer/types'
import React, { useState } from 'react' import React, { memo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism' import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
@@ -17,34 +17,23 @@ interface CodeBlockProps {
[key: string]: any [key: string]: any
} }
const CodeBlock: React.FC<CodeBlockProps> = ({ children, className, ...rest }) => { const CodeBlock: React.FC<CodeBlockProps> = ({ children, className }) => {
const match = /language-(\w+)/.exec(className || '') const match = /language-(\w+)/.exec(className || '')
const [copied, setCopied] = useState(false) const showFooterCopyButton = children && children.length > 500
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation()
const onCopy = () => {
navigator.clipboard.writeText(children)
window.message.success({ content: t('message.copied'), key: 'copy-code' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (match && match[1] === 'mermaid') { if (match && match[1] === 'mermaid') {
initMermaid(theme) initMermaid(theme)
return <Mermaid chart={children} /> return <Mermaid chart={children} />
} }
return match ? ( return match ? (
<> <div className="code-block">
<CodeHeader> <CodeHeader>
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage> <CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
{!copied && <CopyIcon className="copy" onClick={onCopy} />} <CopyButton text={children} />
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</CodeHeader> </CodeHeader>
<SyntaxHighlighter <SyntaxHighlighter
{...rest}
language={match[1]} language={match[1]}
style={theme === ThemeMode.dark ? atomDark : oneLight} style={theme === ThemeMode.dark ? atomDark : oneLight}
wrapLongLines={true} wrapLongLines={true}
@@ -56,11 +45,32 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className, ...rest }) =
}}> }}>
{String(children).replace(/\n$/, '')} {String(children).replace(/\n$/, '')}
</SyntaxHighlighter> </SyntaxHighlighter>
</> {showFooterCopyButton && (
<CodeFooter>
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
</CodeFooter>
)}
</div>
) : ( ) : (
<code {...rest} className={className}> <code className={className}>{children}</code>
{children} )
</code> }
const CopyButton: React.FC<{ text: string; style?: React.CSSProperties }> = ({ text, style }) => {
const [copied, setCopied] = useState(false)
const { t } = useTranslation()
const onCopy = () => {
navigator.clipboard.writeText(text)
window.message.success({ content: t('message.copied'), key: 'copy-code' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return copied ? (
<CheckOutlined style={{ color: 'var(--color-primary)', ...style }} />
) : (
<CopyIcon className="copy" style={style} onClick={onCopy} />
) )
} }
@@ -90,4 +100,19 @@ const CodeLanguage = styled.div`
font-weight: bold; font-weight: bold;
` `
export default CodeBlock const CodeFooter = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
.copy {
cursor: pointer;
color: var(--color-text-3);
transition: color 0.3s;
}
.copy:hover {
color: var(--color-text-1);
}
`
export default memo(CodeBlock)

View File

@@ -4,7 +4,7 @@ import { Message } from '@renderer/types'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { FC, useMemo } from 'react' import { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown' import ReactMarkdown, { Components } from 'react-markdown'
import rehypeKatex from 'rehype-katex' import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math' import remarkMath from 'remark-math'
@@ -16,6 +16,14 @@ interface Props {
message: Message message: Message
} }
const rehypePlugins = [rehypeKatex]
const remarkPlugins = [remarkMath, remarkGfm]
const components = {
code: CodeBlock,
a: Link
}
const Markdown: FC<Props> = ({ message }) => { const Markdown: FC<Props> = ({ message }) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -23,25 +31,54 @@ const Markdown: FC<Props> = ({ message }) => {
const empty = isEmpty(message.content) const empty = isEmpty(message.content)
const paused = message.status === 'paused' const paused = message.status === 'paused'
const content = empty && paused ? t('message.chat.completion.paused') : message.content const content = empty && paused ? t('message.chat.completion.paused') : message.content
return content return escapeBrackets(escapeDollarNumber(content))
}, [message.content, message.status, t]) }, [message.content, message.status, t])
return useMemo(() => { return (
return ( <ReactMarkdown
<ReactMarkdown className="markdown"
className="markdown" rehypePlugins={rehypePlugins}
rehypePlugins={[rehypeKatex]} remarkPlugins={remarkPlugins}
remarkPlugins={[remarkMath, remarkGfm]} components={components as Partial<Components>}
remarkRehypeOptions={{ remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'), footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4', footnoteLabelTagName: 'h4',
footnoteBackContent: ' ' footnoteBackContent: ' '
}} }}>
components={{ code: CodeBlock as any, a: Link as any }}> {messageContent}
{messageContent} </ReactMarkdown>
</ReactMarkdown> )
) }
}, [messageContent, t])
function escapeDollarNumber(text: string) {
let escapedText = ''
for (let i = 0; i < text.length; i += 1) {
let char = text[i]
const nextChar = text[i + 1] || ' '
if (char === '$' && nextChar >= '0' && nextChar <= '9') {
char = '\\$'
}
escapedText += char
}
return escapedText
}
function escapeBrackets(text: string) {
const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g
return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
if (codeBlock) {
return codeBlock
} else if (squareBracket) {
return `$$${squareBracket}$$`
} else if (roundBracket) {
return `$${roundBracket}$`
}
return match
})
} }
export default Markdown export default Markdown

View File

@@ -2,6 +2,7 @@ import {
CheckOutlined, CheckOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
ForkOutlined,
MenuOutlined, MenuOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
SaveOutlined, SaveOutlined,
@@ -9,13 +10,13 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import UserPopup from '@renderer/components/Popups/UserPopup' import UserPopup from '@renderer/components/Popups/UserPopup'
import { FONT_FAMILY } from '@renderer/config/constant' import { FONT_FAMILY } from '@renderer/config/constant'
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { startMinAppById } from '@renderer/config/minapp' import { startMinAppById } from '@renderer/config/minapp'
import { getModelLogo } from '@renderer/config/provider' import { getModelLogo } from '@renderer/config/provider'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import useAvatar from '@renderer/hooks/useAvatar' import useAvatar from '@renderer/hooks/useAvatar'
import { useModel } from '@renderer/hooks/useModel' import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { Message, Model } from '@renderer/types' import { Message, Model } from '@renderer/types'
import { firstLetter, removeLeadingEmoji, removeTrailingDoubleSpaces } from '@renderer/utils' import { firstLetter, removeLeadingEmoji, removeTrailingDoubleSpaces } from '@renderer/utils'
@@ -29,6 +30,7 @@ import styled from 'styled-components'
import SelectModelDropdown from '../components/SelectModelDropdown' import SelectModelDropdown from '../components/SelectModelDropdown'
import Markdown from '../Markdown/Markdown' import Markdown from '../Markdown/Markdown'
import MessageAttachments from './MessageAttachments' import MessageAttachments from './MessageAttachments'
import MessgeTokens from './MessageTokens'
interface Props { interface Props {
message: Message message: Message
@@ -44,14 +46,12 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
const { assistant, setModel } = useAssistant(message.assistantId) const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(message.modelId) const model = useModel(message.modelId)
const { userName, showMessageDivider, messageFont, fontSize } = useSettings() const { userName, showMessageDivider, messageFont, fontSize } = useSettings()
const { generating } = useRuntime()
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const isLastMessage = index === 0 const isLastMessage = index === 0
const isUserMessage = message.role === 'user' const isUserMessage = message.role === 'user'
const isAssistantMessage = message.role === 'assistant' const isAssistantMessage = message.role === 'assistant'
const canRegenerate = isLastMessage && isAssistantMessage const canRegenerate = isLastMessage && isAssistantMessage
const showMetadata = Boolean(message.usage) && !generating
const onCopy = useCallback(() => { const onCopy = useCallback(() => {
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content)) navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
@@ -70,19 +70,29 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
[setModel] [setModel]
) )
const onNewBranch = useCallback(() => {
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
}, [index])
const getUserName = useCallback(() => { const getUserName = useCallback(() => {
if (message.id === 'assistant') return assistant?.name if (isLocalAi && message.role !== 'user') return APP_NAME
if (message.role === 'assistant') return upperFirst(model?.name || model?.id) if (message.role === 'assistant') return upperFirst(model?.name || model?.id)
return userName || t('common.you') return userName || t('common.you')
}, [assistant?.name, message.id, message.role, model?.id, model?.name, t, userName]) }, [message.role, model?.id, model?.name, t, userName])
const fontFamily = useMemo(() => { const fontFamily = useMemo(() => {
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
}, [messageFont]) }, [messageFont])
const messageBorder = showMessageDivider ? undefined : 'none' const messageBorder = showMessageDivider ? undefined : 'none'
const avatarSource = useMemo(() => (message.modelId ? getModelLogo(message.modelId) : undefined), [message.modelId])
const avatarSource = useMemo(() => {
if (isLocalAi) return AppLogo
return message.modelId ? getModelLogo(message.modelId) : undefined
}, [message.modelId])
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name]) const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName]) const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
const dropdownItems = useMemo( const dropdownItems = useMemo(
@@ -100,34 +110,6 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
[t, message] [t, message]
) )
const MessageItem = useCallback(() => {
if (message.status === 'sending') {
return (
<MessageContentLoading>
<SyncOutlined spin size={24} />
</MessageContentLoading>
)
}
if (message.status === 'error') {
return (
<Alert
message={<div style={{ fontSize: 14 }}>{t('error.chat.response')}</div>}
description={<Markdown message={message} />}
type="error"
style={{ marginBottom: 15, padding: 10, fontSize: 12 }}
/>
)
}
return (
<>
<Markdown message={message} />
<MessageAttachments message={message} />
</>
)
}, [message, t])
const showMiniApp = () => model?.provider && startMinAppById(model?.provider) const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
if (message.type === 'clear') { if (message.type === 'clear') {
@@ -146,7 +128,11 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
<Avatar <Avatar
src={avatarSource} src={avatarSource}
size={35} size={35}
style={{ borderRadius: '20%', cursor: 'pointer' }} style={{
borderRadius: '20%',
cursor: 'pointer',
border: '1px solid var(--color-border)'
}}
onClick={showMiniApp}> onClick={showMiniApp}>
{avatarName} {avatarName}
</Avatar> </Avatar>
@@ -164,11 +150,12 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
</UserWrap> </UserWrap>
</AvatarWrapper> </AvatarWrapper>
</MessageHeader> </MessageHeader>
<MessageContent style={{ fontFamily, fontSize }}> <MessageContentContainer style={{ fontFamily, fontSize }}>
<MessageItem /> <MessageContent message={message} />
<MessageFooter style={{ border: messageBorder }}> <MessageFooter style={{ border: messageBorder, flexDirection: isLastMessage ? 'row-reverse' : undefined }}>
<MessgeTokens message={message} />
{showMenu && ( {showMenu && (
<MenusBar className={`menubar ${isLastMessage && 'show'} ${(!isLastMessage || isUserMessage) && 'user'}`}> <MenusBar className={`menubar ${isLastMessage && 'show'}`}>
{message.role === 'user' && ( {message.role === 'user' && (
<Tooltip title="Edit" mouseEnterDelay={0.8}> <Tooltip title="Edit" mouseEnterDelay={0.8}>
<ActionButton onClick={onEdit}> <ActionButton onClick={onEdit}>
@@ -191,6 +178,13 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
</Tooltip> </Tooltip>
</SelectModelDropdown> </SelectModelDropdown>
)} )}
{isAssistantMessage && (
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
<ActionButton onClick={onNewBranch}>
<ForkOutlined />
</ActionButton>
</Tooltip>
)}
<Popconfirm <Popconfirm
title={t('message.message.delete.content')} title={t('message.message.delete.content')}
okButtonProps={{ danger: true }} okButtonProps={{ danger: true }}
@@ -211,18 +205,42 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
)} )}
</MenusBar> </MenusBar>
)} )}
{showMetadata && (
<MessageMetadata>
Tokens: {message?.usage?.total_tokens} | {message?.usage?.prompt_tokens} |
{message?.usage?.completion_tokens}
</MessageMetadata>
)}
</MessageFooter> </MessageFooter>
</MessageContent> </MessageContentContainer>
</MessageContainer> </MessageContainer>
) )
} }
const MessageContent: React.FC<{ message: Message }> = ({ message }) => {
const { t } = useTranslation()
if (message.status === 'sending') {
return (
<MessageContentLoading>
<SyncOutlined spin size={24} />
</MessageContentLoading>
)
}
if (message.status === 'error') {
return (
<Alert
message={<div style={{ fontSize: 14 }}>{t('error.chat.response')}</div>}
description={<Markdown message={message} />}
type="error"
style={{ marginBottom: 15, padding: 10, fontSize: 12 }}
/>
)
}
return (
<>
<Markdown message={message} />
<MessageAttachments message={message} />
</>
)
}
const MessageContainer = styled.div` const MessageContainer = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -279,7 +297,7 @@ const MessageTime = styled.div`
color: var(--color-text-3); color: var(--color-text-3);
` `
const MessageContent = styled.div` const MessageContentContainer = styled.div`
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
@@ -314,13 +332,6 @@ const MenusBar = styled.div`
margin-left: -5px; margin-left: -5px;
` `
const MessageMetadata = styled.div`
font-size: 12px;
color: var(--color-text-2);
user-select: text;
margin: 2px 0;
`
const ActionButton = styled.div` const ActionButton = styled.div`
cursor: pointer; cursor: pointer;
border-radius: 8px; border-radius: 8px;

View File

@@ -1,5 +1,6 @@
import { Message } from '@renderer/types' import { FileTypes, Message } from '@renderer/types'
import { Image as AntdImage } from 'antd' import { getFileDirectory } from '@renderer/utils'
import { Image as AntdImage, Upload } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
@@ -8,9 +9,27 @@ interface Props {
} }
const MessageAttachments: FC<Props> = ({ message }) => { const MessageAttachments: FC<Props> = ({ message }) => {
if (message?.files && message.files[0]?.type === FileTypes.IMAGE) {
return (
<Container>
{message.files?.map((image) => <Image src={'file://' + image.path} key={image.id} width="33%" />)}
</Container>
)
}
return ( return (
<Container> <Container style={{ marginTop: 2 }}>
{message.files?.map((image) => <Image src={'file://' + image.path} key={image.id} width="33%" />)} <Upload
listType="picture"
disabled
onPreview={(item) => item.url && window.open(getFileDirectory(item.url))}
fileList={message.files?.map((file) => ({
uid: file.id,
url: 'file://' + file.path,
status: 'done',
name: file.origin_name
}))}
/>
</Container> </Container>
) )
} }

View File

@@ -0,0 +1,38 @@
import { useRuntime } from '@renderer/hooks/useStore'
import { Message } from '@renderer/types'
import styled from 'styled-components'
const MessgeTokens: React.FC<{ message: Message }> = ({ message }) => {
const { generating } = useRuntime()
if (!message.usage) {
return null
}
if (message.role === 'user') {
return <MessageMetadata>Tokens: {message?.usage?.total_tokens}</MessageMetadata>
}
if (generating) {
return null
}
if (message.role === 'assistant') {
return (
<MessageMetadata>
Tokens: {message?.usage?.total_tokens} | {message?.usage?.prompt_tokens} | {message?.usage?.completion_tokens}
</MessageMetadata>
)
}
return null
}
const MessageMetadata = styled.div`
font-size: 12px;
color: var(--color-text-2);
user-select: text;
margin: 2px 0;
`
export default MessgeTokens

View File

@@ -1,20 +1,15 @@
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useProviderByAssistant } from '@renderer/hooks/useProvider' import { getTopic, TopicManager } from '@renderer/hooks/useTopic'
import { getTopic } from '@renderer/hooks/useTopic'
import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api' import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api'
import { getDefaultTopic } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event' import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { import { deleteMessageFiles, filterMessages, getContextCount } from '@renderer/services/messages'
deleteMessageFiles, import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/tokens'
estimateHistoryTokenCount,
filterMessages,
getContextCount
} from '@renderer/services/messages'
import LocalStorage from '@renderer/services/storage'
import { Assistant, Message, Model, Topic } from '@renderer/types' import { Assistant, Message, Model, Topic } from '@renderer/types'
import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils' import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
import { t } from 'i18next' import { t } from 'i18next'
import localforage from 'localforage' import { flatten, last, reverse, take } from 'lodash'
import { last, reverse } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react' import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
@@ -31,17 +26,25 @@ interface Props {
const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => { const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const [messages, setMessages] = useState<Message[]>([]) const [messages, setMessages] = useState<Message[]>([])
const [lastMessage, setLastMessage] = useState<Message | null>(null) const [lastMessage, setLastMessage] = useState<Message | null>(null)
const provider = useProviderByAssistant(assistant)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const { updateTopic } = useAssistant(assistant.id) const { updateTopic, addTopic } = useAssistant(assistant.id)
const onSendMessage = useCallback( const onSendMessage = useCallback(
(message: Message) => { async (message: Message) => {
if (message.role === 'user') {
estimateMessageUsage(message).then((usage) => {
setMessages((prev) => {
const _messages = prev.map((m) => (m.id === message.id ? { ...m, usage } : m))
db.topics.update(topic.id, { messages: _messages })
return _messages
})
})
}
const _messages = [...messages, message] const _messages = [...messages, message]
setMessages(_messages) setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages }) db.topics.put({ id: topic.id, messages: _messages })
}, },
[messages, topic] [messages, topic.id]
) )
const autoRenameTopic = useCallback(async () => { const autoRenameTopic = useCallback(async () => {
@@ -60,7 +63,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
(message: Message) => { (message: Message) => {
const _messages = messages.filter((m) => m.id !== message.id) const _messages = messages.filter((m) => m.id !== message.id)
setMessages(_messages) setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages }) db.topics.update(topic.id, { messages: _messages })
deleteMessageFiles(message) deleteMessageFiles(message)
}, },
[messages, topic.id] [messages, topic.id]
@@ -69,10 +72,15 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
useEffect(() => { useEffect(() => {
const unsubscribes = [ const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, async (msg: Message) => { EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, async (msg: Message) => {
onSendMessage(msg) await onSendMessage(msg)
fetchChatCompletion({ assistant, messages: [...messages, msg], topic, onResponse: setLastMessage }) fetchChatCompletion({
assistant,
messages: [...messages, msg],
topic,
onResponse: setLastMessage
})
}), }),
EventEmitter.on(EVENT_NAMES.AI_CHAT_COMPLETION, async (msg: Message) => { EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, async (msg: Message) => {
setLastMessage(null) setLastMessage(null)
onSendMessage(msg) onSendMessage(msg)
setTimeout(() => EventEmitter.emit(EVENT_NAMES.AI_AUTO_RENAME), 100) setTimeout(() => EventEmitter.emit(EVENT_NAMES.AI_AUTO_RENAME), 100)
@@ -94,12 +102,13 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => { EventEmitter.on(EVENT_NAMES.CLEAR_MESSAGES, () => {
setMessages([]) setMessages([])
updateTopic({ ...topic, messages: [] }) updateTopic({ ...topic, messages: [] })
LocalStorage.clearTopicMessages(topic.id) TopicManager.clearTopicMessages(topic.id)
}), }),
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => { EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => {
const lastMessage = last(messages) const lastMessage = last(messages)
if (lastMessage && lastMessage.type === 'clear') { if (lastMessage && lastMessage.type === 'clear') {
onDeleteMessage(lastMessage)
return return
} }
@@ -117,14 +126,43 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
status: 'success', status: 'success',
type: 'clear' type: 'clear'
} as Message) } as Message)
}),
EventEmitter.on(EVENT_NAMES.NEW_BRANCH, async (index: number) => {
const newTopic = getDefaultTopic()
newTopic.name = topic.name
const branchMessages = take(messages, messages.length - index)
// 将分支的消息放入数据库
await db.topics.add({ id: newTopic.id, messages: branchMessages })
addTopic(newTopic)
setActiveTopic(newTopic)
autoRenameTopic()
// 由于复制了消息,消息中附带的文件的总数变了,需要更新
const filesArr = branchMessages.map((m) => m.files)
const files = flatten(filesArr).filter(Boolean)
files.map(async (f) => {
const file = await db.files.get({ id: f?.id })
file && db.files.update(file.id, { count: file.count + 1 })
})
}) })
] ]
return () => unsubscribes.forEach((unsub) => unsub()) return () => unsubscribes.forEach((unsub) => unsub())
}, [assistant, messages, provider, topic, autoRenameTopic, updateTopic, onSendMessage]) }, [
addTopic,
assistant,
autoRenameTopic,
messages,
onDeleteMessage,
onSendMessage,
setActiveTopic,
topic,
updateTopic
])
useEffect(() => { useEffect(() => {
runAsyncFunction(async () => { runAsyncFunction(async () => {
const messages = (await LocalStorage.getTopicMessages(topic.id)) || [] const messages = (await TopicManager.getTopicMessages(topic.id)) || []
setMessages(messages) setMessages(messages)
}) })
}, [topic.id]) }, [topic.id])
@@ -134,16 +172,18 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
}, [messages]) }, [messages])
useEffect(() => { useEffect(() => {
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, { runAsyncFunction(async () => {
tokensCount: estimateHistoryTokenCount(assistant, messages), EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, {
contextCount: getContextCount(assistant, messages) tokensCount: await estimateHistoryTokens(assistant, messages),
contextCount: getContextCount(assistant, messages)
})
}) })
}, [assistant, messages]) }, [assistant, messages])
return ( return (
<Container id="messages" key={assistant.id} ref={containerRef}> <Container id="messages" key={assistant.id} ref={containerRef}>
<Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} /> <Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} />
{lastMessage && <MessageItem message={lastMessage} />} {lastMessage && <MessageItem key={lastMessage.id} message={lastMessage} />}
{reverse([...messages]).map((message, index) => ( {reverse([...messages]).map((message, index) => (
<MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} /> <MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} />
))} ))}

View File

@@ -8,6 +8,7 @@ import { useAppDispatch } from '@renderer/store'
import { import {
setFontSize, setFontSize,
setMessageFont, setMessageFont,
setPasteLongTextAsFile,
setShowInputEstimatedTokens, setShowInputEstimatedTokens,
setShowMessageDivider setShowMessageDivider
} from '@renderer/store/settings' } from '@renderer/store/settings'
@@ -33,8 +34,14 @@ const SettingsTab: FC<Props> = (props) => {
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const { showMessageDivider, messageFont, showInputEstimatedTokens, sendMessageShortcut, setSendMessageShortcut } = const {
useSettings() showMessageDivider,
messageFont,
showInputEstimatedTokens,
sendMessageShortcut,
setSendMessageShortcut,
pasteLongTextAsFile
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => { const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
updateAssistantSettings({ updateAssistantSettings({
@@ -210,6 +217,15 @@ const SettingsTab: FC<Props> = (props) => {
/> />
</SettingRow> </SettingRow>
<SettingDivider /> <SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.paste_long_text_as_file')}</SettingRowTitleSmall>
<Switch
size="small"
checked={pasteLongTextAsFile}
onChange={(checked) => dispatch(setPasteLongTextAsFile(checked))}
/>
</SettingRow>
<SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall> <SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall>
</SettingRow> </SettingRow>

View File

@@ -1,9 +1,9 @@
import { CloseOutlined, DeleteOutlined, EditOutlined, OpenAIOutlined } from '@ant-design/icons' import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList' import DragableList from '@renderer/components/DragableList'
import PromptPopup from '@renderer/components/Popups/PromptPopup' import PromptPopup from '@renderer/components/Popups/PromptPopup'
import { useAssistant } from '@renderer/hooks/useAssistant' import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { TopicManager } from '@renderer/hooks/useTopic'
import { fetchMessagesSummary } from '@renderer/services/api' import { fetchMessagesSummary } from '@renderer/services/api'
import LocalStorage from '@renderer/services/storage'
import { useAppSelector } from '@renderer/store' import { useAppSelector } from '@renderer/store'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { Dropdown, MenuProps } from 'antd' import { Dropdown, MenuProps } from 'antd'
@@ -19,7 +19,8 @@ interface Props {
} }
const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic }) => { const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic }) => {
const { assistant, removeTopic, updateTopic, updateTopics } = useAssistant(_assistant.id) const { assistants } = useAssistants()
const { assistant, removeTopic, moveTopic, updateTopic, updateTopics } = useAssistant(_assistant.id)
const { t } = useTranslation() const { t } = useTranslation()
const generating = useAppSelector((state) => state.runtime.generating) const generating = useAppSelector((state) => state.runtime.generating)
@@ -34,6 +35,15 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
[assistant.topics, removeTopic, setActiveTopic] [assistant.topics, removeTopic, setActiveTopic]
) )
const onMoveTopic = useCallback(
(topic: Topic, toAssistant: Assistant) => {
const index = findIndex(assistant.topics, (t) => t.id === topic.id)
setActiveTopic(assistant.topics[index + 1 === assistant.topics.length ? 0 : index + 1])
moveTopic(topic, toAssistant)
},
[assistant.topics, moveTopic, setActiveTopic]
)
const onSwitchTopic = useCallback( const onSwitchTopic = useCallback(
(topic: Topic) => { (topic: Topic) => {
if (generating) { if (generating) {
@@ -51,9 +61,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
{ {
label: t('chat.topics.auto_rename'), label: t('chat.topics.auto_rename'),
key: 'auto-rename', key: 'auto-rename',
icon: <OpenAIOutlined />, icon: <i className="iconfont icon-business-smart-assistant" style={{ fontSize: '14px' }} />,
async onClick() { async onClick() {
const messages = await LocalStorage.getTopicMessages(topic.id) const messages = await TopicManager.getTopicMessages(topic.id)
if (messages.length >= 2) { if (messages.length >= 2) {
const summaryText = await fetchMessagesSummary({ messages, assistant }) const summaryText = await fetchMessagesSummary({ messages, assistant })
if (summaryText) { if (summaryText) {
@@ -79,6 +89,21 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
} }
] ]
if (assistants.length > 1 && assistant.topics.length > 1) {
menus.push({
label: t('chat.topics.move_to'),
key: 'move',
icon: <FolderOutlined />,
children: assistants
.filter((a) => a.id !== assistant.id)
.map((a) => ({
label: a.name,
key: a.id,
onClick: () => onMoveTopic(topic, a)
}))
})
}
if (assistant.topics.length > 1) { if (assistant.topics.length > 1) {
menus.push({ type: 'divider' }) menus.push({ type: 'divider' })
menus.push({ menus.push({
@@ -92,7 +117,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
return menus return menus
}, },
[assistant, onDeleteTopic, t, updateTopic] [assistant, assistants, onDeleteTopic, onMoveTopic, t, updateTopic]
) )
return ( return (

View File

@@ -37,7 +37,7 @@ const Suggestions: FC<Props> = ({ assistant, messages, lastMessage }) => {
useEffect(() => { useEffect(() => {
const unsubscribes = [ const unsubscribes = [
EventEmitter.on(EVENT_NAMES.AI_CHAT_COMPLETION, async (msg: Message) => { EventEmitter.on(EVENT_NAMES.RECEIVE_MESSAGE, async (msg: Message) => {
setLoadingSuggestions(true) setLoadingSuggestions(true)
const _suggestions = await fetchSuggestions({ assistant, messages: [...messages, msg] }) const _suggestions = await fetchSuggestions({ assistant, messages: [...messages, msg] })
if (_suggestions.length) { if (_suggestions.length) {

View File

@@ -5,11 +5,11 @@ import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { backup, reset, restore } from '@renderer/services/backup' import { backup, reset, restore } from '@renderer/services/backup'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { setLanguage, setUserName } from '@renderer/store/settings' import { setClickAssistantToShowTopic, setLanguage } from '@renderer/store/settings'
import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings' import { setProxyUrl as _setProxyUrl } from '@renderer/store/settings'
import { ThemeMode } from '@renderer/types' import { ThemeMode } from '@renderer/types'
import { isValidProxyUrl } from '@renderer/utils' import { isValidProxyUrl } from '@renderer/utils'
import { Button, Input, Select } from 'antd' import { Button, Input, Select, Switch } from 'antd'
import { FC, useState } from 'react' import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -19,10 +19,10 @@ const GeneralSettings: FC = () => {
const { const {
language, language,
proxyUrl: storeProxyUrl, proxyUrl: storeProxyUrl,
userName,
theme, theme,
windowStyle, windowStyle,
topicPosition, topicPosition,
clickAssistantToShowTopic,
setTheme, setTheme,
setWindowStyle, setWindowStyle,
setTopicPosition setTopicPosition
@@ -77,20 +77,22 @@ const GeneralSettings: FC = () => {
]} ]}
/> />
</SettingRow> </SettingRow>
<SettingDivider />
{isMac && ( {isMac && (
<SettingRow> <>
<SettingRowTitle>{t('settings.theme.window.style.title')}</SettingRowTitle> <SettingDivider />
<Select <SettingRow>
defaultValue={windowStyle || 'opaque'} <SettingRowTitle>{t('settings.theme.window.style.title')}</SettingRowTitle>
style={{ width: 180 }} <Select
onChange={setWindowStyle} defaultValue={windowStyle || 'opaque'}
options={[ style={{ width: 180 }}
{ value: 'transparent', label: t('settings.theme.window.style.transparent') }, onChange={setWindowStyle}
{ value: 'opaque', label: t('settings.theme.window.style.opaque') } options={[
]} { value: 'transparent', label: t('settings.theme.window.style.transparent') },
/> { value: 'opaque', label: t('settings.theme.window.style.opaque') }
</SettingRow> ]}
/>
</SettingRow>
</>
)} )}
<SettingDivider /> <SettingDivider />
<SettingRow> <SettingRow>
@@ -106,17 +108,18 @@ const GeneralSettings: FC = () => {
/> />
</SettingRow> </SettingRow>
<SettingDivider /> <SettingDivider />
<SettingRow> {topicPosition === 'left' && (
<SettingRowTitle>{t('settings.general.user_name')}</SettingRowTitle> <>
<Input <SettingRow style={{ minHeight: 32 }}>
placeholder={t('settings.general.user_name.placeholder')} <SettingRowTitle>{t('settings.advanced.click_assistant_switch_to_topics')}</SettingRowTitle>
value={userName} <Switch
onChange={(e) => dispatch(setUserName(e.target.value))} checked={clickAssistantToShowTopic}
style={{ width: 180 }} onChange={(checked) => dispatch(setClickAssistantToShowTopic(checked))}
maxLength={30} />
/> </SettingRow>
</SettingRow> <SettingDivider />
<SettingDivider /> </>
)}
<SettingRow> <SettingRow>
<SettingRowTitle>{t('settings.proxy.title')}</SettingRowTitle> <SettingRowTitle>{t('settings.proxy.title')}</SettingRowTitle>
<Input <Input

View File

@@ -10,12 +10,8 @@ export default class AiProvider {
this.sdk = ProviderFactory.create(provider) this.sdk = ProviderFactory.create(provider)
} }
public async completions( public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
messages: Message[], return this.sdk.completions({ messages, assistant, onChunk, onFilterMessages })
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void> {
return this.sdk.completions(messages, assistant, onChunk)
} }
public async translate(message: Message, assistant: Assistant): Promise<string> { public async translate(message: Message, assistant: Assistant): Promise<string> {

View File

@@ -4,8 +4,8 @@ import { DEFAULT_MAX_TOKENS } from '@renderer/config/constant'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event' import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages' import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types' import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types'
import { first, sum, takeRight } from 'lodash' import { first, flatten, sum, takeRight } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
import BaseProvider from './BaseProvider' import BaseProvider from './BaseProvider'
@@ -18,49 +18,52 @@ export default class AnthropicProvider extends BaseProvider {
this.sdk = new Anthropic({ apiKey: provider.apiKey, baseURL: this.getBaseURL() }) this.sdk = new Anthropic({ apiKey: provider.apiKey, baseURL: this.getBaseURL() })
} }
private async getMessageContent(message: Message): Promise<MessageParam['content']> { private async getMessageParam(message: Message): Promise<MessageParam> {
const file = first(message.files) const parts: MessageParam['content'] = [{ type: 'text', text: message.content }]
if (!file) { for (const file of message.files || []) {
return message.content if (file.type === FileTypes.IMAGE) {
} const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
if (file.type === 'image') {
const base64Data = await window.api.image.base64(file.path)
return [
{ type: 'text', text: message.content },
{
type: 'image', type: 'image',
source: { source: {
data: base64Data.base64, data: base64Data.base64,
media_type: base64Data.mime.replace('jpg', 'jpeg') as any, media_type: base64Data.mime.replace('jpg', 'jpeg') as any,
type: 'base64' type: 'base64'
} }
} })
] }
if (file.type === FileTypes.TEXT) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
})
}
} }
return message.content return {
role: message.role,
content: parts
}
} }
public async completions( public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) {
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
) {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant) const { contextCount, maxTokens } = getAssistantSettings(assistant)
const userMessages: MessageParam[] = [] const userMessagesParams: MessageParam[] = []
const _messages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 2)))
for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 2)))) { onFilterMessages(_messages)
userMessages.push({
role: message.role, for (const message of _messages) {
content: await this.getMessageContent(message) userMessagesParams.push(await this.getMessageParam(message))
})
} }
const userMessages = flatten(userMessagesParams)
if (first(userMessages)?.role === 'assistant') { if (first(userMessages)?.role === 'assistant') {
userMessages.shift() userMessages.shift()
} }
@@ -69,7 +72,7 @@ export default class AnthropicProvider extends BaseProvider {
const stream = this.sdk.messages const stream = this.sdk.messages
.stream({ .stream({
model: model.id, model: model.id,
messages: userMessages.filter(Boolean) as MessageParam[], messages: userMessages,
max_tokens: maxTokens || DEFAULT_MAX_TOKENS, max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
temperature: assistant?.settings?.temperature, temperature: assistant?.settings?.temperature,
system: assistant.prompt, system: assistant.prompt,

View File

@@ -20,11 +20,7 @@ export default abstract class BaseProvider {
return this.provider.id === 'ollama' ? getOllamaKeepAliveTime() : undefined return this.provider.id === 'ollama' ? getOllamaKeepAliveTime() : undefined
} }
abstract completions( abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string> abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string> abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>

View File

@@ -1,10 +1,10 @@
import { Content, GoogleGenerativeAI, InlineDataPart, Part } from '@google/generative-ai' import { Content, GoogleGenerativeAI, InlineDataPart, Part, TextPart } from '@google/generative-ai'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event' import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages' import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types' import { Assistant, FileTypes, Message, Provider, Suggestion } from '@renderer/types'
import axios from 'axios' import axios from 'axios'
import { first, isEmpty, takeRight } from 'lodash' import { isEmpty, takeRight } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
import BaseProvider from './BaseProvider' import BaseProvider from './BaseProvider'
@@ -17,42 +17,50 @@ export default class GeminiProvider extends BaseProvider {
this.sdk = new GoogleGenerativeAI(provider.apiKey) this.sdk = new GoogleGenerativeAI(provider.apiKey)
} }
private async getMessageParts(message: Message): Promise<Part[]> { private async getMessageContents(message: Message): Promise<Content> {
const file = first(message.files) const role = message.role === 'user' ? 'user' : 'model'
if (file && file.type === 'image') { const parts: Part[] = [{ text: message.content }]
const base64Data = await window.api.image.base64(file.path)
return [ for (const file of message.files || []) {
{ if (file.type === FileTypes.IMAGE) {
text: message.content const base64Data = await window.api.file.base64Image(file.id + file.ext)
}, parts.push({
{
inlineData: { inlineData: {
data: base64Data.base64, data: base64Data.base64,
mimeType: base64Data.mime mimeType: base64Data.mime
} }
} as InlineDataPart } as InlineDataPart)
] }
if (file.type === FileTypes.TEXT) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
text: file.origin_name + '\n' + fileContent
} as TextPart)
}
} }
return [{ text: message.content }] return {
role,
parts
}
} }
public async completions( public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) {
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
) {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant) const { contextCount, maxTokens } = getAssistantSettings(assistant)
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1))).map((message) => { const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))
return { onFilterMessages(userMessages)
role: message.role,
message const userLastMessage = userMessages.pop()
}
}) const history: Content[] = []
for (const message of userMessages) {
history.push(await this.getMessageContents(message))
}
const geminiModel = this.sdk.getGenerativeModel({ const geminiModel = this.sdk.getGenerativeModel({
model: model.id, model: model.id,
@@ -63,21 +71,9 @@ export default class GeminiProvider extends BaseProvider {
} }
}) })
const userLastMessage = userMessages.pop()
const history: Content[] = []
for (const message of userMessages) {
history.push({
role: message.role === 'user' ? 'user' : 'model',
parts: await this.getMessageParts(message.message)
})
}
const chat = geminiModel.startChat({ history }) const chat = geminiModel.startChat({ history })
const message = await this.getMessageParts(userLastMessage?.message!) const messageContents = await this.getMessageContents(userLastMessage!)
const userMessagesStream = await chat.sendMessageStream(messageContents.parts)
const userMessagesStream = await chat.sendMessageStream(message)
for await (const chunk of userMessagesStream.stream) { for await (const chunk of userMessagesStream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break

View File

@@ -1,8 +1,9 @@
import { isLocalAi } from '@renderer/config/env' import { isLocalAi } from '@renderer/config/env'
import { isVisionModel } from '@renderer/config/models'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant' import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event' import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages' import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types' import { Assistant, FileTypes, Message, Model, Provider, Suggestion } from '@renderer/types'
import { removeQuotes } from '@renderer/utils' import { removeQuotes } from '@renderer/utils'
import { first, takeRight } from 'lodash' import { first, takeRight } from 'lodash'
import OpenAI from 'openai' import OpenAI from 'openai'
@@ -26,34 +27,57 @@ export default class OpenAIProvider extends BaseProvider {
}) })
} }
private async getMessageContent(message: Message): Promise<string | ChatCompletionContentPart[]> { private isSupportStreamOutput(modelId: string): boolean {
const file = first(message.files) if (this.provider.id === 'openai' && modelId.includes('o1-')) {
return false
if (!file) {
return message.content
} }
return true
if (file.type === 'image') {
const base64Data = await window.api.image.base64(file.path)
return [
{ type: 'text', text: message.content },
{
type: 'image_url',
image_url: {
url: base64Data.data
}
}
]
}
return message.content
} }
async completions( private async getMessageParam(
messages: Message[], message: Message,
assistant: Assistant, model: Model
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void ): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
): Promise<void> { const isVision = isVisionModel(model)
if (message.role !== 'user') {
return {
role: message.role,
content: message.content
}
}
const parts: ChatCompletionContentPart[] = [
{
type: 'text',
text: message.content
}
]
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE && isVision) {
const image = await window.api.file.base64Image(file.id + file.ext)
parts.push({
type: 'image_url',
image_url: { url: image.data }
})
}
if (file.type === FileTypes.TEXT) {
const fileContent = await (await window.api.file.read(file.id + file.ext)).trim()
parts.push({
type: 'text',
text: file.origin_name + '\n' + fileContent
})
}
}
return {
role: message.role,
content: parts
} as ChatCompletionMessageParam
}
async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void> {
const defaultModel = getDefaultModel() const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant) const { contextCount, maxTokens } = getAssistantSettings(assistant)
@@ -61,26 +85,32 @@ export default class OpenAIProvider extends BaseProvider {
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
const userMessages: ChatCompletionMessageParam[] = [] const userMessages: ChatCompletionMessageParam[] = []
for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))) { const _messages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))
userMessages.push({ onFilterMessages(_messages)
role: message.role,
content: await this.getMessageContent(message) for (const message of _messages) {
} as ChatCompletionMessageParam) userMessages.push(await this.getMessageParam(message, model))
} }
// @ts-ignore key is not typed // @ts-ignore key is not typed
const stream = await this.sdk.chat.completions.create({ const stream = await this.sdk.chat.completions.create({
model: model.id, model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[], messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
stream: true, stream: this.isSupportStreamOutput(model.id),
temperature: assistant?.settings?.temperature, temperature: assistant?.settings?.temperature,
max_tokens: maxTokens, max_tokens: maxTokens,
keep_alive: this.keepAliveTime keep_alive: this.keepAliveTime
}) })
for await (const chunk of stream) { for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
onChunk({ text: chunk.choices[0]?.delta?.content || '', usage: chunk.usage }) break
}
onChunk({
text: chunk.choices[0]?.delta?.content || '',
usage: chunk.usage
})
} }
} }

11
src/renderer/src/providers/index.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
interface ChunkCallbackData {
text?: string
usage?: OpenAI.Completions.CompletionUsage
}
interface CompletionsParams {
messages: Message[]
assistant: Assistant
onChunk: ({ text, usage }: ChunkCallbackData) => void
onFilterMessages: (messages: Message[]) => void
}

View File

@@ -16,6 +16,7 @@ import {
} from './assistant' } from './assistant'
import { EVENT_NAMES, EventEmitter } from './event' import { EVENT_NAMES, EventEmitter } from './event'
import { filterMessages } from './messages' import { filterMessages } from './messages'
import { estimateMessagesUsage } from './tokens'
export async function fetchChatCompletion({ export async function fetchChatCompletion({
messages, messages,
@@ -61,12 +62,27 @@ export async function fetchChatCompletion({
}, 1000) }, 1000)
try { try {
await AI.completions(messages, assistant, ({ text, usage }) => { let _messages: Message[] = []
message.content = message.content + text || ''
message.usage = usage await AI.completions({
onResponse({ ...message, status: 'pending' }) messages,
assistant,
onFilterMessages: (messages) => (_messages = messages),
onChunk: ({ text, usage }) => {
message.content = message.content + text || ''
message.usage = usage
onResponse({ ...message, status: 'pending' })
}
}) })
message.status = 'success' message.status = 'success'
if (!message.usage) {
message.usage = await estimateMessagesUsage({
assistant,
messages: [..._messages, message]
})
}
} catch (error: any) { } catch (error: any) {
message.content = `Error: ${error.message}` message.content = `Error: ${error.message}`
message.status = 'error' message.status = 'error'
@@ -82,7 +98,7 @@ export async function fetchChatCompletion({
message.status = window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED) ? 'paused' : message.status message.status = window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED) ? 'paused' : message.status
// Emit chat completion event // Emit chat completion event
EventEmitter.emit(EVENT_NAMES.AI_CHAT_COMPLETION, message) EventEmitter.emit(EVENT_NAMES.RECEIVE_MESSAGE, message)
// Reset generating state // Reset generating state
store.dispatch(setGenerating(false)) store.dispatch(setGenerating(false))

View File

@@ -1,31 +1,26 @@
import db from '@renderer/databases'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import localforage from 'localforage' import localforage from 'localforage'
export async function backup() { export async function backup() {
const indexedKeys = await localforage.keys() const version = 2
const version = 1
const time = new Date().getTime() const time = new Date().getTime()
const data = { const data = {
time, time,
version, version,
localStorage, localStorage,
indexedDB: [] as { key: string; value: any }[] indexedDB: await backupDatabase()
}
for (const key of indexedKeys) {
data.indexedDB.push({
key,
value: await localforage.getItem(key)
})
} }
const filename = `cherry-studio.${dayjs().format('YYYYMMDD')}.bak` const filename = `cherry-studio.${dayjs().format('YYYYMMDD')}.bak`
const fileContnet = JSON.stringify(data) const fileContnet = JSON.stringify(data)
const file = await window.api.compress(fileContnet) const file = await window.api.compress(fileContnet)
window.api.saveFile(filename, file) await window.api.saveFile(filename, file)
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
} }
export async function restore() { export async function restore() {
@@ -37,17 +32,32 @@ export async function restore() {
const data = JSON.parse(content) const data = JSON.parse(content)
if (data.version === 1) { if (data.version === 1) {
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) await clearDatabase()
for (const { key, value } of data.indexedDB) { for (const { key, value } of data.indexedDB) {
await localforage.setItem(key, value) if (key.startsWith('topic:')) {
await db.table('topics').add({ id: value.id, messages: value.messages })
}
if (key === 'image://avatar') {
await db.table('settings').add({ id: key, value })
}
} }
await localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' }) window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' })
setTimeout(() => window.api.reload(), 1500) setTimeout(() => window.api.reload(), 1000)
} else { return
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
} }
if (data.version === 2) {
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
await restoreDatabase(data.indexedDB)
window.message.success({ content: i18n.t('message.restore.success'), key: 'restore' })
setTimeout(() => window.api.reload(), 1000)
return
}
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
} catch (error) { } catch (error) {
console.error(error) console.error(error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' }) window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
@@ -59,6 +69,7 @@ export async function reset() {
window.modal.confirm({ window.modal.confirm({
title: i18n.t('common.warning'), title: i18n.t('common.warning'),
content: i18n.t('message.reset.confirm.content'), content: i18n.t('message.reset.confirm.content'),
centered: true,
onOk: async () => { onOk: async () => {
window.modal.confirm({ window.modal.confirm({
title: i18n.t('message.reset.double.confirm.title'), title: i18n.t('message.reset.double.confirm.title'),
@@ -67,9 +78,43 @@ export async function reset() {
onOk: async () => { onOk: async () => {
await localStorage.clear() await localStorage.clear()
await localforage.clear() await localforage.clear()
await clearDatabase()
await window.api.file.clear()
window.api.reload() window.api.reload()
} }
}) })
} }
}) })
} }
/************************************* Backup Utils ************************************** */
async function backupDatabase() {
const tables = db.tables
const backup = {}
for (const table of tables) {
backup[table.name] = await table.toArray()
}
return backup
}
async function restoreDatabase(backup: Record<string, any>) {
await db.transaction('rw', db.tables, async () => {
for (const tableName in backup) {
await db.table(tableName).clear()
await db.table(tableName).bulkAdd(backup[tableName])
}
})
}
async function clearDatabase() {
const storeNames = await db.tables.map((table) => table.name)
await db.transaction('rw', db.tables, async () => {
for (const storeName of storeNames) {
await db[storeName].clear()
}
})
}

View File

@@ -4,7 +4,7 @@ export const EventEmitter = new Emittery()
export const EVENT_NAMES = { export const EVENT_NAMES = {
SEND_MESSAGE: 'SEND_MESSAGE', SEND_MESSAGE: 'SEND_MESSAGE',
AI_CHAT_COMPLETION: 'AI_CHAT_COMPLETION', RECEIVE_MESSAGE: 'RECEIVE_MESSAGE',
AI_AUTO_RENAME: 'AI_AUTO_RENAME', AI_AUTO_RENAME: 'AI_AUTO_RENAME',
CLEAR_MESSAGES: 'CLEAR_MESSAGES', CLEAR_MESSAGES: 'CLEAR_MESSAGES',
ADD_ASSISTANT: 'ADD_ASSISTANT', ADD_ASSISTANT: 'ADD_ASSISTANT',
@@ -16,5 +16,6 @@ export const EVENT_NAMES = {
SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS', SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS',
SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR', SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR',
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR', SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR',
NEW_CONTEXT: 'NEW_CONTEXT' NEW_CONTEXT: 'NEW_CONTEXT',
NEW_BRANCH: 'NEW_BRANCH'
} }

View File

@@ -1,9 +1,7 @@
import { DEFAULT_CONEXTCOUNT } from '@renderer/config/constant' import { DEFAULT_CONEXTCOUNT } from '@renderer/config/constant'
import { Assistant, Message } from '@renderer/types' import { Assistant, Message } from '@renderer/types'
import { GPTTokens } from 'gpt-tokens'
import { isEmpty, takeRight } from 'lodash' import { isEmpty, takeRight } from 'lodash'
import { getAssistantSettings } from './assistant'
import FileManager from './file' import FileManager from './file'
export const filterMessages = (messages: Message[]) => { export const filterMessages = (messages: Message[]) => {
@@ -35,32 +33,6 @@ export function getContextCount(assistant: Assistant, messages: Message[]) {
return messagesCount - (clearIndex + 1) return messagesCount - (clearIndex + 1)
} }
export function estimateInputTokenCount(text: string) {
const input = new GPTTokens({
model: 'gpt-4o',
messages: [{ role: 'user', content: text }]
})
return input.usedTokens - 7
}
export function estimateHistoryTokenCount(assistant: Assistant, msgs: Message[]) {
const { contextCount } = getAssistantSettings(assistant)
const all = new GPTTokens({
model: 'gpt-4o',
messages: [
{ role: 'system', content: assistant.prompt },
...filterMessages(filterContextMessages(takeRight(msgs, contextCount))).map((message) => ({
role: message.role,
content: message.content
}))
]
})
return all.usedTokens - 7
}
export function deleteMessageFiles(message: Message) { export function deleteMessageFiles(message: Message) {
message.files && FileManager.deleteFiles(message.files.map((f) => f.id)) message.files && FileManager.deleteFiles(message.files.map((f) => f.id))
} }

View File

@@ -1,60 +1,27 @@
import { Topic } from '@renderer/types' import db from '@renderer/databases'
import { convertToBase64 } from '@renderer/utils' import { convertToBase64 } from '@renderer/utils'
import localforage from 'localforage'
import { deleteMessageFiles } from './messages'
const IMAGE_PREFIX = 'image://' const IMAGE_PREFIX = 'image://'
export default class LocalStorage { export default class ImageStorage {
static async getTopic(id: string) { static async set(key: string, file: File) {
return localforage.getItem<Topic>(`topic:${id}`) const id = IMAGE_PREFIX + key
}
static async getTopicMessages(id: string) {
const topic = await this.getTopic(id)
return topic ? topic.messages : []
}
static async removeTopic(id: string) {
const messages = await this.getTopicMessages(id)
for (const message of messages) {
await deleteMessageFiles(message)
}
localforage.removeItem(`topic:${id}`)
}
static async clearTopicMessages(id: string) {
const topic = await this.getTopic(id)
if (topic) {
for (const message of topic?.messages ?? []) {
await deleteMessageFiles(message)
}
topic.messages = []
await localforage.setItem(`topic:${id}`, topic)
}
}
static async storeImage(name: string, file: File) {
try { try {
const base64Image = await convertToBase64(file) const base64Image = await convertToBase64(file)
if (typeof base64Image === 'string') { if (typeof base64Image === 'string') {
await localforage.setItem(IMAGE_PREFIX + name, base64Image) if (await db.settings.get(id)) {
db.settings.update(id, { value: base64Image })
return
}
await db.settings.add({ id, value: base64Image })
} }
} catch (error) { } catch (error) {
console.error('Error storing the image', error) console.error('Error storing the image', error)
} }
} }
static async getImage(name: string) { static async get(key: string): Promise<string> {
return localforage.getItem<string>(IMAGE_PREFIX + name) const id = IMAGE_PREFIX + key
} return (await db.settings.get(id))?.value
static async removeImage(name: string) {
await localforage.removeItem(IMAGE_PREFIX + name)
} }
} }

View File

@@ -0,0 +1,130 @@
import { Assistant, FileType, FileTypes, Message } from '@renderer/types'
import { GPTTokens } from 'gpt-tokens'
import { flatten, takeRight } from 'lodash'
import { CompletionUsage } from 'openai/resources'
import { getAssistantSettings } from './assistant'
import { filterContextMessages, filterMessages } from './messages'
interface MessageItem {
name?: string
role: 'system' | 'user' | 'assistant'
content: string
}
async function getFileContent(file: FileType) {
if (!file) {
return ''
}
const fileId = file.id + file.ext
if (file.type === FileTypes.IMAGE) {
const data = await window.api.file.base64Image(fileId)
return data.data
}
if (file.type === FileTypes.TEXT) {
return await window.api.file.read(fileId)
}
return ''
}
async function getMessageParam(message: Message): Promise<MessageItem[]> {
const param: MessageItem[] = []
param.push({
role: message.role,
content: message.content
})
if (message.files) {
for (const file of message.files) {
param.push({
role: 'assistant',
content: await getFileContent(file)
})
}
}
return param
}
export function estimateTextTokens(text: string) {
const { usedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: [{ role: 'user', content: text }]
})
return usedTokens - 7
}
export async function estimateMessageUsage(message: Message): Promise<CompletionUsage> {
const { usedTokens, promptUsedTokens, completionUsedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: await getMessageParam(message)
})
const hasImage = message.files?.some((f) => f.type === FileTypes.IMAGE)
return {
prompt_tokens: promptUsedTokens,
completion_tokens: completionUsedTokens,
total_tokens: hasImage ? Math.floor(usedTokens / 80) : usedTokens - 7
}
}
export async function estimateMessagesUsage({
assistant,
messages
}: {
assistant: Assistant
messages: Message[]
}): Promise<CompletionUsage> {
const outputMessage = messages.pop()!
const prompt_tokens = await estimateHistoryTokens(assistant, messages)
const { completion_tokens } = await estimateMessageUsage(outputMessage)
return {
prompt_tokens: await estimateHistoryTokens(assistant, messages),
completion_tokens,
total_tokens: prompt_tokens + completion_tokens
} as CompletionUsage
}
export async function estimateHistoryTokens(assistant: Assistant, msgs: Message[]) {
const { contextCount } = getAssistantSettings(assistant)
const messages = filterMessages(filterContextMessages(takeRight(msgs, contextCount)))
// 有 usage 数据的消息,快速计算总数
const uasageTokens = messages
.filter((m) => m.usage)
.reduce((acc, message) => {
const inputTokens = message.usage?.total_tokens ?? 0
const outputTokens = message.usage!.completion_tokens ?? 0
return acc + (message.role === 'user' ? inputTokens : outputTokens)
}, 0)
// 没有 usage 数据的消息,需要计算每条消息的 token
let allMessages: MessageItem[][] = []
for (const message of messages.filter((m) => !m.usage)) {
const items = await getMessageParam(message)
allMessages = allMessages.concat(items)
}
const { usedTokens } = new GPTTokens({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: assistant.prompt
},
...flatten(allMessages)
]
})
return usedTokens - 7 + uasageTokens
}

View File

@@ -1,6 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { TopicManager } from '@renderer/hooks/useTopic'
import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/assistant' import { getDefaultAssistant, getDefaultTopic } from '@renderer/services/assistant'
import LocalStorage from '@renderer/services/storage'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types' import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import { uniqBy } from 'lodash' import { uniqBy } from 'lodash'
@@ -91,7 +91,7 @@ const assistantsSlice = createSlice({
removeAllTopics: (state, action: PayloadAction<{ assistantId: string }>) => { removeAllTopics: (state, action: PayloadAction<{ assistantId: string }>) => {
state.assistants = state.assistants.map((assistant) => { state.assistants = state.assistants.map((assistant) => {
if (assistant.id === action.payload.assistantId) { if (assistant.id === action.payload.assistantId) {
assistant.topics.forEach((topic) => LocalStorage.removeTopic(topic.id)) assistant.topics.forEach((topic) => TopicManager.removeTopic(topic.id))
return { return {
...assistant, ...assistant,
topics: [getDefaultTopic()] topics: [getDefaultTopic()]

View File

@@ -1,8 +1,7 @@
import { SYSTEM_MODELS } from '@renderer/config/models' import { SYSTEM_MODELS } from '@renderer/config/models'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { Assistant } from '@renderer/types' import { Assistant } from '@renderer/types'
import localforage from 'localforage' import { isEmpty } from 'lodash'
import { isEmpty, pick } from 'lodash'
import { createMigrate } from 'redux-persist' import { createMigrate } from 'redux-persist'
import { RootState } from '.' import { RootState } from '.'
@@ -375,12 +374,7 @@ const migrateConfig = {
} }
} }
}, },
'24': async (state: RootState) => { '24': (state: RootState) => {
for (const key of await localforage.keys()) {
if (key.startsWith('topic:')) {
localforage.getItem(key).then((topic) => localforage.setItem(key, pick(topic, ['id', 'messages'])))
}
}
return { return {
...state, ...state,
assistants: { assistants: {

View File

@@ -17,6 +17,8 @@ export interface SettingsState {
windowStyle: 'transparent' | 'opaque' windowStyle: 'transparent' | 'opaque'
fontSize: number fontSize: number
topicPosition: 'left' | 'right' topicPosition: 'left' | 'right'
pasteLongTextAsFile: boolean
clickAssistantToShowTopic: boolean
} }
const initialState: SettingsState = { const initialState: SettingsState = {
@@ -32,7 +34,9 @@ const initialState: SettingsState = {
theme: ThemeMode.light, theme: ThemeMode.light,
windowStyle: 'opaque', windowStyle: 'opaque',
fontSize: 14, fontSize: 14,
topicPosition: 'right' topicPosition: 'right',
pasteLongTextAsFile: true,
clickAssistantToShowTopic: false
} }
const settingsSlice = createSlice({ const settingsSlice = createSlice({
@@ -84,6 +88,12 @@ const settingsSlice = createSlice({
}, },
setTopicPosition: (state, action: PayloadAction<'left' | 'right'>) => { setTopicPosition: (state, action: PayloadAction<'left' | 'right'>) => {
state.topicPosition = action.payload state.topicPosition = action.payload
},
setPasteLongTextAsFile: (state, action: PayloadAction<boolean>) => {
state.pasteLongTextAsFile = action.payload
},
setClickAssistantToShowTopic: (state, action: PayloadAction<boolean>) => {
state.clickAssistantToShowTopic = action.payload
} }
} }
}) })
@@ -103,7 +113,9 @@ export const {
setTheme, setTheme,
setFontSize, setFontSize,
setWindowStyle, setWindowStyle,
setTopicPosition setTopicPosition,
setPasteLongTextAsFile,
setClickAssistantToShowTopic
} = settingsSlice.actions } = settingsSlice.actions
export default settingsSlice.reducer export default settingsSlice.reducer

View File

@@ -97,12 +97,14 @@ export interface FileType {
type: FileTypes type: FileTypes
created_at: Date created_at: Date
count: number count: number
tokens?: number
} }
export enum FileTypes { export enum FileTypes {
IMAGE = 'image', IMAGE = 'image',
VIDEO = 'video', VIDEO = 'video',
AUDIO = 'audio', AUDIO = 'audio',
TEXT = 'text',
DOCUMENT = 'document', DOCUMENT = 'document',
OTHER = 'other' OTHER = 'other'
} }

View File

@@ -229,3 +229,15 @@ export function removeTrailingDoubleSpaces(markdown: string): string {
// 使用正则表达式匹配末尾的两个空格,并替换为空字符串 // 使用正则表达式匹配末尾的两个空格,并替换为空字符串
return markdown.replace(/ {2}$/gm, '') return markdown.replace(/ {2}$/gm, '')
} }
export function getFileDirectory(filePath: string) {
const parts = filePath.split('/')
const directory = parts.slice(0, -1).join('/')
return directory
}
export function getFileExtension(filePath: string) {
const parts = filePath.split('.')
const extension = parts.slice(-1)[0]
return '.' + extension
}

156
yarn.lock
View File

@@ -503,6 +503,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@electron/notarize@patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch":
version: 2.3.2
resolution: "@electron/notarize@patch:@electron/notarize@npm%3A2.3.2#~/.yarn/patches/@electron-notarize-npm-2.3.2-535908a4bd.patch::version=2.3.2&hash=dd0991"
dependencies:
debug: "npm:^4.1.1"
fs-extra: "npm:^9.0.1"
promise-retry: "npm:^2.0.1"
checksum: 10c0/37729f49effdf43fe6a4c5ea8f90c624c6c5f4eab3e040d91366a5b23aade9481148266d5015de64eb369ed75886664f22b9a7c34aeb9355978b78e2c901ea5e
languageName: node
linkType: hard
"@electron/osx-sign@npm:1.0.5": "@electron/osx-sign@npm:1.0.5":
version: 1.0.5 version: 1.0.5
resolution: "@electron/osx-sign@npm:1.0.5" resolution: "@electron/osx-sign@npm:1.0.5"
@@ -1760,6 +1771,7 @@ __metadata:
dotenv-cli: "npm:^7.4.2" dotenv-cli: "npm:^7.4.2"
electron: "npm:^28.3.3" electron: "npm:^28.3.3"
electron-builder: "npm:^24.9.1" electron-builder: "npm:^24.9.1"
electron-devtools-installer: "npm:^3.2.0"
electron-log: "npm:^5.1.5" electron-log: "npm:^5.1.5"
electron-store: "npm:^8.2.0" electron-store: "npm:^8.2.0"
electron-updater: "npm:^6.1.7" electron-updater: "npm:^6.1.7"
@@ -1772,7 +1784,7 @@ __metadata:
eslint-plugin-react-hooks: "npm:^4.6.2" eslint-plugin-react-hooks: "npm:^4.6.2"
eslint-plugin-simple-import-sort: "npm:^12.1.1" eslint-plugin-simple-import-sort: "npm:^12.1.1"
eslint-plugin-unused-imports: "npm:^4.0.0" eslint-plugin-unused-imports: "npm:^4.0.0"
gpt-tokens: "npm:^1.3.6" gpt-tokens: "npm:^1.3.10"
i18next: "npm:^23.11.5" i18next: "npm:^23.11.5"
localforage: "npm:^1.10.0" localforage: "npm:^1.10.0"
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
@@ -2826,6 +2838,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"core-util-is@npm:~1.0.0":
version: 1.0.3
resolution: "core-util-is@npm:1.0.3"
checksum: 10c0/90a0e40abbddfd7618f8ccd63a74d88deea94e77d0e8dbbea059fa7ebebb8fbb4e2909667fe26f3a467073de1a542ebe6ae4c73a73745ac5833786759cd906c9
languageName: node
linkType: hard
"crc@npm:^3.8.0": "crc@npm:^3.8.0":
version: 3.8.0 version: 3.8.0
resolution: "crc@npm:3.8.0" resolution: "crc@npm:3.8.0"
@@ -3214,6 +3233,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"electron-devtools-installer@npm:^3.2.0":
version: 3.2.0
resolution: "electron-devtools-installer@npm:3.2.0"
dependencies:
rimraf: "npm:^3.0.2"
semver: "npm:^7.2.1"
tslib: "npm:^2.1.0"
unzip-crx-3: "npm:^0.2.0"
checksum: 10c0/50d56e174e3bbe568d3d4a56a56e8c87faf44aa54a49ecc93ab672905f30ca1bf4e6a1b5a0b297c6ffeec1e89848086a6ff47f0db8197edb16d1bda16d6440c2
languageName: node
linkType: hard
"electron-log@npm:^5.1.5": "electron-log@npm:^5.1.5":
version: 5.2.0 version: 5.2.0
resolution: "electron-log@npm:5.2.0" resolution: "electron-log@npm:5.2.0"
@@ -4362,14 +4393,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"gpt-tokens@npm:^1.3.6": "gpt-tokens@npm:^1.3.10":
version: 1.3.9 version: 1.3.10
resolution: "gpt-tokens@npm:1.3.9" resolution: "gpt-tokens@npm:1.3.10"
dependencies: dependencies:
decimal.js: "npm:^10.4.3" decimal.js: "npm:^10.4.3"
js-tiktoken: "npm:^1.0.14" js-tiktoken: "npm:^1.0.14"
openai-chat-tokens: "npm:^0.2.8" openai-chat-tokens: "npm:^0.2.8"
checksum: 10c0/14ea94c0df4b83fdbfc8ee9c337aca5584441f8b4440a619eea9defc65dc3782fdb81c138d7153e39bae34cff60ce778e1d38f62e775d7cc378c2eac78d3299c checksum: 10c0/9aa83bf1aecc3a11b8557769fa20d0f9daae61e3e79e1e308e2435069cee9c49f06563f68d9ce90b53c4981e84c92bf9bb43168f042e4606b51ccfce9210f95c
languageName: node languageName: node
linkType: hard linkType: hard
@@ -4802,7 +4833,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"inherits@npm:2": "inherits@npm:2, inherits@npm:~2.0.3":
version: 2.0.4 version: 2.0.4
resolution: "inherits@npm:2.0.4" resolution: "inherits@npm:2.0.4"
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
@@ -5176,6 +5207,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"isarray@npm:~1.0.0":
version: 1.0.0
resolution: "isarray@npm:1.0.0"
checksum: 10c0/18b5be6669be53425f0b84098732670ed4e727e3af33bc7f948aac01782110eb9a18b3b329c5323bcdd3acdaae547ee077d3951317e7f133bff7105264b3003d
languageName: node
linkType: hard
"isbinaryfile@npm:^4.0.8": "isbinaryfile@npm:^4.0.8":
version: 4.0.10 version: 4.0.10
resolution: "isbinaryfile@npm:4.0.10" resolution: "isbinaryfile@npm:4.0.10"
@@ -5384,6 +5422,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jszip@npm:^3.1.0":
version: 3.10.1
resolution: "jszip@npm:3.10.1"
dependencies:
lie: "npm:~3.3.0"
pako: "npm:~1.0.2"
readable-stream: "npm:~2.3.6"
setimmediate: "npm:^1.0.5"
checksum: 10c0/58e01ec9c4960383fb8b38dd5f67b83ccc1ec215bf74c8a5b32f42b6e5fb79fada5176842a11409c4051b5b94275044851814a31076bf49e1be218d3ef57c863
languageName: node
linkType: hard
"katex@npm:^0.16.0": "katex@npm:^0.16.0":
version: 0.16.11 version: 0.16.11
resolution: "katex@npm:0.16.11" resolution: "katex@npm:0.16.11"
@@ -5430,6 +5480,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lie@npm:~3.3.0":
version: 3.3.0
resolution: "lie@npm:3.3.0"
dependencies:
immediate: "npm:~3.0.5"
checksum: 10c0/56dd113091978f82f9dc5081769c6f3b947852ecf9feccaf83e14a123bc630c2301439ce6182521e5fbafbde88e88ac38314327a4e0493a1bea7e0699a7af808
languageName: node
linkType: hard
"localforage@npm:^1.10.0": "localforage@npm:^1.10.0":
version: 1.10.0 version: 1.10.0
resolution: "localforage@npm:1.10.0" resolution: "localforage@npm:1.10.0"
@@ -6702,6 +6761,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"pako@npm:~1.0.2":
version: 1.0.11
resolution: "pako@npm:1.0.11"
checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe
languageName: node
linkType: hard
"parent-module@npm:^1.0.0": "parent-module@npm:^1.0.0":
version: 1.0.1 version: 1.0.1
resolution: "parent-module@npm:1.0.1" resolution: "parent-module@npm:1.0.1"
@@ -6925,6 +6991,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"process-nextick-args@npm:~2.0.0":
version: 2.0.1
resolution: "process-nextick-args@npm:2.0.1"
checksum: 10c0/bec089239487833d46b59d80327a1605e1c5287eaad770a291add7f45fda1bb5e28b38e0e061add0a1d0ee0984788ce74fa394d345eed1c420cacf392c554367
languageName: node
linkType: hard
"progress@npm:^2.0.3": "progress@npm:^2.0.3":
version: 2.0.3 version: 2.0.3
resolution: "progress@npm:2.0.3" resolution: "progress@npm:2.0.3"
@@ -7752,6 +7825,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"readable-stream@npm:~2.3.6":
version: 2.3.8
resolution: "readable-stream@npm:2.3.8"
dependencies:
core-util-is: "npm:~1.0.0"
inherits: "npm:~2.0.3"
isarray: "npm:~1.0.0"
process-nextick-args: "npm:~2.0.0"
safe-buffer: "npm:~5.1.1"
string_decoder: "npm:~1.1.1"
util-deprecate: "npm:~1.0.1"
checksum: 10c0/7efdb01f3853bc35ac62ea25493567bf588773213f5f4a79f9c365e1ad13bab845ac0dae7bc946270dc40c3929483228415e92a3fc600cc7e4548992f41ee3fa
languageName: node
linkType: hard
"readdirp@npm:~3.6.0": "readdirp@npm:~3.6.0":
version: 3.6.0 version: 3.6.0
resolution: "readdirp@npm:3.6.0" resolution: "readdirp@npm:3.6.0"
@@ -8117,6 +8205,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1":
version: 5.1.2
resolution: "safe-buffer@npm:5.1.2"
checksum: 10c0/780ba6b5d99cc9a40f7b951d47152297d0e260f0df01472a1b99d4889679a4b94a13d644f7dbc4f022572f09ae9005fa2fbb93bbbd83643316f365a3e9a45b21
languageName: node
linkType: hard
"safe-regex-test@npm:^1.0.3": "safe-regex-test@npm:^1.0.3":
version: 1.0.3 version: 1.0.3
resolution: "safe-regex-test@npm:1.0.3" resolution: "safe-regex-test@npm:1.0.3"
@@ -8198,7 +8293,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3": "semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3":
version: 7.6.3 version: 7.6.3
resolution: "semver@npm:7.6.3" resolution: "semver@npm:7.6.3"
bin: bin:
@@ -8242,6 +8337,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"setimmediate@npm:^1.0.5":
version: 1.0.5
resolution: "setimmediate@npm:1.0.5"
checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49
languageName: node
linkType: hard
"shallowequal@npm:1.1.0": "shallowequal@npm:1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "shallowequal@npm:1.1.0" resolution: "shallowequal@npm:1.1.0"
@@ -8493,6 +8595,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"string_decoder@npm:~1.1.1":
version: 1.1.1
resolution: "string_decoder@npm:1.1.1"
dependencies:
safe-buffer: "npm:~5.1.0"
checksum: 10c0/b4f89f3a92fd101b5653ca3c99550e07bdf9e13b35037e9e2a1c7b47cec4e55e06ff3fc468e314a0b5e80bfbaf65c1ca5a84978764884ae9413bec1fc6ca924e
languageName: node
linkType: hard
"stringify-entities@npm:^4.0.0": "stringify-entities@npm:^4.0.0":
version: 4.0.4 version: 4.0.4
resolution: "stringify-entities@npm:4.0.4" resolution: "stringify-entities@npm:4.0.4"
@@ -8752,7 +8863,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tslib@npm:^2.6.2": "tslib@npm:^2.1.0, tslib@npm:^2.6.2":
version: 2.7.0 version: 2.7.0
resolution: "tslib@npm:2.7.0" resolution: "tslib@npm:2.7.0"
checksum: 10c0/469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6 checksum: 10c0/469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6
@@ -8853,11 +8964,11 @@ __metadata:
"typescript@patch:typescript@npm%3A^5.3.3#optional!builtin<compat/typescript>, typescript@patch:typescript@npm%3A^5.6.2#optional!builtin<compat/typescript>": "typescript@patch:typescript@npm%3A^5.3.3#optional!builtin<compat/typescript>, typescript@patch:typescript@npm%3A^5.6.2#optional!builtin<compat/typescript>":
version: 5.6.2 version: 5.6.2
resolution: "typescript@patch:typescript@npm%3A5.6.2#optional!builtin<compat/typescript>::version=5.6.2&hash=379a07" resolution: "typescript@patch:typescript@npm%3A5.6.2#optional!builtin<compat/typescript>::version=5.6.2&hash=8c6c40"
bin: bin:
tsc: bin/tsc tsc: bin/tsc
tsserver: bin/tsserver tsserver: bin/tsserver
checksum: 10c0/e6c1662e4852e22fe4bbdca471dca3e3edc74f6f1df043135c44a18a7902037023ccb0abdfb754595ca9028df8920f2f8492c00fc3cbb4309079aae8b7de71cd checksum: 10c0/94eb47e130d3edd964b76da85975601dcb3604b0c848a36f63ac448d0104e93819d94c8bdf6b07c00120f2ce9c05256b8b6092d23cf5cf1c6fa911159e4d572f
languageName: node languageName: node
linkType: hard linkType: hard
@@ -9002,6 +9113,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"unzip-crx-3@npm:^0.2.0":
version: 0.2.0
resolution: "unzip-crx-3@npm:0.2.0"
dependencies:
jszip: "npm:^3.1.0"
mkdirp: "npm:^0.5.1"
yaku: "npm:^0.16.6"
checksum: 10c0/e551cb3d57d0271da41825e9bd9a7f4ef9ec5c3f15edc37bf909928c8327f21a6938ddc922787ee2b1b31f95ac83232dac79fd5a44e2727f9e800df9017a3b91
languageName: node
linkType: hard
"update-browserslist-db@npm:^1.1.0": "update-browserslist-db@npm:^1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "update-browserslist-db@npm:1.1.0" resolution: "update-browserslist-db@npm:1.1.0"
@@ -9050,6 +9172,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"util-deprecate@npm:~1.0.1":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"
checksum: 10c0/41a5bdd214df2f6c3ecf8622745e4a366c4adced864bc3c833739791aeeeb1838119af7daed4ba36428114b5c67dcda034a79c882e97e43c03e66a4dd7389942
languageName: node
linkType: hard
"uuid@npm:^10.0.0": "uuid@npm:^10.0.0":
version: 10.0.0 version: 10.0.0
resolution: "uuid@npm:10.0.0" resolution: "uuid@npm:10.0.0"
@@ -9332,6 +9461,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"yaku@npm:^0.16.6":
version: 0.16.7
resolution: "yaku@npm:0.16.7"
checksum: 10c0/de45a2f6c31ab905174c4e5a3aad93972cb3cf2946d206ab9718ad5fb9adf0781c011d8b1d576daf20ebfa02fa120a8e7552a742a88bb3d288eea5a3a693187c
languageName: node
linkType: hard
"yallist@npm:^3.0.2": "yallist@npm:^3.0.2":
version: 3.1.1 version: 3.1.1
resolution: "yallist@npm:3.1.1" resolution: "yallist@npm:3.1.1"