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

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "0.7.0",
"version": "0.7.2",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -59,6 +59,7 @@
"dotenv-cli": "^7.4.2",
"electron": "^28.3.3",
"electron-builder": "^24.9.1",
"electron-devtools-installer": "^3.2.0",
"electron-vite": "^2.0.0",
"emittery": "^1.0.3",
"emoji-picker-element": "^1.22.1",
@@ -67,7 +68,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.0.0",
"gpt-tokens": "^1.3.6",
"gpt-tokens": "^1.3.10",
"i18next": "^23.11.5",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
@@ -99,8 +100,7 @@
"react-dom": "^17.0.0 || ^18.0.0"
},
"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"
},
"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 path from 'path'
const isDev = process.env.NODE_ENV === 'development'
isDev && app.setPath('userData', app.getPath('userData') + 'Dev')
const getDataPath = () => {
const dataPath = path.join(app.getPath('userData'), 'Data')
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 { app, BrowserWindow } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { updateUserDataPath } from './utils/upgrade'
@@ -12,7 +13,7 @@ app.whenReady().then(async () => {
await updateUserDataPath()
// 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
// and ignore CommandOrControl + R in production.
@@ -30,6 +31,12 @@ app.whenReady().then(async () => {
const mainWindow = createMainWindow()
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

View File

@@ -1,17 +1,14 @@
import { FileType } from '@types'
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 AppUpdater from './services/AppUpdater'
import File from './services/File'
import FileManager from './services/FileManager'
import { openFile, saveFile } from './utils/file'
import { compress, decompress } from './utils/zip'
import { createMinappWindow } from './window'
const fileManager = new File()
const fileManager = new FileManager()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
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:decompress', (_, text: Buffer) => decompress(text))
ipcMain.handle('image:base64', async (_, filePath) => {
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:base64Image', async (_, id) => await fileManager.base64Image(id))
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:delete', async (_, fileId: string) => {
await fileManager.deleteFile(fileId)
return { success: true }
})
ipcMain.handle('file:clear', async () => await fileManager.clear())
ipcMain.handle('file:read', async (_, id: string) => await fileManager.readFile(id))
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) => {
createMinappWindow({
url: args.url,

View File

@@ -6,7 +6,7 @@ import * as fs from 'fs'
import * as path from 'path'
import { v4 as uuidv4 } from 'uuid'
class File {
class FileManager {
private storageDir: string
constructor() {
@@ -131,9 +131,68 @@ class File {
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> {
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 logger from 'electron-log'
import { writeFile } from 'fs'
import { writeFileSync } from 'fs'
import { readFile } from 'fs/promises'
import { FileTypes } from '../../renderer/src/types'
@@ -19,11 +19,7 @@ export async function saveFile(
})
if (!result.canceled && result.filePath) {
writeFile(result.filePath, content, { encoding: 'utf-8' }, (err) => {
if (err) {
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
}
})
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
} catch (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 videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
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()
if (imageExts.includes(ext)) return FileTypes.IMAGE
if (videoExts.includes(ext)) return FileTypes.VIDEO
if (audioExts.includes(ext)) return FileTypes.AUDIO
if (textExts.includes(ext)) return FileTypes.TEXT
if (documentExts.includes(ext)) return FileTypes.DOCUMENT
return FileTypes.OTHER
}

View File

@@ -24,10 +24,13 @@ declare global {
file: {
select: (options?: OpenDialogOptions) => Promise<FileType[] | null>
upload: (file: FileType) => Promise<FileType>
delete: (fileId: string) => Promise<{ success: boolean }>
}
image: {
base64: (filePath: string) => Promise<{ mime: string; base64: string; data: string }>
delete: (fileId: string) => Promise<void>
read: (fileId: string) => Promise<string>
base64Image: (fileId: 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),
reload: () => ipcRenderer.invoke('reload'),
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),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
file: {
select: (options?: OpenDialogOptions) => ipcRenderer.invoke('file:select', options),
upload: (filePath: string) => ipcRenderer.invoke('file:upload', filePath),
delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId)
},
image: {
base64: (filePath: string) => ipcRenderer.invoke('image:base64', filePath)
delete: (fileId: string) => ipcRenderer.invoke('file:delete', fileId),
read: (fileId: string) => ipcRenderer.invoke('file:read', fileId),
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
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;
}
.text-nowrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.minapp-drawer {
.ant-drawer-content-wrapper {
box-shadow: none;

View File

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

View File

@@ -1,6 +1,6 @@
import useAvatar from '@renderer/hooks/useAvatar'
import { useSettings } from '@renderer/hooks/useSettings'
import LocalStorage from '@renderer/services/storage'
import ImageStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime'
import { setUserName } from '@renderer/store/settings'
@@ -55,8 +55,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
try {
const _file = file.originFileObj as File
const compressedFile = await compressImage(_file)
await LocalStorage.storeImage('avatar', compressedFile)
dispatch(setAvatar(await LocalStorage.getImage('avatar')))
await ImageStorage.set('avatar', compressedFile)
dispatch(setAvatar(await ImageStorage.get('avatar')))
} catch (error: any) {
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 isWindows = platform === 'win32' || platform === 'win64'
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 const APP_NAME = 'Cherry Studio'
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 LlamaModelLogo from '@renderer/assets/images/models/llama.jpeg'
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 PalmModelLogo from '@renderer/assets/images/models/palm.svg'
import QwenModelLogo from '@renderer/assets/images/models/qwen.png'
@@ -91,6 +92,7 @@ export function getModelLogo(modelId: string) {
}
const logoMap = {
o1: OpenAiProviderLogo,
gpt: ChatGPTModelLogo,
glm: ChatGLMModelLogo,
deepseek: DeepSeekModelLogo,
@@ -112,7 +114,8 @@ export function getModelLogo(modelId: string) {
abab: HailuoModelLogo,
'ep-202': DoubaoModelLogo,
cohere: CohereModelLogo,
command: CohereModelLogo
command: CohereModelLogo,
minicpm: MinicpmModelLogo
}
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 { populateTopics } from './populate'
// Database declaration (move this to its own module also)
export const db = new Dexie('CherryStudio') as Dexie & {
files: EntityTable<FileType, 'id'>
topics: EntityTable<Pick<Topic, 'id' | 'messages'>, 'id'>
settings: EntityTable<{ id: string; value: any }, 'id'>
}
db.version(1).stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count'
})
db.version(2)
.stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count',
topics: '&id, messages',
settings: '&id, value'
})
.upgrade(populateTopics)
db.on('populate', populateTopics)
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 db from '@renderer/databases'
import i18n from '@renderer/i18n'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch } from '@renderer/store'
import { setAvatar } from '@renderer/store/runtime'
import { runAsyncFunction } from '@renderer/utils'
import { useLiveQuery } from 'dexie-react-hooks'
import { useEffect } from 'react'
import { useDefaultModel } from './useAssistant'
@@ -13,13 +14,11 @@ export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl, language } = useSettings()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
const avatar = useLiveQuery(() => db.settings.get('image://avatar'))
useEffect(() => {
runAsyncFunction(async () => {
const storedImage = await LocalStorage.getImage('avatar')
storedImage && dispatch(setAvatar(storedImage))
})
}, [dispatch])
avatar?.value && dispatch(setAvatar(avatar.value))
}, [avatar, dispatch])
useEffect(() => {
runAsyncFunction(async () => {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import DragableList from '@renderer/components/DragableList'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup'
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { getDefaultTopic, syncAsistantToAgent } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
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 [search, setSearch] = useState('')
const { updateAssistant, removeAllTopics } = useAssistant(activeAssistant.id)
const { clickAssistantToShowTopic, topicPosition } = useSettings()
const searchRef = useRef<InputRef>(null)
const { t } = useTranslation()
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)
},
[generating, setActiveAssistant, t]
[clickAssistantToShowTopic, generating, setActiveAssistant, t, topicPosition]
)
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 { imageExts, textExts } from '@renderer/config/constant'
import { isVisionModel } from '@renderer/config/models'
import { FileType, Model } from '@renderer/types'
import { Tooltip } from 'antd'
@@ -14,18 +15,16 @@ interface Props {
const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton }) => {
const { t } = useTranslation()
const extensions = isVisionModel(model) ? [...imageExts, ...textExts] : [...textExts]
const onSelectFile = async () => {
const _files = await window.api.file.select({
filters: [{ name: 'Files', extensions: ['jpg', 'png', 'jpeg'] }]
})
if (files.length > 0) {
return setFiles([])
}
const _files = await window.api.file.select({ filters: [{ name: 'Files', extensions }] })
_files && setFiles(_files)
}
if (!isVisionModel(model)) {
return null
}
return (
<Tooltip placement="top" title={t('chat.input.upload')} arrow>
<ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile}>

View File

@@ -8,17 +8,20 @@ import {
PauseCircleOutlined,
QuestionCircleOutlined
} 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 { useSettings } from '@renderer/hooks/useSettings'
import { useRuntime, useShowTopics } from '@renderer/hooks/useStore'
import { getDefaultTopic } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
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 { setGenerating, setSearching } from '@renderer/store/runtime'
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 TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
import dayjs from 'dayjs'
@@ -43,7 +46,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { addTopic, model } = useAssistant(assistant.id)
const { sendMessageShortcut, fontSize } = useSettings()
const { sendMessageShortcut, fontSize, pasteLongTextAsFile } = useSettings()
const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
const [contextCount, setContextCount] = useState(0)
@@ -56,6 +59,9 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const { searching } = useRuntime()
const dispatch = useAppDispatch()
const isVision = useMemo(() => isVisionModel(model), [model])
const supportExts = useMemo(() => [...textExts, ...(isVision ? imageExts : [])], [isVision])
_text = text
const sendMessage = useCallback(async () => {
@@ -91,7 +97,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
setExpend(false)
}, [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 isEnterPressed = event.keyCode == 13
@@ -120,6 +126,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const topic = getDefaultTopic()
addTopic(topic)
setActiveTopic(topic)
db.topics.add({ id: topic.id, messages: [] })
}, [addTopic, setActiveTopic])
const clearTopic = async () => {
@@ -169,6 +176,53 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
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
useEffect(() => {
const onKeydown = (e) => {
@@ -190,6 +244,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
EventEmitter.on(EVENT_NAMES.EDIT_MESSAGE, (message: Message) => {
setText(message.content)
textareaRef.current?.focus()
setTimeout(() => resizeTextArea(), 0)
}),
EventEmitter.on(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, ({ tokensCount, contextCount }) => {
_setEstimateTokenCount(tokensCount)
@@ -203,6 +258,11 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
textareaRef.current?.focus()
}, [assistant])
useEffect(() => {
document.addEventListener('paste', onPaste)
return () => document.removeEventListener('paste', onPaste)
}, [onPaste])
return (
<Container>
<AttachmentPreview files={files} setFiles={setFiles} />

View File

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

View File

@@ -3,7 +3,7 @@ import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { useTheme } from '@renderer/context/ThemeProvider'
import { initMermaid } from '@renderer/init'
import { ThemeMode } from '@renderer/types'
import React, { useState } from 'react'
import React, { memo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { atomDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'
@@ -17,34 +17,23 @@ interface CodeBlockProps {
[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 [copied, setCopied] = useState(false)
const showFooterCopyButton = children && children.length > 500
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') {
initMermaid(theme)
return <Mermaid chart={children} />
}
return match ? (
<>
<div className="code-block">
<CodeHeader>
<CodeLanguage>{'<' + match[1].toUpperCase() + '>'}</CodeLanguage>
{!copied && <CopyIcon className="copy" onClick={onCopy} />}
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
<CopyButton text={children} />
</CodeHeader>
<SyntaxHighlighter
{...rest}
language={match[1]}
style={theme === ThemeMode.dark ? atomDark : oneLight}
wrapLongLines={true}
@@ -56,11 +45,32 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ children, className, ...rest }) =
}}>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</>
{showFooterCopyButton && (
<CodeFooter>
<CopyButton text={children} style={{ marginTop: -40, marginRight: 10 }} />
</CodeFooter>
)}
</div>
) : (
<code {...rest} className={className}>
{children}
</code>
<code className={className}>{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;
`
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 { FC, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import ReactMarkdown, { Components } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
@@ -16,6 +16,14 @@ interface Props {
message: Message
}
const rehypePlugins = [rehypeKatex]
const remarkPlugins = [remarkMath, remarkGfm]
const components = {
code: CodeBlock,
a: Link
}
const Markdown: FC<Props> = ({ message }) => {
const { t } = useTranslation()
@@ -23,25 +31,54 @@ const Markdown: FC<Props> = ({ message }) => {
const empty = isEmpty(message.content)
const paused = message.status === 'paused'
const content = empty && paused ? t('message.chat.completion.paused') : message.content
return content
return escapeBrackets(escapeDollarNumber(content))
}, [message.content, message.status, t])
return useMemo(() => {
return (
<ReactMarkdown
className="markdown"
rehypePlugins={[rehypeKatex]}
remarkPlugins={[remarkMath, remarkGfm]}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',
footnoteBackContent: ' '
}}
components={{ code: CodeBlock as any, a: Link as any }}>
{messageContent}
</ReactMarkdown>
)
}, [messageContent, t])
return (
<ReactMarkdown
className="markdown"
rehypePlugins={rehypePlugins}
remarkPlugins={remarkPlugins}
components={components as Partial<Components>}
remarkRehypeOptions={{
footnoteLabel: t('common.footnotes'),
footnoteLabelTagName: 'h4',
footnoteBackContent: ' '
}}>
{messageContent}
</ReactMarkdown>
)
}
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

View File

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

View File

@@ -1,5 +1,6 @@
import { Message } from '@renderer/types'
import { Image as AntdImage } from 'antd'
import { FileTypes, Message } from '@renderer/types'
import { getFileDirectory } from '@renderer/utils'
import { Image as AntdImage, Upload } from 'antd'
import { FC } from 'react'
import styled from 'styled-components'
@@ -8,9 +9,27 @@ interface Props {
}
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 (
<Container>
{message.files?.map((image) => <Image src={'file://' + image.path} key={image.id} width="33%" />)}
<Container style={{ marginTop: 2 }}>
<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>
)
}

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 { useProviderByAssistant } from '@renderer/hooks/useProvider'
import { getTopic } from '@renderer/hooks/useTopic'
import { getTopic, TopicManager } from '@renderer/hooks/useTopic'
import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api'
import { getDefaultTopic } from '@renderer/services/assistant'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import {
deleteMessageFiles,
estimateHistoryTokenCount,
filterMessages,
getContextCount
} from '@renderer/services/messages'
import LocalStorage from '@renderer/services/storage'
import { deleteMessageFiles, filterMessages, getContextCount } from '@renderer/services/messages'
import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/tokens'
import { Assistant, Message, Model, Topic } from '@renderer/types'
import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
import { t } from 'i18next'
import localforage from 'localforage'
import { last, reverse } from 'lodash'
import { flatten, last, reverse, take } from 'lodash'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import styled from 'styled-components'
@@ -31,17 +26,25 @@ interface Props {
const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const [messages, setMessages] = useState<Message[]>([])
const [lastMessage, setLastMessage] = useState<Message | null>(null)
const provider = useProviderByAssistant(assistant)
const containerRef = useRef<HTMLDivElement>(null)
const { updateTopic } = useAssistant(assistant.id)
const { updateTopic, addTopic } = useAssistant(assistant.id)
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]
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 () => {
@@ -60,7 +63,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
(message: Message) => {
const _messages = messages.filter((m) => m.id !== message.id)
setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages })
db.topics.update(topic.id, { messages: _messages })
deleteMessageFiles(message)
},
[messages, topic.id]
@@ -69,10 +72,15 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
useEffect(() => {
const unsubscribes = [
EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, async (msg: Message) => {
onSendMessage(msg)
fetchChatCompletion({ assistant, messages: [...messages, msg], topic, onResponse: setLastMessage })
await onSendMessage(msg)
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)
onSendMessage(msg)
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, () => {
setMessages([])
updateTopic({ ...topic, messages: [] })
LocalStorage.clearTopicMessages(topic.id)
TopicManager.clearTopicMessages(topic.id)
}),
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => {
const lastMessage = last(messages)
if (lastMessage && lastMessage.type === 'clear') {
onDeleteMessage(lastMessage)
return
}
@@ -117,14 +126,43 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
status: 'success',
type: 'clear'
} 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())
}, [assistant, messages, provider, topic, autoRenameTopic, updateTopic, onSendMessage])
}, [
addTopic,
assistant,
autoRenameTopic,
messages,
onDeleteMessage,
onSendMessage,
setActiveTopic,
topic,
updateTopic
])
useEffect(() => {
runAsyncFunction(async () => {
const messages = (await LocalStorage.getTopicMessages(topic.id)) || []
const messages = (await TopicManager.getTopicMessages(topic.id)) || []
setMessages(messages)
})
}, [topic.id])
@@ -134,16 +172,18 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
}, [messages])
useEffect(() => {
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, {
tokensCount: estimateHistoryTokenCount(assistant, messages),
contextCount: getContextCount(assistant, messages)
runAsyncFunction(async () => {
EventEmitter.emit(EVENT_NAMES.ESTIMATED_TOKEN_COUNT, {
tokensCount: await estimateHistoryTokens(assistant, messages),
contextCount: getContextCount(assistant, messages)
})
})
}, [assistant, messages])
return (
<Container id="messages" key={assistant.id} ref={containerRef}>
<Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} />
{lastMessage && <MessageItem message={lastMessage} />}
{lastMessage && <MessageItem key={lastMessage.id} message={lastMessage} />}
{reverse([...messages]).map((message, index) => (
<MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} />
))}

View File

@@ -8,6 +8,7 @@ import { useAppDispatch } from '@renderer/store'
import {
setFontSize,
setMessageFont,
setPasteLongTextAsFile,
setShowInputEstimatedTokens,
setShowMessageDivider
} from '@renderer/store/settings'
@@ -33,8 +34,14 @@ const SettingsTab: FC<Props> = (props) => {
const dispatch = useAppDispatch()
const { showMessageDivider, messageFont, showInputEstimatedTokens, sendMessageShortcut, setSendMessageShortcut } =
useSettings()
const {
showMessageDivider,
messageFont,
showInputEstimatedTokens,
sendMessageShortcut,
setSendMessageShortcut,
pasteLongTextAsFile
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
updateAssistantSettings({
@@ -210,6 +217,15 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<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>
<SettingRowTitleSmall>{t('settings.messages.input.send_shortcuts')}</SettingRowTitleSmall>
</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 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 LocalStorage from '@renderer/services/storage'
import { useAppSelector } from '@renderer/store'
import { Assistant, Topic } from '@renderer/types'
import { Dropdown, MenuProps } from 'antd'
@@ -19,7 +19,8 @@ interface Props {
}
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 generating = useAppSelector((state) => state.runtime.generating)
@@ -34,6 +35,15 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, 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(
(topic: Topic) => {
if (generating) {
@@ -51,9 +61,9 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
{
label: t('chat.topics.auto_rename'),
key: 'auto-rename',
icon: <OpenAIOutlined />,
icon: <i className="iconfont icon-business-smart-assistant" style={{ fontSize: '14px' }} />,
async onClick() {
const messages = await LocalStorage.getTopicMessages(topic.id)
const messages = await TopicManager.getTopicMessages(topic.id)
if (messages.length >= 2) {
const summaryText = await fetchMessagesSummary({ messages, assistant })
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) {
menus.push({ type: 'divider' })
menus.push({
@@ -92,7 +117,7 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
return menus
},
[assistant, onDeleteTopic, t, updateTopic]
[assistant, assistants, onDeleteTopic, onMoveTopic, t, updateTopic]
)
return (

View File

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

View File

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

View File

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

View File

@@ -20,11 +20,7 @@ export default abstract class BaseProvider {
return this.provider.id === 'ollama' ? getOllamaKeepAliveTime() : undefined
}
abstract completions(
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void>
abstract completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
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 { EVENT_NAMES } from '@renderer/services/event'
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 { first, isEmpty, takeRight } from 'lodash'
import { isEmpty, takeRight } from 'lodash'
import OpenAI from 'openai'
import BaseProvider from './BaseProvider'
@@ -17,42 +17,50 @@ export default class GeminiProvider extends BaseProvider {
this.sdk = new GoogleGenerativeAI(provider.apiKey)
}
private async getMessageParts(message: Message): Promise<Part[]> {
const file = first(message.files)
private async getMessageContents(message: Message): Promise<Content> {
const role = message.role === 'user' ? 'user' : 'model'
if (file && file.type === 'image') {
const base64Data = await window.api.image.base64(file.path)
return [
{
text: message.content
},
{
const parts: Part[] = [{ text: message.content }]
for (const file of message.files || []) {
if (file.type === FileTypes.IMAGE) {
const base64Data = await window.api.file.base64Image(file.id + file.ext)
parts.push({
inlineData: {
data: base64Data.base64,
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(
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
) {
public async completions({ messages, assistant, onChunk, onFilterMessages }: CompletionsParams) {
const defaultModel = getDefaultModel()
const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant)
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1))).map((message) => {
return {
role: message.role,
message
}
})
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))
onFilterMessages(userMessages)
const userLastMessage = userMessages.pop()
const history: Content[] = []
for (const message of userMessages) {
history.push(await this.getMessageContents(message))
}
const geminiModel = this.sdk.getGenerativeModel({
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 message = await this.getMessageParts(userLastMessage?.message!)
const userMessagesStream = await chat.sendMessageStream(message)
const messageContents = await this.getMessageContents(userLastMessage!)
const userMessagesStream = await chat.sendMessageStream(messageContents.parts)
for await (const chunk of userMessagesStream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break

View File

@@ -1,8 +1,9 @@
import { isLocalAi } from '@renderer/config/env'
import { isVisionModel } from '@renderer/config/models'
import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@renderer/services/assistant'
import { EVENT_NAMES } from '@renderer/services/event'
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 { first, takeRight } from 'lodash'
import OpenAI from 'openai'
@@ -26,34 +27,57 @@ export default class OpenAIProvider extends BaseProvider {
})
}
private async getMessageContent(message: Message): Promise<string | ChatCompletionContentPart[]> {
const file = first(message.files)
if (!file) {
return message.content
private isSupportStreamOutput(modelId: string): boolean {
if (this.provider.id === 'openai' && modelId.includes('o1-')) {
return false
}
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
return true
}
async completions(
messages: Message[],
assistant: Assistant,
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void> {
private async getMessageParam(
message: Message,
model: Model
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam> {
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 model = assistant.model || defaultModel
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 userMessages: ChatCompletionMessageParam[] = []
for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))) {
userMessages.push({
role: message.role,
content: await this.getMessageContent(message)
} as ChatCompletionMessageParam)
const _messages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))
onFilterMessages(_messages)
for (const message of _messages) {
userMessages.push(await this.getMessageParam(message, model))
}
// @ts-ignore key is not typed
const stream = await this.sdk.chat.completions.create({
model: model.id,
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
stream: true,
stream: this.isSupportStreamOutput(model.id),
temperature: assistant?.settings?.temperature,
max_tokens: maxTokens,
keep_alive: this.keepAliveTime
})
for await (const chunk of stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
onChunk({ text: chunk.choices[0]?.delta?.content || '', usage: chunk.usage })
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
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'
import { EVENT_NAMES, EventEmitter } from './event'
import { filterMessages } from './messages'
import { estimateMessagesUsage } from './tokens'
export async function fetchChatCompletion({
messages,
@@ -61,12 +62,27 @@ export async function fetchChatCompletion({
}, 1000)
try {
await AI.completions(messages, assistant, ({ text, usage }) => {
message.content = message.content + text || ''
message.usage = usage
onResponse({ ...message, status: 'pending' })
let _messages: Message[] = []
await AI.completions({
messages,
assistant,
onFilterMessages: (messages) => (_messages = messages),
onChunk: ({ text, usage }) => {
message.content = message.content + text || ''
message.usage = usage
onResponse({ ...message, status: 'pending' })
}
})
message.status = 'success'
if (!message.usage) {
message.usage = await estimateMessagesUsage({
assistant,
messages: [..._messages, message]
})
}
} catch (error: any) {
message.content = `Error: ${error.message}`
message.status = 'error'
@@ -82,7 +98,7 @@ export async function fetchChatCompletion({
message.status = window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED) ? 'paused' : message.status
// Emit chat completion event
EventEmitter.emit(EVENT_NAMES.AI_CHAT_COMPLETION, message)
EventEmitter.emit(EVENT_NAMES.RECEIVE_MESSAGE, message)
// Reset generating state
store.dispatch(setGenerating(false))

View File

@@ -1,31 +1,26 @@
import db from '@renderer/databases'
import i18n from '@renderer/i18n'
import dayjs from 'dayjs'
import localforage from 'localforage'
export async function backup() {
const indexedKeys = await localforage.keys()
const version = 1
const version = 2
const time = new Date().getTime()
const data = {
time,
version,
localStorage,
indexedDB: [] as { key: string; value: any }[]
}
for (const key of indexedKeys) {
data.indexedDB.push({
key,
value: await localforage.getItem(key)
})
indexedDB: await backupDatabase()
}
const filename = `cherry-studio.${dayjs().format('YYYYMMDD')}.bak`
const fileContnet = JSON.stringify(data)
const file = await window.api.compress(fileContnet)
window.api.saveFile(filename, file)
await window.api.saveFile(filename, file)
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
}
export async function restore() {
@@ -37,17 +32,32 @@ export async function restore() {
const data = JSON.parse(content)
if (data.version === 1) {
localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio'])
await clearDatabase()
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' })
setTimeout(() => window.api.reload(), 1500)
} else {
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
setTimeout(() => window.api.reload(), 1000)
return
}
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) {
console.error(error)
window.message.error({ content: i18n.t('error.backup.file_format'), key: 'restore' })
@@ -59,6 +69,7 @@ export async function reset() {
window.modal.confirm({
title: i18n.t('common.warning'),
content: i18n.t('message.reset.confirm.content'),
centered: true,
onOk: async () => {
window.modal.confirm({
title: i18n.t('message.reset.double.confirm.title'),
@@ -67,9 +78,43 @@ export async function reset() {
onOk: async () => {
await localStorage.clear()
await localforage.clear()
await clearDatabase()
await window.api.file.clear()
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 = {
SEND_MESSAGE: 'SEND_MESSAGE',
AI_CHAT_COMPLETION: 'AI_CHAT_COMPLETION',
RECEIVE_MESSAGE: 'RECEIVE_MESSAGE',
AI_AUTO_RENAME: 'AI_AUTO_RENAME',
CLEAR_MESSAGES: 'CLEAR_MESSAGES',
ADD_ASSISTANT: 'ADD_ASSISTANT',
@@ -16,5 +16,6 @@ export const EVENT_NAMES = {
SHOW_CHAT_SETTINGS: 'SHOW_CHAT_SETTINGS',
SHOW_TOPIC_SIDEBAR: 'SHOW_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 { Assistant, Message } from '@renderer/types'
import { GPTTokens } from 'gpt-tokens'
import { isEmpty, takeRight } from 'lodash'
import { getAssistantSettings } from './assistant'
import FileManager from './file'
export const filterMessages = (messages: Message[]) => {
@@ -35,32 +33,6 @@ export function getContextCount(assistant: Assistant, messages: Message[]) {
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) {
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 localforage from 'localforage'
import { deleteMessageFiles } from './messages'
const IMAGE_PREFIX = 'image://'
export default class LocalStorage {
static async getTopic(id: string) {
return localforage.getItem<Topic>(`topic:${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)
}
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) {
export default class ImageStorage {
static async set(key: string, file: File) {
const id = IMAGE_PREFIX + key
try {
const base64Image = await convertToBase64(file)
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) {
console.error('Error storing the image', error)
}
}
static async getImage(name: string) {
return localforage.getItem<string>(IMAGE_PREFIX + name)
}
static async removeImage(name: string) {
await localforage.removeItem(IMAGE_PREFIX + name)
static async get(key: string): Promise<string> {
const id = IMAGE_PREFIX + key
return (await db.settings.get(id))?.value
}
}

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

View File

@@ -1,8 +1,7 @@
import { SYSTEM_MODELS } from '@renderer/config/models'
import i18n from '@renderer/i18n'
import { Assistant } from '@renderer/types'
import localforage from 'localforage'
import { isEmpty, pick } from 'lodash'
import { isEmpty } from 'lodash'
import { createMigrate } from 'redux-persist'
import { RootState } from '.'
@@ -375,12 +374,7 @@ const migrateConfig = {
}
}
},
'24': async (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'])))
}
}
'24': (state: RootState) => {
return {
...state,
assistants: {

View File

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

View File

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

View File

@@ -229,3 +229,15 @@ export function removeTrailingDoubleSpaces(markdown: string): string {
// 使用正则表达式匹配末尾的两个空格,并替换为空字符串
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
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":
version: 1.0.5
resolution: "@electron/osx-sign@npm:1.0.5"
@@ -1760,6 +1771,7 @@ __metadata:
dotenv-cli: "npm:^7.4.2"
electron: "npm:^28.3.3"
electron-builder: "npm:^24.9.1"
electron-devtools-installer: "npm:^3.2.0"
electron-log: "npm:^5.1.5"
electron-store: "npm:^8.2.0"
electron-updater: "npm:^6.1.7"
@@ -1772,7 +1784,7 @@ __metadata:
eslint-plugin-react-hooks: "npm:^4.6.2"
eslint-plugin-simple-import-sort: "npm:^12.1.1"
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"
localforage: "npm:^1.10.0"
lodash: "npm:^4.17.21"
@@ -2826,6 +2838,13 @@ __metadata:
languageName: node
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":
version: 3.8.0
resolution: "crc@npm:3.8.0"
@@ -3214,6 +3233,18 @@ __metadata:
languageName: node
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":
version: 5.2.0
resolution: "electron-log@npm:5.2.0"
@@ -4362,14 +4393,14 @@ __metadata:
languageName: node
linkType: hard
"gpt-tokens@npm:^1.3.6":
version: 1.3.9
resolution: "gpt-tokens@npm:1.3.9"
"gpt-tokens@npm:^1.3.10":
version: 1.3.10
resolution: "gpt-tokens@npm:1.3.10"
dependencies:
decimal.js: "npm:^10.4.3"
js-tiktoken: "npm:^1.0.14"
openai-chat-tokens: "npm:^0.2.8"
checksum: 10c0/14ea94c0df4b83fdbfc8ee9c337aca5584441f8b4440a619eea9defc65dc3782fdb81c138d7153e39bae34cff60ce778e1d38f62e775d7cc378c2eac78d3299c
checksum: 10c0/9aa83bf1aecc3a11b8557769fa20d0f9daae61e3e79e1e308e2435069cee9c49f06563f68d9ce90b53c4981e84c92bf9bb43168f042e4606b51ccfce9210f95c
languageName: node
linkType: hard
@@ -4802,7 +4833,7 @@ __metadata:
languageName: node
linkType: hard
"inherits@npm:2":
"inherits@npm:2, inherits@npm:~2.0.3":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2
@@ -5176,6 +5207,13 @@ __metadata:
languageName: node
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":
version: 4.0.10
resolution: "isbinaryfile@npm:4.0.10"
@@ -5384,6 +5422,18 @@ __metadata:
languageName: node
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":
version: 0.16.11
resolution: "katex@npm:0.16.11"
@@ -5430,6 +5480,15 @@ __metadata:
languageName: node
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":
version: 1.10.0
resolution: "localforage@npm:1.10.0"
@@ -6702,6 +6761,13 @@ __metadata:
languageName: node
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":
version: 1.0.1
resolution: "parent-module@npm:1.0.1"
@@ -6925,6 +6991,13 @@ __metadata:
languageName: node
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":
version: 2.0.3
resolution: "progress@npm:2.0.3"
@@ -7752,6 +7825,21 @@ __metadata:
languageName: node
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":
version: 3.6.0
resolution: "readdirp@npm:3.6.0"
@@ -8117,6 +8205,13 @@ __metadata:
languageName: node
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":
version: 1.0.3
resolution: "safe-regex-test@npm:1.0.3"
@@ -8198,7 +8293,7 @@ __metadata:
languageName: node
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
resolution: "semver@npm:7.6.3"
bin:
@@ -8242,6 +8337,13 @@ __metadata:
languageName: node
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":
version: 1.1.0
resolution: "shallowequal@npm:1.1.0"
@@ -8493,6 +8595,15 @@ __metadata:
languageName: node
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":
version: 4.0.4
resolution: "stringify-entities@npm:4.0.4"
@@ -8752,7 +8863,7 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:^2.6.2":
"tslib@npm:^2.1.0, tslib@npm:^2.6.2":
version: 2.7.0
resolution: "tslib@npm:2.7.0"
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>":
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:
tsc: bin/tsc
tsserver: bin/tsserver
checksum: 10c0/e6c1662e4852e22fe4bbdca471dca3e3edc74f6f1df043135c44a18a7902037023ccb0abdfb754595ca9028df8920f2f8492c00fc3cbb4309079aae8b7de71cd
checksum: 10c0/94eb47e130d3edd964b76da85975601dcb3604b0c848a36f63ac448d0104e93819d94c8bdf6b07c00120f2ce9c05256b8b6092d23cf5cf1c6fa911159e4d572f
languageName: node
linkType: hard
@@ -9002,6 +9113,17 @@ __metadata:
languageName: node
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":
version: 1.1.0
resolution: "update-browserslist-db@npm:1.1.0"
@@ -9050,6 +9172,13 @@ __metadata:
languageName: node
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":
version: 10.0.0
resolution: "uuid@npm:10.0.0"
@@ -9332,6 +9461,13 @@ __metadata:
languageName: node
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":
version: 3.1.1
resolution: "yallist@npm:3.1.1"