Compare commits

...

25 Commits

Author SHA1 Message Date
kangfenmao
a654ccc25e chore(version): 0.7.0 2024-09-14 21:28:39 +08:00
kangfenmao
71a35ccd44 fix: removed dev tools, updated sidebar links, fixed file deletion.
- Removed ability to open developer tools in main window.
- Added and removed a link to the "/files" route in the Sidebar component.
- Fixed file deletion logic to correctly delete files from both the database and the file system.
2024-09-14 21:28:39 +08:00
kangfenmao
29826ff091 fix: removed 'trigger' attribute from popover component 2024-09-14 17:22:03 +08:00
kangfenmao
8566476d91 feat: add id to miniapp 2024-09-14 17:02:47 +08:00
kangfenmao
a173a87f29 style: improved formatting in add agent popup.
- Improved formatting of prompt and fetched generated text in Add Agent Popup.
2024-09-14 16:53:22 +08:00
Aimer
cb068d71ca Modified the prompt part Modified the minapp data part 2024-09-14 16:23:58 +08:00
kangfenmao
66210d1d2e fix: remove trailing double spaces from markdown strings 2024-09-14 16:17:35 +08:00
kangfenmao
aa427c9911 refactor: update file management to use filetype instead of filemetadata 2024-09-14 16:08:43 +08:00
kangfenmao
9ae9fdf392 refactor: remove sqlite3 use dexie 2024-09-14 15:25:56 +08:00
kangfenmao
0ddef31ed8 chore: update build process and database configuration.
- Updated configuration to exclude additional directories from electron-builder's build process.
- Dropped the creation of the "files" table in the database schema.
- Improved code organization and extracted the data path into a reusable function.
- Updated database migration configuration to use a new migration manager.
- Added database migration to create a table for file management.
- A migration to remove the "files" table has been applied.
2024-09-13 17:03:26 +08:00
kangfenmao
617af8b12a feat: implemented vision model support and ui enhancements.
- Updated color palette settings have been implemented.
- Added VisionIcon component utilizing Ant Design icons and styled components for visual customization.
- Updated vision model regex to include additional models.
- Added support for multiple file columns in i18n resources.
- Added translations to column titles.
- Added support for vision models in the Select Model Button component.
- Added functionality to display a vision model icon next to the model name on dropdown items.
- Implemented changes to add vision model support to the Edit Models Popup.
- Added icon to display vision models in provider settings.
2024-09-13 15:46:48 +08:00
kangfenmao
71876e6a70 feat: added attachment preview and upload/removal capabilities.
- Added functionality to display attachment preview with upload and removal capabilities.
- Added support for file attachments to the input bar.
2024-09-13 14:47:05 +08:00
kangfenmao
4f250cdcb1 refactor: use sequelize replace better-sqlite3 2024-09-13 13:26:22 +08:00
kangfenmao
9268ab845e fix: Corrected image mime type in IPC message.
- Corrected image mime type in IPC message.
2024-09-13 13:26:22 +08:00
kangfenmao
0337c6649b feat: Added tracking column to files table and updated FileMetadata interface.
- Added a "count" column with default value 1 to the "files" table for tracking purposes.
- Improved file duplication and deletion handling.
- Updated regular expression for vision models to include additional providers.
- Improved removal of topics for assistants from local storage.
- Added support for human-readable date formats in file metadata.
- Improved handling of messages with image attachments to include base64 encoded images in the response.
- Added new 'count' property to the FileMetadata interface.
2024-09-13 13:26:22 +08:00
kangfenmao
8781388760 feat: Improved IPC image handling and added vision model support.
- Improved IPC image handling to return mime type and base64 encoded data alongside the image data.
- Updated type definition for `base64` method in image object to return an object with mime, base64, and data properties.
- Added support for vision models using new function and regex.
- Table cell size has been reduced on the FilesPage component.
- Added support for vision model attachments.
- Added model dependency to AttachmentButton component.
- Implemented new functionality to handle image messages in the GeminiProvider class.
- Update image base64 encoding to directly use API response data.
2024-09-13 13:26:22 +08:00
kangfenmao
2016ba7062 feat: add attachment files 2024-09-13 13:26:22 +08:00
kangfenmao
a03d619e2f feat: add sqlite database manager 2024-09-13 13:26:22 +08:00
kangfenmao
76d1f0bb1e feat: added file management functionality and API operations
- Improved functionality for file management has been added.
- Added file system management functionality through IPC.
- Added functionality to interact with files including selection, upload, deletion, and batch operations.
- Added new file operations to the custom API, including file select, upload, delete, batch upload, and batch delete functions.
- Implemented feature to select and upload files via API.
2024-09-13 13:26:22 +08:00
kangfenmao
2bad5a1184 feat: add file class 2024-09-13 13:26:22 +08:00
kangfenmao
94ba3aee05 feat: add files sidebar menu 2024-09-13 13:26:22 +08:00
kangfenmao
563758f69f refactor: renamed generate method to generateText for clarity and consistency 2024-09-13 10:03:30 +08:00
kangfenmao
56af85cc3e feat: add generate to ai provider api 2024-09-13 09:57:27 +08:00
kangfenmao
6a1a861ecc chore(version): 0.6.14 2024-09-11 20:58:46 +08:00
kangfenmao
ceab574a22 feat: Add new image file and Poe app support.
- Added a new image file.
- Added Poe app to the list of supported apps.
- Removed unused provider configuration.
2024-09-11 20:58:29 +08:00
58 changed files with 1840 additions and 1377 deletions

View File

@@ -15,6 +15,7 @@ module.exports = {
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off',
'react/prop-types': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error'
'simple-import-sort/exports': 'error',
'react/no-is-mounted': 'off'
}
}

View File

@@ -29,5 +29,6 @@
},
"[markdown]": {
"files.trimTrailingWhitespace": false
}
},
"i18n-ally.localesPaths": ["src/renderer/src/i18n"]
}

View File

@@ -8,8 +8,10 @@ files:
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!src/*'
- '!src'
- '!local'
- '!scripts'
- '!resources'
asarUnpack:
- resources/**
win:

View File

@@ -7,7 +7,8 @@ export default defineConfig({
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
ollama: resolve('ollama/src')
'@types': resolve('src/renderer/src/types'),
'@main': resolve('src/main')
}
}
},

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "0.6.13",
"version": "0.7.0",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -54,6 +54,8 @@
"axios": "^1.7.3",
"browser-image-compression": "^2.0.2",
"dayjs": "^1.11.11",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"dotenv-cli": "^7.4.2",
"electron": "^28.3.3",
"electron-builder": "^24.9.1",
@@ -69,6 +71,7 @@
"i18next": "^23.11.5",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mime": "^4.0.4",
"openai": "^4.52.1",
"prettier": "^3.2.4",
"react": "^18.2.0",
@@ -87,7 +90,7 @@
"remark-math": "^6.0.0",
"sass": "^1.77.2",
"styled-components": "^6.1.11",
"typescript": "^5.3.3",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"vite": "^5.0.12"
},

View File

@@ -1,4 +1,18 @@
import fs from 'node:fs'
import { app } from 'electron'
import Store from 'electron-store'
import path from 'path'
const getDataPath = () => {
const dataPath = path.join(app.getPath('userData'), 'Data')
if (!fs.existsSync(dataPath)) {
fs.mkdirSync(dataPath, { recursive: true })
}
return dataPath
}
export const DATA_PATH = getDataPath()
export const appConfig = new Store()

View File

@@ -1,11 +1,18 @@
import { BrowserWindow, ipcMain, session, shell } from 'electron'
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 './updater'
import AppUpdater from './services/AppUpdater'
import File from './services/File'
import { openFile, saveFile } from './utils/file'
import { compress, decompress } from './utils/zip'
import { createMinappWindow } from './window'
const fileManager = new File()
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const { autoUpdater } = new AppUpdater(mainWindow)
@@ -31,6 +38,28 @@ 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: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('minapp', (_, args) => {
createMinappWindow({
url: args.url,

139
src/main/services/File.ts Normal file
View File

@@ -0,0 +1,139 @@
import { getFileType } from '@main/utils/file'
import { FileType } from '@types'
import * as crypto from 'crypto'
import { app, dialog, OpenDialogOptions } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import { v4 as uuidv4 } from 'uuid'
class File {
private storageDir: string
constructor() {
this.storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
this.initStorageDir()
}
private initStorageDir(): void {
if (!fs.existsSync(this.storageDir)) {
fs.mkdirSync(this.storageDir, { recursive: true })
}
}
private async getFileHash(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('md5')
const stream = fs.createReadStream(filePath)
stream.on('data', (data) => hash.update(data))
stream.on('end', () => resolve(hash.digest('hex')))
stream.on('error', reject)
})
}
async findDuplicateFile(filePath: string): Promise<FileType | null> {
const stats = fs.statSync(filePath)
const fileSize = stats.size
const files = await fs.promises.readdir(this.storageDir)
for (const file of files) {
const storedFilePath = path.join(this.storageDir, file)
const storedStats = fs.statSync(storedFilePath)
if (storedStats.size === fileSize) {
const [originalHash, storedHash] = await Promise.all([
this.getFileHash(filePath),
this.getFileHash(storedFilePath)
])
if (originalHash === storedHash) {
const ext = path.extname(file)
const id = path.basename(file, ext)
return {
id,
origin_name: file,
name: file + ext,
path: storedFilePath,
created_at: storedStats.birthtime,
size: storedStats.size,
ext,
type: getFileType(ext),
count: 2
}
}
}
}
return null
}
async selectFile(options?: OpenDialogOptions): Promise<FileType[] | null> {
const defaultOptions: OpenDialogOptions = {
properties: ['openFile']
}
const dialogOptions = { ...defaultOptions, ...options }
const result = await dialog.showOpenDialog(dialogOptions)
if (result.canceled || result.filePaths.length === 0) {
return null
}
const fileMetadataPromises = result.filePaths.map(async (filePath) => {
const stats = fs.statSync(filePath)
const ext = path.extname(filePath)
const fileType = getFileType(ext)
return {
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 Promise.all(fileMetadataPromises)
}
async uploadFile(file: FileType): Promise<FileType> {
const duplicateFile = await this.findDuplicateFile(file.path)
if (duplicateFile) {
return duplicateFile
}
const uuid = uuidv4()
const origin_name = path.basename(file.path)
const ext = path.extname(origin_name)
const destPath = path.join(this.storageDir, uuid + ext)
await fs.promises.copyFile(file.path, destPath)
const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
const fileMetadata: FileType = {
id: uuid,
origin_name,
name: uuid + ext,
path: destPath,
created_at: stats.birthtime,
size: stats.size,
ext: ext,
type: fileType,
count: 1
}
return fileMetadata
}
async deleteFile(id: string): Promise<void> {
await fs.promises.unlink(path.join(this.storageDir, id))
}
}
export default File

View File

@@ -3,6 +3,8 @@ import logger from 'electron-log'
import { writeFile } from 'fs'
import { readFile } from 'fs/promises'
import { FileTypes } from '../../renderer/src/types'
export async function saveFile(
_: Electron.IpcMainInvokeEvent,
fileName: string,
@@ -53,3 +55,17 @@ export async function openFile(
return null
}
}
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']
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 (documentExts.includes(ext)) return FileTypes.DOCUMENT
return FileTypes.OTHER
}

7
src/main/utils/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import path from 'node:path'
import { app } from 'electron'
export function getResourcePath() {
return path.join(app.getAppPath(), 'resources')
}

View File

@@ -1,4 +1,5 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import { FileType } from '@renderer/types'
import type { OpenDialogOptions } from 'electron'
declare global {
@@ -20,6 +21,14 @@ declare global {
reload: () => void
compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string>
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 }>
}
}
}
}

View File

@@ -1,5 +1,5 @@
import { electronAPI } from '@electron-toolkit/preload'
import { contextBridge, ipcRenderer } from 'electron'
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
// Custom APIs for renderer
const api = {
@@ -15,7 +15,15 @@ const api = {
ipcRenderer.invoke('save-file', path, content, options)
},
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
file: {
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)
}
}
// Use `contextBridge` APIs to expose Electron APIs to

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="initial-scale=1, width=device-width" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src *; script-src 'self' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: *; frame-src * file:" />
content="default-src 'self'; connect-src *; script-src 'self' *; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' *; font-src 'self' data: *; img-src 'self' data: file: *; frame-src * file:" />
</head>
<body>
<div id="root"></div>

View File

@@ -1,3 +1,5 @@
import '@renderer/databases'
import store, { persistor } from '@renderer/store'
import { Provider } from 'react-redux'
import { HashRouter, Route, Routes } from 'react-router-dom'
@@ -9,6 +11,7 @@ import AntdProvider from './context/AntdProvider'
import { ThemeProvider } from './context/ThemeProvider'
import AgentsPage from './pages/agents/AgentsPage'
import AppsPage from './pages/apps/AppsPage'
import FilesPage from './pages/files/FilesPage'
import HomePage from './pages/home/HomePage'
import SettingsPage from './pages/settings/SettingsPage'
import TranslatePage from './pages/translate/TranslatePage'
@@ -24,6 +27,7 @@ function App(): JSX.Element {
<Sidebar />
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/translate" element={<TranslatePage />} />
<Route path="/apps" element={<AppsPage />} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -24,9 +24,9 @@
--color-background-soft: var(--color-black-soft);
--color-background-mute: var(--color-black-mute);
--color-primary: #135200;
--color-primary-soft: #13520099;
--color-primary-mute: #13520033;
--color-primary: #00b96b;
--color-primary-soft: #00b96b99;
--color-primary-mute: #00b96b33;
--color-text: var(--color-text-1);
--color-icon: #ffffff99;

View File

@@ -0,0 +1,15 @@
import { EyeOutlined } from '@ant-design/icons'
import React, { FC } from 'react'
import styled from 'styled-components'
const VisionIcon: FC<React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>> = (props) => {
return <Icon {...(props as any)} />
}
const Icon = styled(EyeOutlined)`
color: var(--color-primary);
font-size: 14px;
margin-left: 4px;
`
export default VisionIcon

View File

@@ -1,4 +1,4 @@
import { TranslationOutlined } from '@ant-design/icons'
import { FolderOutlined, TranslationOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env'
import useAvatar from '@renderer/hooks/useAvatar'
@@ -66,6 +66,11 @@ const Sidebar: FC = () => {
<i className="iconfont icon-appstore"></i>
</Icon>
</StyledLink>
<StyledLink onClick={() => to('/files')}>
<Icon className={isRoute('/files')}>
<FolderOutlined />
</Icon>
</StyledLink>
</Menus>
</MainMenus>
<Menus>

View File

@@ -4,6 +4,7 @@ import BaiduAiAppLogo from '@renderer/assets/images/apps/baidu-ai.png'
import DevvAppLogo from '@renderer/assets/images/apps/devv.png'
import MetasoAppLogo from '@renderer/assets/images/apps/metaso.webp'
import PerplexityAppLogo from '@renderer/assets/images/apps/perplexity.webp'
import PoeAppLogo from '@renderer/assets/images/apps/poe.webp'
import SensetimeAppLogo from '@renderer/assets/images/apps/sensetime.png'
import SparkDeskAppLogo from '@renderer/assets/images/apps/sparkdesk.png'
import TiangongAiLogo from '@renderer/assets/images/apps/tiangong.png'
@@ -15,56 +16,73 @@ import { MinAppType } from '@renderer/types'
const _apps: MinAppType[] = [
{
name: 'AI 助手',
logo: AiAssistantAppLogo,
url: 'https://bot.360.com/'
},
{
name: '文心一言',
logo: BaiduAiAppLogo,
url: 'https://yiyan.baidu.com/'
},
{
name: 'SparkDesk',
logo: SparkDeskAppLogo,
url: 'https://xinghuo.xfyun.cn/desk'
},
{
name: '腾讯元宝',
logo: TencentYuanbaoAppLogo,
url: 'https://yuanbao.tencent.com/chat'
},
{
name: '商量',
logo: SensetimeAppLogo,
url: 'https://chat.sensetime.com/wb/chat'
},
{
id: '360-ai-so',
name: '360AI搜索',
logo: AiSearchAppLogo,
url: 'https://so.360.com/'
},
{
id: '360-ai-bot',
name: 'AI 助手',
logo: AiAssistantAppLogo,
url: 'https://bot.360.com/'
},
{
id: 'baidu-ai-chat',
name: '文心一言',
logo: BaiduAiAppLogo,
url: 'https://yiyan.baidu.com/'
},
{
id: 'tencent-yuanbao',
name: '腾讯元宝',
logo: TencentYuanbaoAppLogo,
url: 'https://yuanbao.tencent.com/chat'
},
{
id: 'sensetime-chat',
name: '商量',
logo: SensetimeAppLogo,
url: 'https://chat.sensetime.com/wb/chat'
},
{
id: 'spark-desk',
name: 'SparkDesk',
logo: SparkDeskAppLogo,
url: 'https://xinghuo.xfyun.cn/desk'
},
{
id: 'metaso',
name: '秘塔AI搜索',
logo: MetasoAppLogo,
url: 'https://metaso.cn/'
},
{
name: '天工AI',
logo: TiangongAiLogo,
url: 'https://www.tiangong.cn/'
},
{
name: 'DEVV_',
logo: DevvAppLogo,
url: 'https://devv.ai/'
id: 'poe',
name: 'Poe',
logo: PoeAppLogo,
url: 'https://poe.com'
},
{
id: 'perplexity',
name: 'perplexity',
logo: PerplexityAppLogo,
url: 'https://www.perplexity.ai/'
},
{
id: 'devv',
name: 'DEVV_',
logo: DevvAppLogo,
url: 'https://devv.ai/'
},
{
id: 'tiangong-ai',
name: '天工AI',
logo: TiangongAiLogo,
url: 'https://www.tiangong.cn/'
},
{
id: 'zhihu-zhiada',
name: '知乎直答',
logo: ZhihuAppLogo,
url: 'https://zhida.zhihu.com/'

View File

@@ -1,6 +1,7 @@
import { Model } from '@renderer/types'
const TEXT_TO_IMAGE_REGEX = /flux|diffusion|stabilityai|sd-turbo|dall|cogview/i
const VISION_REGEX = /llava|moondream|minicpm|gemini-1.5|claude-3|vision|glm-4v|gpt-4|qwen-vl/i
const EMBEDDING_REGEX = /embedding/i
export const SYSTEM_MODELS: Record<string, Model[]> = {
@@ -395,3 +396,7 @@ export function isTextToImageModel(model: Model): boolean {
export function isEmbeddingModel(model: Model): boolean {
return EMBEDDING_REGEX.test(model.id)
}
export function isVisionModel(model: Model): boolean {
return VISION_REGEX.test(model.id)
}

View File

@@ -136,6 +136,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.openai.com/docs/models'
},
app: {
id: 'openai',
name: 'ChatGPT',
url: 'https://chatgpt.com/',
logo: OpenAiProviderLogo
@@ -152,6 +153,7 @@ export const PROVIDER_CONFIG = {
models: 'https://ai.google.dev/gemini-api/docs/models/gemini'
},
app: {
id: 'gemini',
name: 'Gemini',
url: 'https://gemini.google.com/',
logo: GeminiProviderLogo
@@ -168,6 +170,7 @@ export const PROVIDER_CONFIG = {
models: 'https://docs.siliconflow.cn/docs/model-names'
},
app: {
id: 'silicon',
name: 'SiliconFlow',
url: 'https://cloud.siliconflow.cn/playground/chat',
logo: SiliconFlowProviderLogo
@@ -184,6 +187,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.deepseek.com/api-docs/'
},
app: {
id: 'deepseek',
name: 'DeepSeek',
url: 'https://chat.deepseek.com/',
logo: DeepSeekProviderLogo
@@ -198,11 +202,6 @@ export const PROVIDER_CONFIG = {
apiKey: 'https://github.com/settings/tokens',
docs: 'https://docs.github.com/en/github-models',
models: 'https://github.com/marketplace/models'
},
app: {
name: 'Github Models',
url: 'https://github.com/marketplace/models/azure-openai/gpt-4o/playground',
logo: GithubProviderLogo
}
},
yi: {
@@ -216,6 +215,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.lingyiwanwu.com/docs#%E6%A8%A1%E5%9E%8B'
},
app: {
id: 'yi',
name: 'Yi',
url: 'https://www.wanzhi.com/',
logo: YiProviderLogo
@@ -232,6 +232,7 @@ export const PROVIDER_CONFIG = {
models: 'https://open.bigmodel.cn/modelcenter/square'
},
app: {
id: 'zhipu',
name: '智谱',
url: 'https://chatglm.cn/main/alltoolsdetail',
logo: ZhipuProviderLogo
@@ -248,6 +249,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.moonshot.cn/docs/intro#%E6%A8%A1%E5%9E%8B%E5%88%97%E8%A1%A8'
},
app: {
id: 'moonshot',
name: 'Kimi',
url: 'https://kimi.moonshot.cn/',
logo: KimiAppLogo
@@ -264,6 +266,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.baichuan-ai.com/price'
},
app: {
id: 'baichuan',
name: '百小应',
url: 'https://ying.baichuan-ai.com/chat',
logo: BaicuanAppLogo
@@ -280,6 +283,7 @@ export const PROVIDER_CONFIG = {
models: 'https://dashscope.console.aliyun.com/model'
},
app: {
id: 'dashscope',
name: '通义千问',
url: 'https://tongyi.aliyun.com/qianwen/',
logo: QwenModelLogo
@@ -296,6 +300,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.stepfun.com/docs/llm/text'
},
app: {
id: 'stepfun',
name: '跃问',
url: 'https://yuewen.cn/chats/new',
logo: YuewenAppLogo
@@ -312,6 +317,7 @@ export const PROVIDER_CONFIG = {
models: 'https://console.volcengine.com/ark/region:ark+cn-beijing/endpoint'
},
app: {
id: 'doubao',
name: '豆包',
url: 'https://www.doubao.com/chat/',
logo: DoubaoProviderLogo
@@ -328,6 +334,7 @@ export const PROVIDER_CONFIG = {
models: 'https://platform.minimaxi.com/document/Models'
},
app: {
id: 'minimax',
name: '海螺',
url: 'https://hailuoai.com/',
logo: HailuoModelLogo
@@ -360,6 +367,7 @@ export const PROVIDER_CONFIG = {
models: 'https://console.groq.com/docs/models'
},
app: {
id: 'groq',
name: 'Groq',
url: 'https://chat.groq.com/',
logo: GroqProviderLogo
@@ -386,6 +394,7 @@ export const PROVIDER_CONFIG = {
models: 'https://docs.anthropic.com/en/docs/about-claude/models'
},
app: {
id: 'anthropic',
name: 'Claude',
url: 'https://claude.ai/',
logo: AnthropicProviderLogo

View File

@@ -1,5 +1,5 @@
import { useSettings } from '@renderer/hooks/useSettings'
import { ThemeMode } from '@renderer/store/settings'
import { ThemeMode } from '@renderer/types'
import React, { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
interface ThemeContextType {

View File

@@ -0,0 +1,13 @@
import { FileType } from '@renderer/types'
import { Dexie, type EntityTable } from 'dexie'
// Database declaration (move this to its own module also)
export const db = new Dexie('CherryStudio') as Dexie & {
files: EntityTable<FileType, 'id'>
}
db.version(1).stores({
files: 'id, name, origin_name, path, size, ext, type, created_at, count'
})
export default db

View File

@@ -11,8 +11,7 @@ import { useSettings } from './useSettings'
export function useAppInit() {
const dispatch = useAppDispatch()
const { proxyUrl } = useSettings()
const { language } = useSettings()
const { proxyUrl, language } = useSettings()
const { setDefaultModel, setTopicNamingModel, setTranslateModel } = useDefaultModel()
useEffect(() => {

View File

@@ -1,4 +1,5 @@
import { getDefaultTopic } from '@renderer/services/assistant'
import LocalStorage from '@renderer/services/storage'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import {
addAssistant,
@@ -16,7 +17,6 @@ import {
} from '@renderer/store/assistants'
import { setDefaultModel, setTopicNamingModel, setTranslateModel } from '@renderer/store/llm'
import { Assistant, AssistantSettings, Model, Topic } from '@renderer/types'
import localforage from 'localforage'
export function useAssistants() {
const { assistants } = useAppSelector((state) => state.assistants)
@@ -29,9 +29,8 @@ export function useAssistants() {
removeAssistant: (id: string) => {
dispatch(removeAssistant({ id }))
const assistant = assistants.find((a) => a.id === id)
if (assistant) {
assistant.topics.forEach((id) => localforage.removeItem(`topic:${id}`))
}
const topics = assistant?.topics || []
topics.forEach(({ id }) => LocalStorage.removeTopic(id))
}
}
}
@@ -45,7 +44,10 @@ export function useAssistant(id: string) {
assistant,
model: assistant?.model ?? defaultModel,
addTopic: (topic: Topic) => dispatch(addTopic({ assistantId: assistant.id, topic })),
removeTopic: (topic: Topic) => dispatch(removeTopic({ assistantId: assistant.id, topic })),
removeTopic: (topic: Topic) => {
LocalStorage.removeTopic(topic.id)
dispatch(removeTopic({ assistantId: assistant.id, topic }))
},
updateTopic: (topic: Topic) => dispatch(updateTopic({ assistantId: assistant.id, topic })),
updateTopics: (topics: Topic[]) => dispatch(updateTopics({ assistantId: assistant.id, topics })),
removeAllTopics: () => dispatch(removeAllTopics({ assistantId: assistant.id })),

View File

@@ -4,9 +4,9 @@ import {
setSendMessageShortcut as _setSendMessageShortcut,
setTheme,
setTopicPosition,
setWindowStyle,
ThemeMode
setWindowStyle
} from '@renderer/store/settings'
import { ThemeMode } from '@renderer/types'
export function useSettings() {
const settings = useAppSelector((state) => state.settings)

View File

@@ -102,6 +102,13 @@ const resources = {
'message.new.context': 'New Context',
'assistant.search.placeholder': 'Search'
},
files: {
title: 'Files',
file: 'File',
name: 'Name',
size: 'Size',
created_at: 'Created At'
},
agents: {
title: 'Assistants',
my_agents: 'My Assistants',
@@ -358,6 +365,13 @@ const resources = {
'message.new.context': '清除上下文',
'assistant.search.placeholder': '搜索'
},
files: {
title: '文件',
file: '文件',
name: '文件名',
size: '大小',
created_at: '创建时间'
},
agents: {
title: '智能体',
my_agents: '我的智能体',

View File

@@ -2,7 +2,7 @@ import KeyvStorage from '@kangfenmao/keyv-storage'
import localforage from 'localforage'
import { APP_NAME } from './config/env'
import { ThemeMode } from './store/settings'
import { ThemeMode } from './types'
import { loadScript } from './utils'
export async function initMermaid(theme: ThemeMode) {

View File

@@ -1,8 +1,10 @@
import 'emoji-picker-element'
import { LoadingOutlined, ThunderboltOutlined } from '@ant-design/icons'
import EmojiPicker from '@renderer/components/EmojiPicker'
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { fetchGenerate } from '@renderer/services/api'
import { syncAgentToAssistant } from '@renderer/services/assistant'
import { Agent } from '@renderer/types'
import { getLeadingEmoji, uuid } from '@renderer/utils'
@@ -29,6 +31,7 @@ const PopupContainer: React.FC<Props> = ({ agent, resolve }) => {
const { addAgent, updateAgent } = useAgents()
const formRef = useRef<FormInstance>(null)
const [emoji, setEmoji] = useState(agent?.emoji)
const [loading, setLoading] = useState(false)
const onFinish = (values: FieldType) => {
const _emoji = emoji || getLeadingEmoji(values.name)
@@ -81,6 +84,34 @@ const PopupContainer: React.FC<Props> = ({ agent, resolve }) => {
}
}, [agent, form])
const handleButtonClick = async () => {
const prompt = `你是一个专业的 prompt 优化助手我会给你一段prompt你需要帮我优化它仅回复优化后的 prompt 不要添加任何解释,使用 [CRISPE提示框架] 回复。`
const name = formRef.current?.getFieldValue('name')
const content = formRef.current?.getFieldValue('prompt')
const promptText = content || name
if (!promptText) {
return
}
if (content) {
navigator.clipboard.writeText(content)
}
setLoading(true)
try {
const prefixedContent = `请帮我优化下面这段 prompt使用 CRISPE 提示框架,请使用 Markdown 格式回复,不要使用 codeblock: ${promptText}`
const generatedText = await fetchGenerate({ prompt, content: prefixedContent })
formRef.current?.setFieldValue('prompt', generatedText)
} catch (error) {
console.error('Error fetching data:', error)
}
setLoading(false)
}
return (
<Modal
title={agent ? t('agents.edit.title') : t('agents.add.title')}
@@ -100,16 +131,28 @@ const PopupContainer: React.FC<Props> = ({ agent, resolve }) => {
style={{ marginTop: 25 }}
onFinish={onFinish}>
<Form.Item name="name" label="Emoji">
<Popover content={<EmojiPicker onEmojiClick={setEmoji} />} trigger="click" arrow>
<Popover content={<EmojiPicker onEmojiClick={setEmoji} />} arrow>
<Button icon={emoji && <span style={{ fontSize: 20 }}>{emoji}</span>}>{t('common.select')}</Button>
</Popover>
</Form.Item>
<Form.Item name="name" label={t('agents.add.name')} rules={[{ required: true }]}>
<Input placeholder={t('agents.add.name.placeholder')} spellCheck={false} allowClear />
</Form.Item>
<Form.Item name="prompt" label={t('agents.add.prompt')} rules={[{ required: true }]}>
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={10} />
</Form.Item>
<div style={{ position: 'relative' }}>
<Form.Item
name="prompt"
label={t('agents.add.prompt')}
rules={[{ required: true }]}
style={{ position: 'relative' }}>
<TextArea placeholder={t('agents.add.prompt.placeholder')} spellCheck={false} rows={10} />
</Form.Item>
<Button
icon={loading ? <LoadingOutlined /> : <ThunderboltOutlined />}
onClick={handleButtonClick}
style={{ position: 'absolute', top: 8, right: 8 }}
disabled={loading}
/>
</div>
</Form>
</Modal>
)

View File

@@ -4,23 +4,22 @@ import { Center } from '@renderer/components/Layout'
import { getAllMinApps } from '@renderer/config/minapp'
import { Empty, Input } from 'antd'
import { isEmpty } from 'lodash'
import { FC, useState } from 'react'
import { FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import App from './App'
const list = getAllMinApps()
const AppsPage: FC = () => {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const apps = useMemo(() => getAllMinApps(), [])
const apps = search
? list.filter(
const filteredApps = search
? apps.filter(
(app) => app.name.toLowerCase().includes(search.toLowerCase()) || app.url.includes(search.toLowerCase())
)
: list
: apps
return (
<Container>
@@ -42,10 +41,10 @@ const AppsPage: FC = () => {
</Navbar>
<ContentContainer>
<AppsContainer>
{apps.map((app) => (
<App key={app.name} app={app} />
{filteredApps.map((app) => (
<App key={app.id} app={app} />
))}
{isEmpty(apps) && (
{isEmpty(filteredApps) && (
<Center style={{ flex: 1 }}>
<Empty />
</Center>

View File

@@ -0,0 +1,82 @@
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 { Image, Table } from 'antd'
import dayjs from 'dayjs'
import { useLiveQuery } from 'dexie-react-hooks'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const FilesPage: FC = () => {
const { t } = useTranslation()
const files = useLiveQuery<FileType[]>(() => db.files.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 columns = [
{
title: t('files.file'),
dataIndex: 'file',
key: 'file',
width: '300px'
},
{
title: t('files.name'),
dataIndex: 'name',
key: 'name'
},
{
title: t('files.size'),
dataIndex: 'size',
key: 'size',
width: '100px'
},
{
title: t('files.created_at'),
dataIndex: 'created_at',
key: 'created_at',
width: '120px'
}
]
return (
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('files.title')}</NavbarCenter>
</Navbar>
<ContentContainer>
<VStack style={{ flex: 1 }}>
<Table dataSource={dataSource} columns={columns} style={{ width: '100%', height: '100%' }} size="small" />
</VStack>
</ContentContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
`
const ContentContainer = styled.div`
display: flex;
flex: 1;
flex-direction: row;
justify-content: center;
height: 100%;
overflow-y: scroll;
background-color: var(--color-background);
padding: 20px;
`
export default FilesPage

View File

@@ -1,29 +1,36 @@
import { PaperClipOutlined } from '@ant-design/icons'
import { Tooltip, Upload } from 'antd'
import { isVisionModel } from '@renderer/config/models'
import { FileType, Model } from '@renderer/types'
import { Tooltip } from 'antd'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
interface Props {
files: File[]
setFiles: (files: File[]) => void
model: Model
files: FileType[]
setFiles: (files: FileType[]) => void
ToolbarButton: any
}
const AttachmentButton: FC<Props> = ({ files, setFiles, ToolbarButton }) => {
const AttachmentButton: FC<Props> = ({ model, files, setFiles, ToolbarButton }) => {
const { t } = useTranslation()
const onSelectFile = async () => {
const _files = await window.api.file.select({
filters: [{ name: 'Files', extensions: ['jpg', 'png', 'jpeg'] }]
})
_files && setFiles(_files)
}
if (!isVisionModel(model)) {
return null
}
return (
<Tooltip placement="top" title={t('chat.input.upload')} arrow>
<Upload
customRequest={() => {}}
accept="image/*"
itemRender={() => null}
maxCount={1}
onChange={async ({ file }) => file?.originFileObj && setFiles([file.originFileObj as File])}>
<ToolbarButton type="text" className={files.length ? 'active' : ''}>
<PaperClipOutlined style={{ rotate: '135deg' }} />
</ToolbarButton>
</Upload>
<ToolbarButton type="text" className={files.length ? 'active' : ''} onClick={onSelectFile}>
<PaperClipOutlined style={{ rotate: '135deg' }} />
</ToolbarButton>
</Tooltip>
)
}

View File

@@ -0,0 +1,36 @@
import { FileType } from '@renderer/types'
import { Upload } from 'antd'
import { isEmpty } from 'lodash'
import { FC } from 'react'
import styled from 'styled-components'
interface Props {
files: FileType[]
setFiles: (files: FileType[]) => void
}
const AttachmentPreview: FC<Props> = ({ files, setFiles }) => {
if (isEmpty(files)) {
return null
}
return (
<Container>
<Upload
listType="picture-card"
fileList={files.map((file) => ({ uid: file.id, url: 'file://' + file.path, status: 'done', name: file.name }))}
onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))}
/>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
margin: 10px 20px;
margin-right: 0;
`
export default AttachmentPreview

View File

@@ -1,11 +1,11 @@
import {
ClearOutlined,
ControlOutlined,
FormOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
HistoryOutlined,
PauseCircleOutlined,
PlusCircleOutlined,
QuestionCircleOutlined
} from '@ant-design/icons'
import { useAssistant } from '@renderer/hooks/useAssistant'
@@ -13,10 +13,11 @@ 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 store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { setGenerating, setSearching } from '@renderer/store/runtime'
import { Assistant, Message, Topic } from '@renderer/types'
import { Assistant, FileType, Message, Topic } from '@renderer/types'
import { delay, uuid } from '@renderer/utils'
import { Button, Popconfirm, Tooltip } from 'antd'
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
@@ -26,6 +27,8 @@ import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState }
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AttachmentButton from './AttachmentButton'
import AttachmentPreview from './AttachmentPreview'
import SendMessageButton from './SendMessageButton'
import TokenCount from './TokenCount'
@@ -39,14 +42,14 @@ let _text = ''
const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const [text, setText] = useState(_text)
const [inputFocus, setInputFocus] = useState(false)
const { addTopic } = useAssistant(assistant.id)
const { addTopic, model } = useAssistant(assistant.id)
const { sendMessageShortcut, fontSize } = useSettings()
const [expended, setExpend] = useState(false)
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
const [contextCount, setContextCount] = useState(0)
const generating = useAppSelector((state) => state.runtime.generating)
const textareaRef = useRef<TextAreaRef>(null)
const [files, setFiles] = useState<File[]>([])
const [files, setFiles] = useState<FileType[]>([])
const { t } = useTranslation()
const containerRef = useRef(null)
const { showTopics, toggleShowTopics } = useShowTopics()
@@ -55,7 +58,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
_text = text
const sendMessage = useCallback(() => {
const sendMessage = useCallback(async () => {
if (generating) {
return
}
@@ -75,7 +78,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
}
if (files.length > 0) {
message.files = files
message.files = await FileManager.uploadFiles(files)
}
EventEmitter.emit(EVENT_NAMES.SEND_MESSAGE, message)
@@ -201,100 +204,108 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
}, [assistant])
return (
<Container id="inputbar" className={inputFocus ? 'focus' : ''} ref={containerRef}>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder')}
autoFocus
contextMenu="true"
variant="borderless"
rows={1}
ref={textareaRef}
style={{ fontSize }}
styles={{ textarea: TextareaStyle }}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
onInput={onInput}
disabled={searching}
onClick={() => searching && dispatch(setSearching(false))}
/>
<Toolbar>
<ToolbarMenu>
<Tooltip placement="top" title={t('chat.input.new_topic')} arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<PlusCircleOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={t('chat.input.clear')} arrow>
<Popconfirm
title={t('chat.input.clear.content')}
placement="top"
onConfirm={clearTopic}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
okText={t('chat.input.clear')}>
<ToolbarButton type="text">
<ClearOutlined />
</ToolbarButton>
</Popconfirm>
</Tooltip>
<Tooltip placement="top" title={t('chat.input.topics')} arrow>
<ToolbarButton
type="text"
onClick={() => {
!showTopics && toggleShowTopics()
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
}}>
<HistoryOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={t('chat.input.settings')} arrow>
<ToolbarButton
type="text"
onClick={() => {
!showTopics && toggleShowTopics()
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_CHAT_SETTINGS), 0)
}}>
<ControlOutlined />
</ToolbarButton>
</Tooltip>
{/* <AttachmentButton files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} /> */}
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</ToolbarButton>
</Tooltip>
<TokenCount
estimateTokenCount={estimateTokenCount}
inputTokenCount={inputTokenCount}
contextCount={contextCount}
ToolbarButton={ToolbarButton}
onClick={onNewContext}
/>
</ToolbarMenu>
<ToolbarMenu>
{generating && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
<PauseCircleOutlined style={{ color: 'var(--color-error)', fontSize: 20 }} />
<Container>
<AttachmentPreview files={files} setFiles={setFiles} />
<InputBarContainer id="inputbar" className={inputFocus ? 'focus' : ''} ref={containerRef}>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('chat.input.placeholder')}
autoFocus
contextMenu="true"
variant="borderless"
rows={1}
ref={textareaRef}
style={{ fontSize }}
styles={{ textarea: TextareaStyle }}
onFocus={() => setInputFocus(true)}
onBlur={() => setInputFocus(false)}
onInput={onInput}
disabled={searching}
onClick={() => searching && dispatch(setSearching(false))}
/>
<Toolbar>
<ToolbarMenu>
<Tooltip placement="top" title={t('chat.input.new_topic')} arrow>
<ToolbarButton type="text" onClick={addNewTopic}>
<FormOutlined />
</ToolbarButton>
</Tooltip>
)}
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || !text} />}
</ToolbarMenu>
</Toolbar>
<Tooltip placement="top" title={t('chat.input.clear')} arrow>
<Popconfirm
title={t('chat.input.clear.content')}
placement="top"
onConfirm={clearTopic}
okButtonProps={{ danger: true }}
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
okText={t('chat.input.clear')}>
<ToolbarButton type="text">
<ClearOutlined />
</ToolbarButton>
</Popconfirm>
</Tooltip>
<Tooltip placement="top" title={t('chat.input.topics')} arrow>
<ToolbarButton
type="text"
onClick={() => {
!showTopics && toggleShowTopics()
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
}}>
<HistoryOutlined />
</ToolbarButton>
</Tooltip>
<Tooltip placement="top" title={t('chat.input.settings')} arrow>
<ToolbarButton
type="text"
onClick={() => {
!showTopics && toggleShowTopics()
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_CHAT_SETTINGS), 0)
}}>
<ControlOutlined />
</ToolbarButton>
</Tooltip>
<AttachmentButton model={model} files={files} setFiles={setFiles} ToolbarButton={ToolbarButton} />
<Tooltip placement="top" title={expended ? t('chat.input.collapse') : t('chat.input.expand')} arrow>
<ToolbarButton type="text" onClick={onToggleExpended}>
{expended ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
</ToolbarButton>
</Tooltip>
<TokenCount
estimateTokenCount={estimateTokenCount}
inputTokenCount={inputTokenCount}
contextCount={contextCount}
ToolbarButton={ToolbarButton}
onClick={onNewContext}
/>
</ToolbarMenu>
<ToolbarMenu>
{generating && (
<Tooltip placement="top" title={t('chat.input.pause')} arrow>
<ToolbarButton type="text" onClick={onPause} style={{ marginRight: -2, marginTop: 1 }}>
<PauseCircleOutlined style={{ color: 'var(--color-error)', fontSize: 20 }} />
</ToolbarButton>
</Tooltip>
)}
{!generating && <SendMessageButton sendMessage={sendMessage} disabled={generating || !text} />}
</ToolbarMenu>
</Toolbar>
</InputBarContainer>
</Container>
)
}
const Container = styled.div`
display: flex;
flex-direction: column;
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '10px 15px 8px'
}
const Container = styled.div`
const InputBarContainer = styled.div`
border: 1px solid var(--color-border-soft);
transition: all 0.3s ease;
position: relative;
@@ -350,14 +361,23 @@ const ToolbarButton = styled(Button)`
transition: all 0.3s ease;
color: var(--color-icon);
}
&:hover,
&.active {
&:hover {
background-color: var(--color-background-soft);
.anticon,
.iconfont {
color: var(--color-text-1);
}
}
&.active {
background-color: var(--color-primary) !important;
.anticon,
.iconfont {
color: var(--color-white-soft);
}
&:hover {
background-color: var(--color-primary);
}
}
`
export default Inputbar

View File

@@ -2,7 +2,7 @@ import { CheckOutlined } from '@ant-design/icons'
import CopyIcon from '@renderer/components/Icons/CopyIcon'
import { useTheme } from '@renderer/context/ThemeProvider'
import { initMermaid } from '@renderer/init'
import { ThemeMode } from '@renderer/store/settings'
import { ThemeMode } from '@renderer/types'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'

View File

@@ -18,7 +18,7 @@ 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 } from '@renderer/utils'
import { firstLetter, removeLeadingEmoji, removeTrailingDoubleSpaces } from '@renderer/utils'
import { Alert, Avatar, Divider, Dropdown, Popconfirm, Tooltip } from 'antd'
import dayjs from 'dayjs'
import { upperFirst } from 'lodash'
@@ -54,7 +54,7 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
const showMetadata = Boolean(message.usage) && !generating
const onCopy = useCallback(() => {
navigator.clipboard.writeText(message.content)
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)

View File

@@ -8,7 +8,11 @@ interface Props {
}
const MessageAttachments: FC<Props> = ({ message }) => {
return <Container>{message.images?.map((image) => <Image src={image} key={image} width="33%" />)}</Container>
return (
<Container>
{message.files?.map((image) => <Image src={'file://' + image.path} key={image.id} width="33%" />)}
</Container>
)
}
const Container = styled.div`

View File

@@ -3,7 +3,12 @@ import { useProviderByAssistant } from '@renderer/hooks/useProvider'
import { getTopic } from '@renderer/hooks/useTopic'
import { fetchChatCompletion, fetchMessagesSummary } from '@renderer/services/api'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
import { estimateHistoryTokenCount, filterMessages, getContextCount } from '@renderer/services/messages'
import {
deleteMessageFiles,
estimateHistoryTokenCount,
filterMessages,
getContextCount
} from '@renderer/services/messages'
import LocalStorage from '@renderer/services/storage'
import { Assistant, Message, Model, Topic } from '@renderer/types'
import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
@@ -56,6 +61,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
const _messages = messages.filter((m) => m.id !== message.id)
setMessages(_messages)
localforage.setItem(`topic:${topic.id}`, { id: topic.id, messages: _messages })
deleteMessageFiles(message)
},
[messages, topic.id]
)

View File

@@ -1,5 +1,7 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { isLocalAi } from '@renderer/config/env'
import { isVisionModel } from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { Assistant } from '@renderer/types'
import { Button } from 'antd'
@@ -27,6 +29,7 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
<DropdownButton size="small" type="default">
<ModelAvatar model={model} size={20} />
<ModelName>{model ? upperFirst(model.name) : t('button.select_model')}</ModelName>
{isVisionModel(model) && <VisionIcon style={{ marginLeft: 0 }} />}
</DropdownButton>
</SelectModelDropdown>
)

View File

@@ -1,3 +1,5 @@
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { isVisionModel } from '@renderer/config/models'
import { getModelLogo } from '@renderer/config/provider'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/model'
@@ -25,8 +27,11 @@ const SelectModelDropdown: FC<Props & PropsWithChildren> = ({ children, model, o
type: 'group',
children: reverse(sortBy(p.models, 'name')).map((m) => ({
key: getModelUniqId(m),
label: upperFirst(m?.name),
defaultSelectedKeys: model ? [getModelUniqId(model)] : [],
label: (
<div>
{upperFirst(m?.name)} {isVisionModel(m) && <VisionIcon />}
</div>
),
icon: (
<Avatar src={getModelLogo(m?.id || '')} size={24}>
{first(m?.name)}

View File

@@ -5,8 +5,9 @@ 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, ThemeMode } from '@renderer/store/settings'
import { setLanguage, setUserName } 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 { FC, useState } from 'react'

View File

@@ -1,5 +1,6 @@
import { LoadingOutlined, MinusOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons'
import { SYSTEM_MODELS } from '@renderer/config/models'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { isVisionModel, SYSTEM_MODELS } from '@renderer/config/models'
import { getModelLogo } from '@renderer/config/provider'
import { useProvider } from '@renderer/hooks/useProvider'
import { fetchModels } from '@renderer/services/api'
@@ -126,6 +127,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
</Avatar>
<ListItemName>
{model.name}
{isVisionModel(model) && <VisionIcon />}
{isFreeModel(model) && (
<Tag style={{ marginLeft: 10 }} color="green">
Free

View File

@@ -6,6 +6,8 @@ import {
MinusCircleOutlined,
PlusOutlined
} from '@ant-design/icons'
import VisionIcon from '@renderer/components/Icons/VisionIcon'
import { isVisionModel } from '@renderer/config/models'
import { getModelLogo } from '@renderer/config/provider'
import { PROVIDER_CONFIG } from '@renderer/config/provider'
import { useTheme } from '@renderer/context/ThemeProvider'
@@ -148,7 +150,7 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
<Avatar src={getModelLogo(model.id)} size={22} style={{ marginRight: '8px' }}>
{model.name[0].toUpperCase()}
</Avatar>
{model.name}
{model.name} {isVisionModel(model) && <VisionIcon />}
</ModelListHeader>
<RemoveIcon onClick={() => removeModel(model)} />
</ModelListItem>

View File

@@ -22,7 +22,7 @@ export default class AiProvider {
return this.sdk.translate(message, assistant)
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
return this.sdk.summaries(messages, assistant)
}
@@ -30,6 +30,10 @@ export default class AiProvider {
return this.sdk.suggestions(messages, assistant)
}
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
return this.sdk.generateText({ prompt, content })
}
public async check(): Promise<{ valid: boolean; error: Error | null }> {
return this.sdk.check()
}

View File

@@ -18,6 +18,31 @@ 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)
if (!file) {
return message.content
}
if (file.type === 'image') {
const base64Data = await window.api.image.base64(file.path)
return [
{ type: 'text', text: message.content },
{
type: 'image',
source: {
data: base64Data.base64,
media_type: base64Data.mime.replace('jpg', 'jpeg') as any,
type: 'base64'
}
}
]
}
return message.content
}
public async completions(
messages: Message[],
assistant: Assistant,
@@ -27,12 +52,14 @@ export default class AnthropicProvider extends BaseProvider {
const model = assistant.model || defaultModel
const { contextCount, maxTokens } = getAssistantSettings(assistant)
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 2))).map((message) => {
return {
const userMessages: MessageParam[] = []
for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 2)))) {
userMessages.push({
role: message.role,
content: message.content
}
})
content: await this.getMessageContent(message)
})
}
if (first(userMessages)?.role === 'assistant') {
userMessages.shift()
@@ -90,7 +117,7 @@ export default class AnthropicProvider extends BaseProvider {
return response.content[0].type === 'text' ? response.content[0].text : ''
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({
@@ -115,7 +142,26 @@ export default class AnthropicProvider extends BaseProvider {
max_tokens: 4096
})
return message.content[0].type === 'text' ? message.content[0].text : null
return message.content[0].type === 'text' ? message.content[0].text : ''
}
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const message = await this.sdk.messages.create({
messages: [
{
role: 'user',
content
}
],
model: model.id,
system: prompt,
stream: false,
max_tokens: 4096
})
return message.content[0].type === 'text' ? message.content[0].text : ''
}
public async suggestions(): Promise<Suggestion[]> {

View File

@@ -26,8 +26,9 @@ export default abstract class BaseProvider {
onChunk: ({ text, usage }: { text?: string; usage?: OpenAI.Completions.CompletionUsage }) => void
): Promise<void>
abstract translate(message: Message, assistant: Assistant): Promise<string>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string | null>
abstract summaries(messages: Message[], assistant: Assistant): Promise<string>
abstract suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]>
abstract generateText({ prompt, content }: { prompt: string; content: string }): Promise<string>
abstract check(): Promise<{ valid: boolean; error: Error | null }>
abstract models(): Promise<OpenAI.Models.Model[]>
}

View File

@@ -1,10 +1,10 @@
import { GoogleGenerativeAI } from '@google/generative-ai'
import { Content, GoogleGenerativeAI, InlineDataPart, Part } 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 axios from 'axios'
import { isEmpty, takeRight } from 'lodash'
import { first, isEmpty, takeRight } from 'lodash'
import OpenAI from 'openai'
import BaseProvider from './BaseProvider'
@@ -17,6 +17,27 @@ export default class GeminiProvider extends BaseProvider {
this.sdk = new GoogleGenerativeAI(provider.apiKey)
}
private async getMessageParts(message: Message): Promise<Part[]> {
const file = first(message.files)
if (file && file.type === 'image') {
const base64Data = await window.api.image.base64(file.path)
return [
{
text: message.content
},
{
inlineData: {
data: base64Data.base64,
mimeType: base64Data.mime
}
} as InlineDataPart
]
}
return [{ text: message.content }]
}
public async completions(
messages: Message[],
assistant: Assistant,
@@ -29,7 +50,7 @@ export default class GeminiProvider extends BaseProvider {
const userMessages = filterMessages(filterContextMessages(takeRight(messages, contextCount + 1))).map((message) => {
return {
role: message.role,
content: message.content
message
}
})
@@ -44,14 +65,19 @@ export default class GeminiProvider extends BaseProvider {
const userLastMessage = userMessages.pop()
const chat = geminiModel.startChat({
history: userMessages.map((message) => ({
role: message.role === 'user' ? 'user' : 'model',
parts: [{ text: message.content }]
}))
})
const history: Content[] = []
const userMessagesStream = await chat.sendMessageStream(userLastMessage?.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)
for await (const chunk of userMessagesStream.stream) {
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) break
@@ -85,7 +111,7 @@ export default class GeminiProvider extends BaseProvider {
return response.text()
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({
@@ -120,6 +146,18 @@ export default class GeminiProvider extends BaseProvider {
return response.text()
}
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const systemMessage = { role: 'system', content: prompt }
const geminiModel = this.sdk.getGenerativeModel({ model: model.id })
const chat = await geminiModel.startChat({ systemInstruction: systemMessage.content })
const { response } = await chat.sendMessage(content)
return response.text()
}
public async suggestions(): Promise<Suggestion[]> {
return []
}

View File

@@ -3,7 +3,7 @@ import { getAssistantSettings, getDefaultModel, getTopNamingModel } from '@rende
import { EVENT_NAMES } from '@renderer/services/event'
import { filterContextMessages, filterMessages } from '@renderer/services/messages'
import { Assistant, Message, Provider, Suggestion } from '@renderer/types'
import { fileToBase64, removeQuotes } from '@renderer/utils'
import { removeQuotes } from '@renderer/utils'
import { first, takeRight } from 'lodash'
import OpenAI from 'openai'
import {
@@ -33,13 +33,14 @@ export default class OpenAIProvider extends BaseProvider {
return message.content
}
if (file.type.includes('image')) {
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: await fileToBase64(file)
url: base64Data.data
}
}
]
@@ -58,7 +59,6 @@ export default class OpenAIProvider extends BaseProvider {
const { contextCount, maxTokens } = getAssistantSettings(assistant)
const systemMessage = assistant.prompt ? { role: 'system', content: assistant.prompt } : undefined
const userMessages: ChatCompletionMessageParam[] = []
for (const message of filterMessages(filterContextMessages(takeRight(messages, contextCount + 1)))) {
@@ -103,7 +103,7 @@ export default class OpenAIProvider extends BaseProvider {
return response.choices[0].message?.content || ''
}
public async summaries(messages: Message[], assistant: Assistant): Promise<string | null> {
public async summaries(messages: Message[], assistant: Assistant): Promise<string> {
const model = getTopNamingModel() || assistant.model || getDefaultModel()
const userMessages = takeRight(messages, 5).map((message) => ({
@@ -128,6 +128,21 @@ export default class OpenAIProvider extends BaseProvider {
return removeQuotes(response.choices[0].message?.content?.substring(0, 50) || '')
}
public async generateText({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const response = await this.sdk.chat.completions.create({
model: model.id,
stream: false,
messages: [
{ role: 'user', content },
{ role: 'system', content: prompt }
]
})
return response.choices[0].message?.content || ''
}
async suggestions(messages: Message[], assistant: Assistant): Promise<Suggestion[]> {
const model = assistant.model

View File

@@ -129,6 +129,23 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
}
}
export async function fetchGenerate({ prompt, content }: { prompt: string; content: string }): Promise<string> {
const model = getDefaultModel()
const provider = getProviderByModel(model)
if (!hasApiKey(provider)) {
return ''
}
const AI = new AiProvider(provider)
try {
return await AI.generateText({ prompt, content })
} catch (error: any) {
return ''
}
}
export async function fetchSuggestions({
messages,
assistant

View File

@@ -0,0 +1,57 @@
import db from '@renderer/databases'
import { FileType } from '@renderer/types'
class FileManager {
static async selectFiles(options?: Electron.OpenDialogOptions): Promise<FileType[] | null> {
const files = await window.api.file.select(options)
return files
}
static async uploadFile(file: FileType): Promise<FileType> {
const uploadFile = await window.api.file.upload(file)
const fileRecord = await db.files.get(uploadFile.id)
if (fileRecord) {
await db.files.update(fileRecord.id, { ...fileRecord, count: fileRecord.count + 1 })
return fileRecord
}
await db.files.add(uploadFile)
return uploadFile
}
static async uploadFiles(files: FileType[]): Promise<FileType[]> {
return Promise.all(files.map((file) => this.uploadFile(file)))
}
static async getFile(id: string): Promise<FileType | undefined> {
return db.files.get(id)
}
static async deleteFile(id: string): Promise<void> {
const file = await this.getFile(id)
if (!file) {
return
}
if (file.count > 1) {
await db.files.update(id, { ...file, count: file.count - 1 })
return
}
db.files.delete(id)
await window.api.file.delete(id + file.ext)
}
static async deleteFiles(ids: string[]): Promise<void> {
await Promise.all(ids.map((id) => this.deleteFile(id)))
}
static async allFiles(): Promise<FileType[]> {
return db.files.toArray()
}
}
export default FileManager

View File

@@ -4,6 +4,7 @@ import { GPTTokens } from 'gpt-tokens'
import { isEmpty, takeRight } from 'lodash'
import { getAssistantSettings } from './assistant'
import FileManager from './file'
export const filterMessages = (messages: Message[]) => {
return messages
@@ -59,3 +60,7 @@ export function estimateHistoryTokenCount(assistant: Assistant, msgs: Message[])
return all.usedTokens - 7
}
export function deleteMessageFiles(message: Message) {
message.files && FileManager.deleteFiles(message.files.map((f) => f.id))
}

View File

@@ -2,6 +2,8 @@ import { Topic } from '@renderer/types'
import { convertToBase64 } from '@renderer/utils'
import localforage from 'localforage'
import { deleteMessageFiles } from './messages'
const IMAGE_PREFIX = 'image://'
export default class LocalStorage {
@@ -15,12 +17,23 @@ export default class LocalStorage {
}
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)
}

View File

@@ -1,13 +1,8 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { ThemeMode } from '@renderer/types'
export type SendMessageShortcut = 'Enter' | 'Shift+Enter'
export enum ThemeMode {
light = 'light',
dark = 'dark',
auto = 'auto'
}
export interface SettingsState {
showAssistants: boolean
showTopics: boolean

View File

@@ -27,7 +27,7 @@ export type Message = {
createdAt: string
status: 'sending' | 'pending' | 'success' | 'paused' | 'error'
modelId?: string
files?: File[]
files?: FileType[]
images?: string[]
usage?: OpenAI.Completions.CompletionUsage
type?: 'text' | '@' | 'clear'
@@ -86,3 +86,29 @@ export type MinAppType = {
logo: string
url: string
}
export interface FileType {
id: string
name: string
origin_name: string
path: string
size: number
ext: string
type: FileTypes
created_at: Date
count: number
}
export enum FileTypes {
IMAGE = 'image',
VIDEO = 'video',
AUDIO = 'audio',
DOCUMENT = 'document',
OTHER = 'other'
}
export enum ThemeMode {
light = 'light',
dark = 'dark',
auto = 'auto'
}

View File

@@ -1,5 +1,6 @@
import { Model } from '@renderer/types'
import imageCompression from 'browser-image-compression'
// @ts-ignore next-line`
import { v4 as uuidv4 } from 'uuid'
export const runAsyncFunction = async (fn: () => void) => {
@@ -224,17 +225,7 @@ export function getBriefInfo(text: string, maxLength: number = 50): string {
return truncatedText + '...'
}
export async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const result = e.target?.result
resolve(typeof result === 'string' ? result : '')
}
reader.readAsDataURL(file)
} catch (error: any) {
reject(error)
}
})
export function removeTrailingDoubleSpaces(markdown: string): string {
// 使用正则表达式匹配末尾的两个空格,并替换为空字符串
return markdown.replace(/ {2}$/gm, '')
}

View File

@@ -5,11 +5,20 @@
"src/main/**/*",
"src/preload/**/*",
"src/main/env.d.ts",
"src/renderer/src/types/index.ts"
],
"compilerOptions": {
"composite": true,
"types": [
"electron-vite/node"
]
],
"paths": {
"@types": [
"./src/renderer/src/types/index.ts"
],
"@main/*": [
"./src/main/*"
]
}
}
}

2000
yarn.lock

File diff suppressed because it is too large Load Diff