Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a654ccc25e | ||
|
|
71a35ccd44 | ||
|
|
29826ff091 | ||
|
|
8566476d91 | ||
|
|
a173a87f29 | ||
|
|
cb068d71ca | ||
|
|
66210d1d2e | ||
|
|
aa427c9911 | ||
|
|
9ae9fdf392 | ||
|
|
0ddef31ed8 | ||
|
|
617af8b12a | ||
|
|
71876e6a70 | ||
|
|
4f250cdcb1 | ||
|
|
9268ab845e | ||
|
|
0337c6649b | ||
|
|
8781388760 | ||
|
|
2016ba7062 | ||
|
|
a03d619e2f | ||
|
|
76d1f0bb1e | ||
|
|
2bad5a1184 | ||
|
|
94ba3aee05 | ||
|
|
563758f69f | ||
|
|
56af85cc3e | ||
|
|
6a1a861ecc | ||
|
|
ceab574a22 |
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -29,5 +29,6 @@
|
||||
},
|
||||
"[markdown]": {
|
||||
"files.trimTrailingWhitespace": false
|
||||
}
|
||||
},
|
||||
"i18n-ally.localesPaths": ["src/renderer/src/i18n"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
139
src/main/services/File.ts
Normal 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
|
||||
@@ -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
7
src/main/utils/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { app } from 'electron'
|
||||
|
||||
export function getResourcePath() {
|
||||
return path.join(app.getAppPath(), 'resources')
|
||||
}
|
||||
9
src/preload/index.d.ts
vendored
9
src/preload/index.d.ts
vendored
@@ -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 }>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
BIN
src/renderer/src/assets/images/apps/poe.webp
Normal file
BIN
src/renderer/src/assets/images/apps/poe.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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;
|
||||
|
||||
15
src/renderer/src/components/Icons/VisionIcon.tsx
Normal file
15
src/renderer/src/components/Icons/VisionIcon.tsx
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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/'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
13
src/renderer/src/databases/index.ts
Normal file
13
src/renderer/src/databases/index.ts
Normal 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
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '我的智能体',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
82
src/renderer/src/pages/files/FilesPage.tsx
Normal file
82
src/renderer/src/pages/files/FilesPage.tsx
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
36
src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx
Normal file
36
src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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[]>
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
57
src/renderer/src/services/file.ts
Normal file
57
src/renderer/src/services/file.ts
Normal 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
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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, '')
|
||||
}
|
||||
|
||||
@@ -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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user