Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f8eec59e | ||
|
|
f98879a1e5 | ||
|
|
ef40e9db5f | ||
|
|
eb799879ff | ||
|
|
13fddc8e7f | ||
|
|
fa3d7f7f4a | ||
|
|
6845ee1664 | ||
|
|
c8b98681ef | ||
|
|
ae4542ce68 | ||
|
|
0140ff5f6e | ||
|
|
a22a47c16a | ||
|
|
6bb7b2ca5d | ||
|
|
1ec7df9a7e | ||
|
|
83925832be | ||
|
|
4dadf98909 | ||
|
|
375c07e442 |
@@ -42,7 +42,10 @@ dmg:
|
||||
artifactName: ${productName}-${version}-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- target: AppImage
|
||||
arch:
|
||||
- arm64
|
||||
- x64
|
||||
# - snap
|
||||
# - deb
|
||||
maintainer: electronjs.org
|
||||
@@ -60,10 +63,13 @@ afterSign: scripts/notarize.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
本次更新:
|
||||
支持话题导出为图片
|
||||
启动界面增加LOGO显示避免空白
|
||||
修复输入框光标位置粘贴文字问题
|
||||
修复暂停生成导致消息显示错乱问题
|
||||
修复公式渲染异常情况
|
||||
修复 Anthropic API 地址错误问题
|
||||
近期更新:
|
||||
增加了30多种文本文档格式选择
|
||||
支持粘贴图片和文件到聊天输入框
|
||||
支持将对话移动到其他智能体了
|
||||
近期更新:
|
||||
支持 Vision 模型
|
||||
新增文件功能
|
||||
支持从特定消息创建新分支
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "0.7.4",
|
||||
"version": "0.7.6",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
@@ -34,7 +34,8 @@
|
||||
"electron-log": "^5.1.5",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.1.7",
|
||||
"electron-window-state": "^5.0.3"
|
||||
"electron-window-state": "^5.0.3",
|
||||
"html2canvas": "^1.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.24.3",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { BrowserWindow, ipcMain, OpenDialogOptions, session, shell } from 'elect
|
||||
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import FileManager from './services/FileManager'
|
||||
import { openFile, saveFile } from './utils/file'
|
||||
import { compress, decompress } from './utils/zip'
|
||||
import { createMinappWindow } from './window'
|
||||
|
||||
@@ -28,13 +27,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
|
||||
})
|
||||
|
||||
ipcMain.handle('save-file', saveFile)
|
||||
ipcMain.handle('open-file', openFile)
|
||||
ipcMain.handle('reload', () => mainWindow.reload())
|
||||
|
||||
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
|
||||
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
|
||||
|
||||
ipcMain.handle('file:open', fileManager.open)
|
||||
ipcMain.handle('file:save', fileManager.save)
|
||||
ipcMain.handle('file:saveImage', fileManager.saveImage)
|
||||
ipcMain.handle('file:base64Image', async (_, id) => await fileManager.base64Image(id))
|
||||
ipcMain.handle('file:select', async (_, options?: OpenDialogOptions) => await fileManager.selectFile(options))
|
||||
ipcMain.handle('file:upload', async (_, file: FileType) => await fileManager.uploadFile(file))
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { getFileType } from '@main/utils/file'
|
||||
import { FileType } from '@types'
|
||||
import * as crypto from 'crypto'
|
||||
import { app, dialog, OpenDialogOptions } from 'electron'
|
||||
import {
|
||||
app,
|
||||
dialog,
|
||||
OpenDialogOptions,
|
||||
OpenDialogReturnValue,
|
||||
SaveDialogOptions,
|
||||
SaveDialogReturnValue
|
||||
} from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import * as fs from 'fs'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -193,6 +203,69 @@ class FileManager {
|
||||
await fs.promises.rmdir(this.storageDir, { recursive: true })
|
||||
await this.initStorageDir()
|
||||
}
|
||||
|
||||
async open(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options: OpenDialogOptions
|
||||
): Promise<{ fileName: string; content: Buffer } | null> {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '打开文件',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, content }
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async save(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
defaultPath: fileName,
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async saveImage(_: Electron.IpcMainInvokeEvent, name: string, data: string): Promise<void> {
|
||||
try {
|
||||
const filePath = dialog.showSaveDialogSync({
|
||||
defaultPath: `${name}.png`,
|
||||
filters: [{ name: 'PNG Image', extensions: ['png'] }]
|
||||
})
|
||||
|
||||
if (filePath) {
|
||||
const base64Data = data.replace(/^data:image\/png;base64,/, '')
|
||||
fs.writeFileSync(filePath, base64Data, 'base64')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the image:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FileManager
|
||||
|
||||
@@ -1,57 +1,5 @@
|
||||
import { dialog, OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
|
||||
import logger from 'electron-log'
|
||||
import { writeFileSync } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
import { FileTypes } from '../../renderer/src/types'
|
||||
|
||||
export async function saveFile(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
fileName: string,
|
||||
content: string,
|
||||
options?: SaveDialogOptions
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
|
||||
title: '保存文件',
|
||||
defaultPath: fileName,
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFile(
|
||||
_: Electron.IpcMainInvokeEvent,
|
||||
options: OpenDialogOptions
|
||||
): Promise<{ fileName: string; content: Buffer } | null> {
|
||||
try {
|
||||
const result: OpenDialogReturnValue = await dialog.showOpenDialog({
|
||||
title: '打开文件',
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: '所有文件', extensions: ['*'] }],
|
||||
...options
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
const filePath = result.filePaths[0]
|
||||
const fileName = filePath.split('/').pop() || ''
|
||||
const content = await readFile(filePath)
|
||||
return { fileName, content }
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (err) {
|
||||
logger.error('[IPC - Error]', 'An error occurred opening the file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getFileType(ext: string): FileTypes {
|
||||
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
|
||||
const videoExts = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv']
|
||||
|
||||
5
src/preload/index.d.ts
vendored
5
src/preload/index.d.ts
vendored
@@ -14,8 +14,6 @@ declare global {
|
||||
checkForUpdate: () => void
|
||||
openWebsite: (url: string) => void
|
||||
setProxy: (proxy: string | undefined) => void
|
||||
saveFile: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
|
||||
openFile: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null>
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
|
||||
reload: () => void
|
||||
@@ -31,6 +29,9 @@ declare global {
|
||||
get: (filePath: string) => Promise<FileType | null>
|
||||
create: (fileName: string) => Promise<string>
|
||||
write: (filePath: string, data: Uint8Array | string) => Promise<void>
|
||||
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; content: Buffer } | null>
|
||||
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
|
||||
saveImage: (name: string, data: string) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,7 @@ const api = {
|
||||
setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy),
|
||||
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
|
||||
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
|
||||
openFile: (options?: { decompress: boolean }) => ipcRenderer.invoke('open-file', options),
|
||||
reload: () => ipcRenderer.invoke('reload'),
|
||||
saveFile: (path: string, content: string, options?: { compress: boolean }) => {
|
||||
return ipcRenderer.invoke('save-file', path, content, options)
|
||||
},
|
||||
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
|
||||
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
|
||||
file: {
|
||||
@@ -25,7 +21,12 @@ const api = {
|
||||
clear: () => ipcRenderer.invoke('file:clear'),
|
||||
get: (filePath: string) => ipcRenderer.invoke('file:get', filePath),
|
||||
create: (fileName: string) => ipcRenderer.invoke('file:create', fileName),
|
||||
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data)
|
||||
write: (filePath: string, data: Uint8Array | string) => ipcRenderer.invoke('file:write', filePath, data),
|
||||
open: (options?: { decompress: boolean }) => ipcRenderer.invoke('file:open', options),
|
||||
save: (path: string, content: string, options?: { compress: boolean }) => {
|
||||
return ipcRenderer.invoke('file:save', path, content, options)
|
||||
},
|
||||
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,25 @@
|
||||
<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: file: *; frame-src * file:" />
|
||||
<style>
|
||||
#spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
#spinner img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="spinner">
|
||||
<img src="/src/assets/images/logo.png" />
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
--color-background: #181818;
|
||||
--color-background-soft: var(--color-black-soft);
|
||||
--color-background-mute: var(--color-black-mute);
|
||||
--color-background-mute: var(--color-black-soft);
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { TopView } from '@renderer/components/TopView'
|
||||
import systemAgents from '@renderer/config/agents.json'
|
||||
import { useAgents } from '@renderer/hooks/useAgents'
|
||||
@@ -5,11 +6,13 @@ import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant
|
||||
import { covertAgentToAssistant } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { Agent, Assistant } from '@renderer/types'
|
||||
import { Input, Modal, Tag } from 'antd'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Divider, Input, InputRef, Modal, Tag } from 'antd'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HStack } from '../Layout'
|
||||
|
||||
interface Props {
|
||||
resolve: (value: Assistant | undefined) => void
|
||||
}
|
||||
@@ -21,6 +24,7 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { defaultAssistant } = useDefaultAssistant()
|
||||
const { assistants, addAssistant } = useAssistants()
|
||||
const inputRef = useRef<InputRef>(null)
|
||||
|
||||
const defaultAgent: Agent = useMemo(
|
||||
() => ({
|
||||
@@ -65,30 +69,51 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
AddAssistantPopup.hide()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
open && setTimeout(() => inputRef.current?.focus(), 0)
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
centered
|
||||
title={t('chat.add.assistant.title')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down"
|
||||
maskTransitionName="ant-fade"
|
||||
styles={{ content: { borderRadius: 20, padding: 0, overflow: 'hidden', paddingBottom: 20 } }}
|
||||
closeIcon={null}
|
||||
footer={null}>
|
||||
<Input
|
||||
placeholder={t('common.search')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<HStack style={{ padding: '0 12px', marginTop: 5 }}>
|
||||
<Input
|
||||
prefix={
|
||||
<SearchIcon>
|
||||
<SearchOutlined />
|
||||
</SearchIcon>
|
||||
}
|
||||
ref={inputRef}
|
||||
placeholder={t('assistants.search')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{ paddingLeft: 0 }}
|
||||
bordered={false}
|
||||
size="large"
|
||||
/>
|
||||
</HStack>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<Container>
|
||||
{agents.map((agent) => (
|
||||
<AgentItem key={agent.id} onClick={() => onCreateAssistant(agent)}>
|
||||
{agent.emoji} {agent.name}
|
||||
{agent.group === 'system' && <Tag color="orange">{t('agents.tag.system')}</Tag>}
|
||||
{agent.group === 'user' && <Tag color="green">{t('agents.tag.user')}</Tag>}
|
||||
<AgentItem
|
||||
key={agent.id}
|
||||
onClick={() => onCreateAssistant(agent)}
|
||||
className={agent.id === 'default' ? 'default' : ''}>
|
||||
<HStack alignItems="center" gap={5}>
|
||||
{agent.emoji} {agent.name}
|
||||
</HStack>
|
||||
{agent.group === 'system' && <Tag color="green">{t('agents.tag.system')}</Tag>}
|
||||
{agent.group === 'user' && <Tag color="orange">{t('agents.tag.user')}</Tag>}
|
||||
</AgentItem>
|
||||
))}
|
||||
</Container>
|
||||
@@ -97,7 +122,9 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
padding: 0 12px;
|
||||
height: 50vh;
|
||||
margin-top: 10px;
|
||||
overflow-y: auto;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -109,12 +136,14 @@ const AgentItem = styled.div`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
&.default {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
.anticon {
|
||||
font-size: 16px;
|
||||
color: var(--color-icon);
|
||||
@@ -124,6 +153,18 @@ const AgentItem = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
const SearchIcon = styled.div`
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-right: 6px;
|
||||
`
|
||||
|
||||
export default class AddAssistantPopup {
|
||||
static topviewId = 0
|
||||
static hide() {
|
||||
|
||||
@@ -40,7 +40,7 @@ const PromptPopupContainer: React.FC<Props> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={title} open={open} onOk={onOk} onCancel={handleCancel} afterClose={onClose}>
|
||||
<Modal title={title} open={open} onOk={onOk} onCancel={handleCancel} afterClose={onClose} centered>
|
||||
<Box mb={8}>{message}</Box>
|
||||
<Input
|
||||
placeholder={inputPlaceholder}
|
||||
|
||||
@@ -44,7 +44,8 @@ const PopupContainer: React.FC<Props> = ({ resolve }) => {
|
||||
onOk={onOk}
|
||||
onCancel={onCancel}
|
||||
afterClose={onClose}
|
||||
transitionName="ant-move-down">
|
||||
transitionName="ant-move-down"
|
||||
centered>
|
||||
<Center mt="30px">
|
||||
<Upload
|
||||
customRequest={() => {}}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isMac } from '@renderer/config/constant'
|
||||
import { isLocalAi, UserAvatar } from '@renderer/config/env'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useRuntime, useShowAssistants } from '@renderer/hooks/useStore'
|
||||
import { useRuntime } from '@renderer/hooks/useStore'
|
||||
import { Avatar } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -17,7 +17,6 @@ const Sidebar: FC = () => {
|
||||
const { pathname } = useLocation()
|
||||
const avatar = useAvatar()
|
||||
const { minappShow } = useRuntime()
|
||||
const { toggleShowAssistants } = useShowAssistants()
|
||||
const { generating } = useRuntime()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -38,23 +37,23 @@ const Sidebar: FC = () => {
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const onToggleShowAssistants = () => {
|
||||
pathname === '/' ? toggleShowAssistants() : navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={{ backgroundColor: minappShow ? 'var(--navbar-background)' : sidebarBgColor }}>
|
||||
<Container
|
||||
style={{
|
||||
backgroundColor: minappShow ? 'var(--navbar-background)' : sidebarBgColor,
|
||||
zIndex: minappShow ? 10000 : 'initial'
|
||||
}}>
|
||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||
<MainMenus>
|
||||
<Menus onClick={MinApp.onClose}>
|
||||
<StyledLink onClick={onToggleShowAssistants}>
|
||||
<StyledLink onClick={() => to('/')}>
|
||||
<Icon className={isRoute('/')}>
|
||||
<i className="iconfont icon-chat"></i>
|
||||
<i className="iconfont icon-chat" />
|
||||
</Icon>
|
||||
</StyledLink>
|
||||
<StyledLink onClick={() => to('/agents')}>
|
||||
<Icon className={isRoute('/agents')}>
|
||||
<i className="iconfont icon-business-smart-assistant"></i>
|
||||
<i className="iconfont icon-business-smart-assistant" />
|
||||
</Icon>
|
||||
</StyledLink>
|
||||
<StyledLink onClick={() => to('/translate')}>
|
||||
@@ -64,7 +63,7 @@ const Sidebar: FC = () => {
|
||||
</StyledLink>
|
||||
<StyledLink onClick={() => to('/apps')}>
|
||||
<Icon className={isRoute('/apps')}>
|
||||
<i className="iconfont icon-appstore"></i>
|
||||
<i className="iconfont icon-appstore" />
|
||||
</Icon>
|
||||
</StyledLink>
|
||||
<StyledLink onClick={() => to('/files')}>
|
||||
@@ -77,7 +76,7 @@ const Sidebar: FC = () => {
|
||||
<Menus onClick={MinApp.onClose}>
|
||||
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
|
||||
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
|
||||
<i className="iconfont icon-setting"></i>
|
||||
<i className="iconfont icon-setting" />
|
||||
</Icon>
|
||||
</StyledLink>
|
||||
</Menus>
|
||||
@@ -97,12 +96,11 @@ const Container = styled.div`
|
||||
border-right: 0.5px solid var(--color-border);
|
||||
margin-top: ${isMac ? 'var(--navbar-height)' : 0};
|
||||
transition: background-color 0.3s ease;
|
||||
z-index: 10000;
|
||||
`
|
||||
|
||||
const AvatarImg = styled(Avatar)`
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background-color: var(--color-background-soft);
|
||||
margin-bottom: ${isMac ? '12px' : '12px'};
|
||||
margin-top: ${isMac ? '5px' : '2px'};
|
||||
@@ -121,15 +119,16 @@ const Menus = styled.div`
|
||||
`
|
||||
|
||||
const Icon = styled.div`
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 5px;
|
||||
transition: background-color 0.2s ease;
|
||||
-webkit-app-region: none;
|
||||
transition: all 0.2s ease;
|
||||
.iconfont,
|
||||
.anticon {
|
||||
color: var(--color-icon);
|
||||
|
||||
@@ -164,7 +164,7 @@ export const PROVIDER_CONFIG = {
|
||||
},
|
||||
silicon: {
|
||||
api: {
|
||||
url: 'https://cloud.siliconflow.cn'
|
||||
url: 'https://api.siliconflow.cn'
|
||||
},
|
||||
websites: {
|
||||
official: 'https://www.siliconflow.cn/',
|
||||
|
||||
@@ -23,7 +23,8 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
}
|
||||
},
|
||||
token: {
|
||||
colorPrimary: '#00b96b'
|
||||
colorPrimary: '#00b96b',
|
||||
borderRadius: 6
|
||||
}
|
||||
}}>
|
||||
{children}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function useAppInit() {
|
||||
}, [avatar, dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById('spinner')?.remove()
|
||||
runAsyncFunction(async () => {
|
||||
const { isPackaged } = await window.api.getAppInfo()
|
||||
isPackaged && setTimeout(window.api.checkForUpdate, 3000)
|
||||
|
||||
@@ -61,11 +61,12 @@ const resources = {
|
||||
'reset.double.confirm.content': 'All data will be lost, do you want to continue?',
|
||||
'upgrade.success.title': 'Upgrade successfully',
|
||||
'upgrade.success.content': 'Please restart the application to complete the upgrade',
|
||||
'upgrade.success.button': 'Restart'
|
||||
'upgrade.success.button': 'Restart',
|
||||
'topic.added': 'New topic added'
|
||||
},
|
||||
chat: {
|
||||
save: 'Save',
|
||||
'default.name': 'Default Assistant',
|
||||
'default.name': '⭐️ Default Assistant',
|
||||
'default.description': "Hello, I'm Default Assistant. You can start chatting with me right away",
|
||||
'default.topic.name': 'Default Topic',
|
||||
'topics.title': 'Topics',
|
||||
@@ -76,6 +77,8 @@ const resources = {
|
||||
'topics.delete.all.content': 'Are you sure you want to delete all topics?',
|
||||
'topics.move_to': 'Move to',
|
||||
'topics.list': 'Topic List',
|
||||
'topics.export.title': 'Export',
|
||||
'topics.export.image': 'Export as image',
|
||||
'input.new_topic': 'New Topic',
|
||||
'input.topics': ' Topics ',
|
||||
'input.clear': 'Clear',
|
||||
@@ -108,6 +111,11 @@ const resources = {
|
||||
'message.new.branch': 'New Branch',
|
||||
'assistant.search.placeholder': 'Search'
|
||||
},
|
||||
assistants: {
|
||||
title: 'Assistants',
|
||||
abbr: 'Assistant',
|
||||
search: 'Search assistants...'
|
||||
},
|
||||
files: {
|
||||
title: 'Files',
|
||||
file: 'File',
|
||||
@@ -333,11 +341,12 @@ const resources = {
|
||||
'reset.double.confirm.content': '你的全部数据都会丢失,如果没有备份数据,将无法恢复,确定要继续吗?',
|
||||
'upgrade.success.title': '升级成功',
|
||||
'upgrade.success.content': '重启应用以完成升级',
|
||||
'upgrade.success.button': '重启'
|
||||
'upgrade.success.button': '重启',
|
||||
'topic.added': '话题添加成功'
|
||||
},
|
||||
chat: {
|
||||
save: '保存',
|
||||
'default.name': '默认助手',
|
||||
'default.name': '⭐️ 默认助手',
|
||||
'default.description': '你好,我是默认助手。你可以立刻开始跟我聊天。',
|
||||
'default.topic.name': '默认话题',
|
||||
'topics.title': '话题',
|
||||
@@ -348,6 +357,8 @@ const resources = {
|
||||
'topics.delete.all.content': '确定要删除所有话题吗?',
|
||||
'topics.move_to': '移动到',
|
||||
'topics.list': '话题列表',
|
||||
'topics.export.title': '导出',
|
||||
'topics.export.image': '导出为图片',
|
||||
'input.new_topic': '新话题',
|
||||
'input.topics': ' 话题 ',
|
||||
'input.clear': '清除会话消息',
|
||||
@@ -376,11 +387,16 @@ const resources = {
|
||||
'settings.set_as_default': '应用到默认助手',
|
||||
'settings.max': '不限',
|
||||
'suggestions.title': '建议的问题',
|
||||
'add.assistant.title': '添加智能体',
|
||||
'add.assistant.title': '添加助手',
|
||||
'message.new.context': '清除上下文',
|
||||
'message.new.branch': '新分支',
|
||||
'assistant.search.placeholder': '搜索'
|
||||
},
|
||||
assistants: {
|
||||
title: '助手',
|
||||
abbr: '助手',
|
||||
search: '搜索助手'
|
||||
},
|
||||
files: {
|
||||
title: '文件',
|
||||
file: '文件',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DeleteOutlined, EditOutlined, MinusCircleOutlined } from '@ant-design/icons'
|
||||
import { DeleteOutlined, EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import CopyIcon from '@renderer/components/Icons/CopyIcon'
|
||||
import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup'
|
||||
@@ -20,13 +20,20 @@ import styled from 'styled-components'
|
||||
interface Props {
|
||||
activeAssistant: Assistant
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
onCreateDefaultAssistant: () => void
|
||||
onCreateAssistant: () => void
|
||||
}
|
||||
|
||||
const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAssistant }) => {
|
||||
const Assistants: FC<Props> = ({
|
||||
activeAssistant,
|
||||
setActiveAssistant,
|
||||
onCreateAssistant,
|
||||
onCreateDefaultAssistant
|
||||
}) => {
|
||||
const { assistants, removeAssistant, addAssistant, updateAssistants } = useAssistants()
|
||||
const generating = useAppSelector((state) => state.runtime.generating)
|
||||
const [search, setSearch] = useState('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const { updateAssistant, removeAllTopics } = useAssistant(activeAssistant.id)
|
||||
const { clickAssistantToShowTopic, topicPosition } = useSettings()
|
||||
const searchRef = useRef<InputRef>(null)
|
||||
@@ -36,10 +43,10 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
|
||||
const onDelete = useCallback(
|
||||
(assistant: Assistant) => {
|
||||
const _assistant = last(assistants.filter((a) => a.id !== assistant.id))
|
||||
_assistant ? setActiveAssistant(_assistant) : onCreateAssistant()
|
||||
_assistant ? setActiveAssistant(_assistant) : onCreateDefaultAssistant()
|
||||
removeAssistant(assistant.id)
|
||||
},
|
||||
[assistants, onCreateAssistant, removeAssistant, setActiveAssistant]
|
||||
[assistants, onCreateDefaultAssistant, removeAssistant, setActiveAssistant]
|
||||
)
|
||||
|
||||
const onEditAssistant = useCallback(
|
||||
@@ -175,24 +182,38 @@ const Assistants: FC<Props> = ({ activeAssistant, setActiveAssistant, onCreateAs
|
||||
/>
|
||||
</SearchContainer>
|
||||
)}
|
||||
<DragableList list={list} onUpdate={updateAssistants} droppableProps={{ isDropDisabled: !isEmpty(search) }}>
|
||||
<DragableList
|
||||
list={list}
|
||||
onUpdate={updateAssistants}
|
||||
droppableProps={{ isDropDisabled: !isEmpty(search) }}
|
||||
style={{ paddingBottom: dragging ? '34px' : 0 }}
|
||||
onDragStart={() => setDragging(true)}
|
||||
onDragEnd={() => setDragging(false)}>
|
||||
{(assistant) => {
|
||||
const isCurrent = assistant.id === activeAssistant?.id
|
||||
return (
|
||||
<Dropdown key={assistant.id} menu={{ items: getMenuItems(assistant) }} trigger={['contextMenu']}>
|
||||
<AssistantItem onClick={() => onSwitchAssistant(assistant)} className={isCurrent ? 'active' : ''}>
|
||||
<AssistantName className="name">{assistant.name || t('chat.default.name')}</AssistantName>
|
||||
<ArrowRightButton
|
||||
className={`arrow-button ${isCurrent ? 'active' : ''}`}
|
||||
onClick={() => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}>
|
||||
<i className="iconfont icon-gridlines" />
|
||||
</ArrowRightButton>
|
||||
{isCurrent && (
|
||||
<ArrowRightButton onClick={() => EventEmitter.emit(EVENT_NAMES.SWITCH_TOPIC_SIDEBAR)}>
|
||||
<i className="iconfont icon-gridlines" />
|
||||
</ArrowRightButton>
|
||||
)}
|
||||
{false && <TopicCount className="topics-count">{assistant.topics.length}</TopicCount>}
|
||||
</AssistantItem>
|
||||
</Dropdown>
|
||||
)
|
||||
}}
|
||||
</DragableList>
|
||||
{!dragging && (
|
||||
<AssistantItem onClick={onCreateAssistant}>
|
||||
<AssistantName>
|
||||
<PlusOutlined style={{ color: 'var(--color-text-2)', marginRight: 4 }} />
|
||||
{t('chat.add.assistant.title')}
|
||||
</AssistantName>
|
||||
</AssistantItem>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -215,12 +236,15 @@ const AssistantItem = styled.div`
|
||||
border-radius: 4px;
|
||||
margin: 0 10px;
|
||||
padding-right: 35px;
|
||||
cursor: pointer;
|
||||
font-family: Ubuntu;
|
||||
cursor: pointer;
|
||||
.iconfont {
|
||||
opacity: 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-background-mute);
|
||||
.name {
|
||||
|
||||
@@ -21,7 +21,7 @@ const HomePage: FC = () => {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Navbar activeAssistant={activeAssistant} setActiveAssistant={setActiveAssistant} activeTopic={activeTopic} />
|
||||
<Navbar activeAssistant={activeAssistant} activeTopic={activeTopic} setActiveTopic={setActiveTopic} />
|
||||
<ContentContainer>
|
||||
{showAssistants && (
|
||||
<RightSidebar
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
FormOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
HistoryOutlined,
|
||||
PauseCircleOutlined,
|
||||
QuestionCircleOutlined
|
||||
} from '@ant-design/icons'
|
||||
@@ -22,6 +21,7 @@ import store, { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
import { setGenerating, setSearching } from '@renderer/store/runtime'
|
||||
import { Assistant, FileType, Message, Topic } from '@renderer/types'
|
||||
import { delay, getFileExtension, uuid } from '@renderer/utils'
|
||||
import { insertTextAtCursor } from '@renderer/utils/input'
|
||||
import { Button, Popconfirm, Tooltip } from 'antd'
|
||||
import TextArea, { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -41,6 +41,7 @@ interface Props {
|
||||
}
|
||||
|
||||
let _text = ''
|
||||
let _files: FileType[] = []
|
||||
|
||||
const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
const [text, setText] = useState(_text)
|
||||
@@ -52,7 +53,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
const [contextCount, setContextCount] = useState(0)
|
||||
const generating = useAppSelector((state) => state.runtime.generating)
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
const [files, setFiles] = useState<FileType[]>([])
|
||||
const [files, setFiles] = useState<FileType[]>(_files)
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef(null)
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
@@ -61,8 +62,10 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
|
||||
const isVision = useMemo(() => isVisionModel(model), [model])
|
||||
const supportExts = useMemo(() => [...textExts, ...(isVision ? imageExts : [])], [isVision])
|
||||
const inputTokenCount = useMemo(() => estimateTextTokens(text), [text])
|
||||
|
||||
_text = text
|
||||
_files = files
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (generating) {
|
||||
@@ -97,8 +100,6 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
setExpend(false)
|
||||
}, [assistant.id, assistant.topics, generating, files, text])
|
||||
|
||||
const inputTokenCount = useMemo(() => estimateTextTokens(text), [text])
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const isEnterPressed = event.keyCode == 13
|
||||
|
||||
@@ -143,10 +144,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
}
|
||||
|
||||
const onNewContext = () => {
|
||||
if (generating) {
|
||||
onPause()
|
||||
return
|
||||
}
|
||||
if (generating) return onPause()
|
||||
EventEmitter.emit(EVENT_NAMES.NEW_CONTEXT)
|
||||
}
|
||||
|
||||
@@ -205,22 +203,21 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
const item = event.clipboardData?.items[0]
|
||||
if (item && item.kind === 'string' && item.type === 'text/plain') {
|
||||
event.preventDefault()
|
||||
item.getAsString(async (text) => {
|
||||
if (text.length > 1500) {
|
||||
console.debug(item.getAsFile())
|
||||
item.getAsString(async (pasteText) => {
|
||||
if (pasteText.length > 1500) {
|
||||
const tempFilePath = await window.api.file.create('pasted_text.txt')
|
||||
await window.api.file.write(tempFilePath, text)
|
||||
await window.api.file.write(tempFilePath, pasteText)
|
||||
const selectedFile = await window.api.file.get(tempFilePath)
|
||||
selectedFile && setFiles((prevFiles) => [...prevFiles, selectedFile])
|
||||
} else {
|
||||
setText((prevText) => prevText + text)
|
||||
insertTextAtCursor({ text, pasteText, textareaRef, setText })
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[supportExts, pasteLongTextAsFile]
|
||||
[pasteLongTextAsFile, supportExts, text]
|
||||
)
|
||||
|
||||
// Command or Ctrl + N create new topic
|
||||
@@ -258,11 +255,6 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
textareaRef.current?.focus()
|
||||
}, [assistant])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('paste', onPaste)
|
||||
return () => document.removeEventListener('paste', onPaste)
|
||||
}, [onPaste])
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<AttachmentPreview files={files} setFiles={setFiles} />
|
||||
@@ -283,6 +275,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
onBlur={() => setInputFocus(false)}
|
||||
onInput={onInput}
|
||||
disabled={searching}
|
||||
onPaste={(e) => onPaste(e.nativeEvent)}
|
||||
onClick={() => searching && dispatch(setSearching(false))}
|
||||
/>
|
||||
<Toolbar>
|
||||
@@ -305,16 +298,6 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
||||
</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"
|
||||
@@ -421,6 +404,10 @@ const ToolbarButton = styled(Button)`
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-icon);
|
||||
}
|
||||
.icon-a-addchat {
|
||||
font-size: 19px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.anticon,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
import { Message } from '@renderer/types'
|
||||
import { escapeBrackets } from '@renderer/utils/formula'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -31,7 +32,7 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
const empty = isEmpty(message.content)
|
||||
const paused = message.status === 'paused'
|
||||
const content = empty && paused ? t('message.chat.completion.paused') : message.content
|
||||
return escapeBrackets(escapeDollarNumber(content))
|
||||
return escapeBrackets(content)
|
||||
}, [message.content, message.status, t])
|
||||
|
||||
return (
|
||||
@@ -50,35 +51,4 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
)
|
||||
}
|
||||
|
||||
function escapeDollarNumber(text: string) {
|
||||
let escapedText = ''
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
let char = text[i]
|
||||
const nextChar = text[i + 1] || ' '
|
||||
|
||||
if (char === '$' && nextChar >= '0' && nextChar <= '9') {
|
||||
char = '\\$'
|
||||
}
|
||||
|
||||
escapedText += char
|
||||
}
|
||||
|
||||
return escapedText
|
||||
}
|
||||
|
||||
function escapeBrackets(text: string) {
|
||||
const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g
|
||||
return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
|
||||
if (codeBlock) {
|
||||
return codeBlock
|
||||
} else if (squareBracket) {
|
||||
return `$$${squareBracket}$$`
|
||||
} else if (roundBracket) {
|
||||
return `$${roundBracket}$`
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
|
||||
export default Markdown
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ForkOutlined,
|
||||
MenuOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SaveOutlined,
|
||||
SyncOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { SyncOutlined } from '@ant-design/icons'
|
||||
import UserPopup from '@renderer/components/Popups/UserPopup'
|
||||
import { FONT_FAMILY } from '@renderer/config/constant'
|
||||
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
|
||||
@@ -17,62 +8,36 @@ import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useModel } from '@renderer/hooks/useModel'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
import { firstLetter, removeLeadingEmoji, removeTrailingDoubleSpaces } from '@renderer/utils'
|
||||
import { Alert, Avatar, Divider, Dropdown, Popconfirm, Tooltip } from 'antd'
|
||||
import { Message } from '@renderer/types'
|
||||
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { Alert, Avatar, Divider } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import { upperFirst } from 'lodash'
|
||||
import { FC, memo, useCallback, useMemo, useState } from 'react'
|
||||
import { FC, memo, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelDropdown from '../components/SelectModelDropdown'
|
||||
import Markdown from '../Markdown/Markdown'
|
||||
import MessageAttachments from './MessageAttachments'
|
||||
import MessageMenubar from './MessageMenubar'
|
||||
import MessgeTokens from './MessageTokens'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
index?: number
|
||||
total?: number
|
||||
showMenu?: boolean
|
||||
onDeleteMessage?: (message: Message) => void
|
||||
}
|
||||
|
||||
const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) => {
|
||||
const MessageItem: FC<Props> = ({ message, index, onDeleteMessage }) => {
|
||||
const avatar = useAvatar()
|
||||
const { t } = useTranslation()
|
||||
const { assistant, setModel } = useAssistant(message.assistantId)
|
||||
const model = useModel(message.modelId)
|
||||
const { userName, showMessageDivider, messageFont, fontSize } = useSettings()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const isLastMessage = index === 0
|
||||
const isUserMessage = message.role === 'user'
|
||||
const isAssistantMessage = message.role === 'assistant'
|
||||
const canRegenerate = isLastMessage && isAssistantMessage
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [message.content, t])
|
||||
|
||||
const onEdit = useCallback(() => EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, message), [message])
|
||||
|
||||
const onRegenerate = useCallback(
|
||||
(model: Model) => {
|
||||
setModel(model)
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.REGENERATE_MESSAGE, model), 100)
|
||||
},
|
||||
[setModel]
|
||||
)
|
||||
|
||||
const onNewBranch = useCallback(() => {
|
||||
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
|
||||
}, [index])
|
||||
|
||||
const getUserName = useCallback(() => {
|
||||
if (isLocalAi && message.role !== 'user') return APP_NAME
|
||||
@@ -95,21 +60,6 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
|
||||
|
||||
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('chat.save'),
|
||||
key: 'save',
|
||||
icon: <SaveOutlined />,
|
||||
onClick: () => {
|
||||
const fileName = message.createdAt + '.md'
|
||||
window.api.saveFile(fileName, message.content)
|
||||
}
|
||||
}
|
||||
],
|
||||
[t, message]
|
||||
)
|
||||
|
||||
const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
|
||||
|
||||
if (message.type === 'clear') {
|
||||
@@ -154,57 +104,15 @@ const MessageItem: FC<Props> = ({ message, index, showMenu, onDeleteMessage }) =
|
||||
<MessageContent message={message} />
|
||||
<MessageFooter style={{ border: messageBorder, flexDirection: isLastMessage ? 'row-reverse' : undefined }}>
|
||||
<MessgeTokens message={message} />
|
||||
{showMenu && (
|
||||
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
||||
{message.role === 'user' && (
|
||||
<Tooltip title="Edit" mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onEdit}>
|
||||
<EditOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onCopy}>
|
||||
{!copied && <i className="iconfont icon-copy"></i>}
|
||||
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
{canRegenerate && (
|
||||
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topLeft">
|
||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||
<ActionButton>
|
||||
<SyncOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</SelectModelDropdown>
|
||||
)}
|
||||
{isAssistantMessage && (
|
||||
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onNewBranch}>
|
||||
<ForkOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('message.message.delete.content')}
|
||||
okButtonProps={{ danger: true }}
|
||||
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
|
||||
onConfirm={() => onDeleteMessage?.(message)}>
|
||||
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
|
||||
<ActionButton>
|
||||
<DeleteOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
{!isUserMessage && (
|
||||
<Dropdown menu={{ items: dropdownItems }} trigger={['click']} placement="topRight" arrow>
|
||||
<ActionButton>
|
||||
<MenuOutlined />
|
||||
</ActionButton>
|
||||
</Dropdown>
|
||||
)}
|
||||
</MenusBar>
|
||||
)}
|
||||
<MessageMenubar
|
||||
message={message}
|
||||
model={model}
|
||||
index={index}
|
||||
isLastMessage={isLastMessage}
|
||||
isAssistantMessage={isAssistantMessage}
|
||||
setModel={setModel}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
/>
|
||||
</MessageFooter>
|
||||
</MessageContentContainer>
|
||||
</MessageContainer>
|
||||
@@ -252,11 +160,6 @@ const MessageContainer = styled.div`
|
||||
&.show {
|
||||
opacity: 1;
|
||||
}
|
||||
&.user {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 15px;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.menubar {
|
||||
@@ -323,40 +226,4 @@ const MessageContentLoading = styled.div`
|
||||
height: 32px;
|
||||
`
|
||||
|
||||
const MenusBar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: -5px;
|
||||
`
|
||||
|
||||
const ActionButton = styled.div`
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transition: all 0.3s ease;
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
.anticon {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
.anticon,
|
||||
.iconfont {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-icon);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
|
||||
export default memo(MessageItem)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import FileManager from '@renderer/services/file'
|
||||
import { FileTypes, Message } from '@renderer/types'
|
||||
import { getFileDirectory } from '@renderer/utils'
|
||||
import { Image as AntdImage, Upload } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@@ -9,6 +9,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const MessageAttachments: FC<Props> = ({ message }) => {
|
||||
if (!message.files) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (message?.files && message.files[0]?.type === FileTypes.IMAGE) {
|
||||
return (
|
||||
<Container>
|
||||
@@ -22,10 +26,9 @@ const MessageAttachments: FC<Props> = ({ message }) => {
|
||||
<Upload
|
||||
listType="picture"
|
||||
disabled
|
||||
onPreview={(item) => item.url && window.open(getFileDirectory(item.url))}
|
||||
fileList={message.files?.map((file) => ({
|
||||
uid: file.id,
|
||||
url: 'file://' + file.path,
|
||||
url: 'file://' + FileManager.getSafePath(file),
|
||||
status: 'done',
|
||||
name: file.origin_name
|
||||
}))}
|
||||
|
||||
164
src/renderer/src/pages/home/Messages/MessageMenubar.tsx
Normal file
164
src/renderer/src/pages/home/Messages/MessageMenubar.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ForkOutlined,
|
||||
MenuOutlined,
|
||||
QuestionCircleOutlined,
|
||||
SaveOutlined,
|
||||
SyncOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
import { removeTrailingDoubleSpaces } from '@renderer/utils'
|
||||
import { Dropdown, Popconfirm, Tooltip } from 'antd'
|
||||
import { FC, useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelDropdown from '../components/SelectModelDropdown'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
model?: Model
|
||||
index?: number
|
||||
isLastMessage: boolean
|
||||
isAssistantMessage: boolean
|
||||
setModel: (model: Model) => void
|
||||
onDeleteMessage?: (message: Message) => void
|
||||
}
|
||||
|
||||
const MessageMenubar: FC<Props> = (props) => {
|
||||
const { message, index, model, isLastMessage, isAssistantMessage, setModel, onDeleteMessage } = props
|
||||
const { t } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const isUserMessage = message.role === 'user'
|
||||
const canRegenerate = isLastMessage && isAssistantMessage
|
||||
|
||||
const onEdit = useCallback(() => EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, message), [message])
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
|
||||
window.message.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [message.content, t])
|
||||
|
||||
const onRegenerate = useCallback(
|
||||
(model: Model) => {
|
||||
setModel(model)
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.REGENERATE_MESSAGE, model), 100)
|
||||
},
|
||||
[setModel]
|
||||
)
|
||||
|
||||
const onNewBranch = useCallback(() => {
|
||||
EventEmitter.emit(EVENT_NAMES.NEW_BRANCH, index)
|
||||
}, [index])
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t('chat.save'),
|
||||
key: 'save',
|
||||
icon: <SaveOutlined />,
|
||||
onClick: () => {
|
||||
const fileName = message.createdAt + '.md'
|
||||
window.api.file.save(fileName, message.content)
|
||||
}
|
||||
}
|
||||
],
|
||||
[t, message]
|
||||
)
|
||||
|
||||
return (
|
||||
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
||||
{message.role === 'user' && (
|
||||
<Tooltip title="Edit" mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onEdit}>
|
||||
<EditOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onCopy}>
|
||||
{!copied && <i className="iconfont icon-copy"></i>}
|
||||
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
{canRegenerate && (
|
||||
<SelectModelDropdown model={model} onSelect={onRegenerate} placement="topLeft">
|
||||
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
|
||||
<ActionButton>
|
||||
<SyncOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</SelectModelDropdown>
|
||||
)}
|
||||
{isAssistantMessage && (
|
||||
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
|
||||
<ActionButton onClick={onNewBranch}>
|
||||
<ForkOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Popconfirm
|
||||
title={t('message.message.delete.content')}
|
||||
okButtonProps={{ danger: true }}
|
||||
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
|
||||
onConfirm={() => onDeleteMessage?.(message)}>
|
||||
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
|
||||
<ActionButton>
|
||||
<DeleteOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
{!isUserMessage && (
|
||||
<Dropdown menu={{ items: dropdownItems }} trigger={['click']} placement="topRight" arrow>
|
||||
<ActionButton>
|
||||
<MenuOutlined />
|
||||
</ActionButton>
|
||||
</Dropdown>
|
||||
)}
|
||||
</MenusBar>
|
||||
)
|
||||
}
|
||||
|
||||
const MenusBar = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: -5px;
|
||||
`
|
||||
|
||||
const ActionButton = styled.div`
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transition: all 0.2s ease;
|
||||
&:hover {
|
||||
background-color: var(--color-background-mute);
|
||||
.anticon {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
}
|
||||
.anticon,
|
||||
.iconfont {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--color-icon);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
`
|
||||
|
||||
export default MessageMenubar
|
||||
@@ -7,7 +7,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { deleteMessageFiles, filterMessages, getContextCount } from '@renderer/services/messages'
|
||||
import { estimateHistoryTokens, estimateMessageUsage } from '@renderer/services/tokens'
|
||||
import { Assistant, Message, Model, Topic } from '@renderer/types'
|
||||
import { getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { captureScrollableDiv, getBriefInfo, runAsyncFunction, uuid } from '@renderer/utils'
|
||||
import { t } from 'i18next'
|
||||
import { flatten, last, reverse, take } from 'lodash'
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -104,6 +104,12 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
|
||||
updateTopic({ ...topic, messages: [] })
|
||||
TopicManager.clearTopicMessages(topic.id)
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.EXPORT_TOPIC_IMAGE, async () => {
|
||||
const imageData = await captureScrollableDiv(containerRef)
|
||||
if (imageData) {
|
||||
window.api.file.saveImage(topic.name, imageData)
|
||||
}
|
||||
}),
|
||||
EventEmitter.on(EVENT_NAMES.NEW_CONTEXT, () => {
|
||||
const lastMessage = last(messages)
|
||||
|
||||
@@ -185,7 +191,7 @@ const Messages: FC<Props> = ({ assistant, topic, setActiveTopic }) => {
|
||||
<Suggestions assistant={assistant} messages={messages} lastMessage={lastMessage} />
|
||||
{lastMessage && <MessageItem key={lastMessage.id} message={lastMessage} />}
|
||||
{reverse([...messages]).map((message, index) => (
|
||||
<MessageItem key={message.id} message={message} showMenu index={index} onDeleteMessage={onDeleteMessage} />
|
||||
<MessageItem key={message.id} message={message} index={index} onDeleteMessage={onDeleteMessage} />
|
||||
))}
|
||||
<Prompt assistant={assistant} key={assistant.prompt} />
|
||||
</Container>
|
||||
@@ -200,6 +206,7 @@ const Container = styled.div`
|
||||
flex-direction: column-reverse;
|
||||
max-height: calc(100vh - var(--input-bar-height) - var(--navbar-height));
|
||||
padding: 10px 0;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
export default Messages
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { FormOutlined } from '@ant-design/icons'
|
||||
import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
|
||||
import AssistantSettingPopup from '@renderer/components/Popups/AssistantSettingPopup'
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import db from '@renderer/databases'
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { syncAsistantToAgent } from '@renderer/services/assistant'
|
||||
import { getDefaultTopic, syncAsistantToAgent } from '@renderer/services/assistant'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Switch } from 'antd'
|
||||
import { FC, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SelectModelButton from './components/SelectModelButton'
|
||||
@@ -18,20 +21,16 @@ import SelectModelButton from './components/SelectModelButton'
|
||||
interface Props {
|
||||
activeAssistant: Assistant
|
||||
activeTopic: Topic
|
||||
setActiveAssistant: (assistant: Assistant) => void
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
}
|
||||
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant }) => {
|
||||
const { assistant, updateAssistant } = useAssistant(activeAssistant.id)
|
||||
const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveTopic }) => {
|
||||
const { assistant, updateAssistant, addTopic } = useAssistant(activeAssistant.id)
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const { topicPosition } = useSettings()
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
|
||||
const onCreateAssistant = async () => {
|
||||
const assistant = await AddAssistantPopup.show()
|
||||
assistant && setActiveAssistant(assistant)
|
||||
}
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onEditAssistant = useCallback(async () => {
|
||||
const _assistant = await AssistantSettingPopup.show({ assistant })
|
||||
@@ -39,6 +38,15 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant }) => {
|
||||
syncAsistantToAgent(_assistant)
|
||||
}, [assistant, updateAssistant])
|
||||
|
||||
const addNewTopic = useCallback(() => {
|
||||
const topic = getDefaultTopic()
|
||||
addTopic(topic)
|
||||
setActiveTopic(topic)
|
||||
db.topics.add({ id: topic.id, messages: [] })
|
||||
window.message.success({ content: t('message.topic.added'), key: 'topic-added' })
|
||||
setTimeout(() => EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR), 0)
|
||||
}, [addTopic, setActiveTopic, t])
|
||||
|
||||
return (
|
||||
<Navbar>
|
||||
{showAssistants && (
|
||||
@@ -46,8 +54,8 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant }) => {
|
||||
<NewButton onClick={toggleShowAssistants} style={{ marginLeft: isMac ? 8 : 0 }}>
|
||||
<i className="iconfont icon-hide-sidebar" />
|
||||
</NewButton>
|
||||
<NewButton onClick={onCreateAssistant}>
|
||||
<i className="iconfont icon-a-addchat" />
|
||||
<NewButton onClick={addNewTopic}>
|
||||
<FormOutlined />
|
||||
</NewButton>
|
||||
</NavbarLeft>
|
||||
)}
|
||||
@@ -85,7 +93,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant }) => {
|
||||
|
||||
export const NewButton = styled.div`
|
||||
-webkit-app-region: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
height: 30px;
|
||||
padding: 0 7px;
|
||||
display: flex;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BarsOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import AddAssistantPopup from '@renderer/components/Popups/AddAssistantPopup'
|
||||
import { useAssistants, useDefaultAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||
@@ -43,12 +44,18 @@ const RightSidebar: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssist
|
||||
}
|
||||
|
||||
const showTab = !(position === 'left' && topicPosition === 'right')
|
||||
|
||||
const assistantTab = {
|
||||
label: t('common.assistant'),
|
||||
label: t('assistants.abbr'),
|
||||
value: 'assistants',
|
||||
icon: <i className="iconfont icon-business-smart-assistant" />
|
||||
}
|
||||
|
||||
const onCreateAssistant = async () => {
|
||||
const assistant = await AddAssistantPopup.show()
|
||||
assistant && setActiveAssistant(assistant)
|
||||
}
|
||||
|
||||
const onCreateDefaultAssistant = () => {
|
||||
const assistant = { ...defaultAssistant, id: uuid() }
|
||||
addAssistant(assistant)
|
||||
@@ -95,8 +102,16 @@ const RightSidebar: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssist
|
||||
options={
|
||||
[
|
||||
position === 'left' && topicPosition === 'left' ? assistantTab : undefined,
|
||||
{ label: t('common.topics'), value: 'topic', icon: <BarsOutlined /> },
|
||||
{ label: t('settings.title'), value: 'settings', icon: <SettingOutlined /> }
|
||||
{
|
||||
label: t('common.topics'),
|
||||
value: 'topic',
|
||||
icon: <BarsOutlined />
|
||||
},
|
||||
{
|
||||
label: t('settings.title'),
|
||||
value: 'settings',
|
||||
icon: <SettingOutlined />
|
||||
}
|
||||
].filter(Boolean) as SegmentedProps['options']
|
||||
}
|
||||
onChange={(value) => setTab(value as 'topic' | 'settings')}
|
||||
@@ -108,7 +123,8 @@ const RightSidebar: FC<Props> = ({ activeAssistant, activeTopic, setActiveAssist
|
||||
<Assistants
|
||||
activeAssistant={activeAssistant}
|
||||
setActiveAssistant={setActiveAssistant}
|
||||
onCreateAssistant={onCreateDefaultAssistant}
|
||||
onCreateAssistant={onCreateAssistant}
|
||||
onCreateDefaultAssistant={onCreateDefaultAssistant}
|
||||
/>
|
||||
)}
|
||||
{tab === 'topic' && (
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined } from '@ant-design/icons'
|
||||
import { CloseOutlined, DeleteOutlined, EditOutlined, FolderOutlined, UploadOutlined } from '@ant-design/icons'
|
||||
import DragableList from '@renderer/components/DragableList'
|
||||
import PromptPopup from '@renderer/components/Popups/PromptPopup'
|
||||
import { useAssistant, useAssistants } from '@renderer/hooks/useAssistant'
|
||||
import { TopicManager } from '@renderer/hooks/useTopic'
|
||||
import { fetchMessagesSummary } from '@renderer/services/api'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/event'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Dropdown, MenuProps } from 'antd'
|
||||
@@ -94,6 +95,18 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
updateTopic({ ...topic, name })
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('chat.topics.export.title'),
|
||||
key: 'export',
|
||||
icon: <UploadOutlined />,
|
||||
children: [
|
||||
{
|
||||
label: t('chat.topics.export.image'),
|
||||
key: 'image',
|
||||
onClick: () => EventEmitter.emit(EVENT_NAMES.EXPORT_TOPIC_IMAGE, topic)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -136,8 +149,11 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
|
||||
return (
|
||||
<Dropdown menu={{ items: getTopicMenuItems(topic) }} trigger={['contextMenu']} key={topic.id}>
|
||||
<TopicListItem className={isActive ? 'active' : ''} onClick={() => onSwitchTopic(topic)}>
|
||||
<TopicName>{topic.name}</TopicName>
|
||||
{assistant.topics.length > 1 && (
|
||||
<TopicName className="name">
|
||||
<TopicHash>#</TopicHash>
|
||||
{topic.name.replace('`', '')}
|
||||
</TopicName>
|
||||
{assistant.topics.length > 1 && isActive && (
|
||||
<MenuButton
|
||||
className="menu"
|
||||
onClick={(e) => {
|
||||
@@ -162,13 +178,15 @@ const Container = styled.div`
|
||||
flex-direction: column;
|
||||
padding-top: 10px;
|
||||
overflow-y: scroll;
|
||||
max-height: calc(100vh - var(--navbar-height) - 140px);
|
||||
max-height: calc(100vh - var(--navbar-height) - 70px);
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const TopicListItem = styled.div`
|
||||
padding: 7px 10px;
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-family: Ubuntu;
|
||||
font-size: 13px;
|
||||
@@ -177,13 +195,24 @@ const TopicListItem = styled.div`
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
font-family: Ubuntu;
|
||||
cursor: pointer;
|
||||
.menu {
|
||||
opacity: 0;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--color-background-soft);
|
||||
.name {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background-color: var(--color-background-mute);
|
||||
font-weight: 500;
|
||||
.name {
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.menu {
|
||||
opacity: 1;
|
||||
background-color: var(--color-background-mute);
|
||||
@@ -195,12 +224,12 @@ const TopicListItem = styled.div`
|
||||
`
|
||||
|
||||
const TopicName = styled.div`
|
||||
color: var(--color-text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
opacity: 0.6;
|
||||
`
|
||||
|
||||
const MenuButton = styled.div`
|
||||
@@ -208,17 +237,20 @@ const MenuButton = styled.div`
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
border-radius: 4px;
|
||||
min-width: 22px;
|
||||
min-height: 22px;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 5px;
|
||||
right: 8px;
|
||||
top: 6px;
|
||||
.anticon {
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
const TopicHash = styled.span`
|
||||
font-size: 13px;
|
||||
color: var(--color-text-3);
|
||||
margin-right: 2px;
|
||||
`
|
||||
|
||||
export default Topics
|
||||
|
||||
@@ -118,6 +118,7 @@ const MenuItem = styled.li`
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
opacity: 0.7;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
this.sdk = new Anthropic({ apiKey: provider.apiKey, baseURL: this.getBaseURL() })
|
||||
}
|
||||
|
||||
public getBaseURL(): string {
|
||||
return this.provider.apiHost
|
||||
}
|
||||
|
||||
private async getMessageParam(message: Message): Promise<MessageParam> {
|
||||
const parts: MessageParam['content'] = [{ type: 'text', text: message.content }]
|
||||
|
||||
@@ -80,8 +84,8 @@ export default class AnthropicProvider extends BaseProvider {
|
||||
})
|
||||
.on('text', (text) => {
|
||||
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
|
||||
resolve()
|
||||
return stream.controller.abort()
|
||||
stream.controller.abort()
|
||||
return resolve()
|
||||
}
|
||||
onChunk({ text })
|
||||
})
|
||||
|
||||
@@ -124,16 +124,25 @@ export default class OpenAIProvider extends BaseProvider {
|
||||
userMessages.push(await this.getMessageParam(message, model))
|
||||
}
|
||||
|
||||
const isSupportStreamOutput = this.isSupportStreamOutput(model.id)
|
||||
|
||||
// @ts-ignore key is not typed
|
||||
const stream = await this.sdk.chat.completions.create({
|
||||
model: model.id,
|
||||
messages: [systemMessage, ...userMessages].filter(Boolean) as ChatCompletionMessageParam[],
|
||||
stream: this.isSupportStreamOutput(model.id),
|
||||
stream: isSupportStreamOutput,
|
||||
temperature: assistant?.settings?.temperature,
|
||||
max_tokens: maxTokens,
|
||||
keep_alive: this.keepAliveTime
|
||||
})
|
||||
|
||||
if (!isSupportStreamOutput) {
|
||||
return onChunk({
|
||||
text: stream.choices[0].message?.content || '',
|
||||
usage: stream.usage
|
||||
})
|
||||
}
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
|
||||
break
|
||||
|
||||
@@ -56,6 +56,9 @@ export async function fetchChatCompletion({
|
||||
const timer = setInterval(() => {
|
||||
if (window.keyv.get(EVENT_NAMES.CHAT_COMPLETION_PAUSED)) {
|
||||
paused = true
|
||||
message.status = 'paused'
|
||||
EventEmitter.emit(EVENT_NAMES.RECEIVE_MESSAGE, message)
|
||||
store.dispatch(setGenerating(false))
|
||||
onResponse({ ...message, status: 'paused' })
|
||||
clearInterval(timer)
|
||||
}
|
||||
@@ -77,7 +80,7 @@ export async function fetchChatCompletion({
|
||||
|
||||
message.status = 'success'
|
||||
|
||||
if (!message.usage) {
|
||||
if (!message.usage || !message?.usage?.completion_tokens) {
|
||||
message.usage = await estimateMessagesUsage({
|
||||
assistant,
|
||||
messages: [..._messages, message]
|
||||
|
||||
@@ -18,13 +18,13 @@ export async function backup() {
|
||||
const fileContnet = JSON.stringify(data)
|
||||
const file = await window.api.compress(fileContnet)
|
||||
|
||||
await window.api.saveFile(filename, file)
|
||||
await window.api.file.save(filename, file)
|
||||
|
||||
window.message.success({ content: i18n.t('message.backup.success'), key: 'backup' })
|
||||
}
|
||||
|
||||
export async function restore() {
|
||||
const file = await window.api.openFile()
|
||||
const file = await window.api.file.open()
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
|
||||
@@ -17,5 +17,6 @@ export const EVENT_NAMES = {
|
||||
SHOW_TOPIC_SIDEBAR: 'SHOW_TOPIC_SIDEBAR',
|
||||
SWITCH_TOPIC_SIDEBAR: 'SWITCH_TOPIC_SIDEBAR',
|
||||
NEW_CONTEXT: 'NEW_CONTEXT',
|
||||
NEW_BRANCH: 'NEW_BRANCH'
|
||||
NEW_BRANCH: 'NEW_BRANCH',
|
||||
EXPORT_TOPIC_IMAGE: 'EXPORT_TOPIC_IMAGE'
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class FileManager {
|
||||
}
|
||||
|
||||
static isDangerFile(file: FileType) {
|
||||
return ['.sh', '.bat', '.cmd', '.ps1'].includes(file.ext)
|
||||
return ['.sh', '.bat', '.cmd', '.ps1', '.vbs', 'reg'].includes(file.ext)
|
||||
}
|
||||
|
||||
static getSafePath(file: FileType) {
|
||||
|
||||
30
src/renderer/src/utils/formula.ts
Normal file
30
src/renderer/src/utils/formula.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export function escapeDollarNumber(text: string) {
|
||||
let escapedText = ''
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
let char = text[i]
|
||||
const nextChar = text[i + 1] || ' '
|
||||
|
||||
if (char === '$' && nextChar >= '0' && nextChar <= '9') {
|
||||
char = '\\$'
|
||||
}
|
||||
|
||||
escapedText += char
|
||||
}
|
||||
|
||||
return escapedText
|
||||
}
|
||||
|
||||
export function escapeBrackets(text: string) {
|
||||
const pattern = /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g
|
||||
return text.replace(pattern, (match, codeBlock, squareBracket, roundBracket) => {
|
||||
if (codeBlock) {
|
||||
return codeBlock
|
||||
} else if (squareBracket) {
|
||||
return `$$${squareBracket}$$`
|
||||
} else if (roundBracket) {
|
||||
return `$${roundBracket}$`
|
||||
}
|
||||
return match
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Model } from '@renderer/types'
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import html2canvas from 'html2canvas'
|
||||
// @ts-ignore next-line`
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
@@ -241,3 +242,66 @@ export function getFileExtension(filePath: string) {
|
||||
const extension = parts.slice(-1)[0]
|
||||
return '.' + extension
|
||||
}
|
||||
|
||||
export async function captureDiv(divRef: React.RefObject<HTMLDivElement>) {
|
||||
if (divRef.current) {
|
||||
try {
|
||||
const canvas = await html2canvas(divRef.current)
|
||||
const imageData = canvas.toDataURL('image/png')
|
||||
return imageData
|
||||
} catch (error) {
|
||||
console.error('Error capturing div:', error)
|
||||
return Promise.reject()
|
||||
}
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElement>) => {
|
||||
if (divRef.current) {
|
||||
try {
|
||||
const div = divRef.current
|
||||
|
||||
// 保存原始样式
|
||||
const originalStyle = {
|
||||
height: div.style.height,
|
||||
maxHeight: div.style.maxHeight,
|
||||
overflow: div.style.overflow,
|
||||
position: div.style.position
|
||||
}
|
||||
|
||||
const originalScrollTop = div.scrollTop
|
||||
|
||||
// 修改样式以显示全部内容
|
||||
div.style.height = 'auto'
|
||||
div.style.maxHeight = 'none'
|
||||
div.style.overflow = 'visible'
|
||||
div.style.position = 'static'
|
||||
|
||||
// 捕获整个内容
|
||||
const canvas = await html2canvas(div, {
|
||||
scrollY: -window.scrollY,
|
||||
windowHeight: document.documentElement.scrollHeight
|
||||
})
|
||||
|
||||
// 恢复原始样式
|
||||
div.style.height = originalStyle.height
|
||||
div.style.maxHeight = originalStyle.maxHeight
|
||||
div.style.overflow = originalStyle.overflow
|
||||
div.style.position = originalStyle.position
|
||||
|
||||
const imageData = canvas.toDataURL('image/png')
|
||||
|
||||
// 恢复原始滚动位置
|
||||
setTimeout(() => {
|
||||
div.scrollTop = originalScrollTop
|
||||
}, 0)
|
||||
|
||||
return imageData
|
||||
} catch (error) {
|
||||
console.error('Error capturing scrollable div:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
31
src/renderer/src/utils/input.ts
Normal file
31
src/renderer/src/utils/input.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { TextAreaRef } from 'antd/es/input/TextArea'
|
||||
import { RefObject } from 'react'
|
||||
|
||||
export const insertTextAtCursor = ({
|
||||
text,
|
||||
pasteText,
|
||||
textareaRef,
|
||||
setText
|
||||
}: {
|
||||
text: string
|
||||
pasteText: string
|
||||
textareaRef: RefObject<TextAreaRef>
|
||||
setText: (text: string) => void
|
||||
}) => {
|
||||
const textarea = textareaRef.current?.resizableTextArea?.textArea
|
||||
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const newValue = text.substring(0, start) + pasteText + text.substring(end)
|
||||
|
||||
setText(newValue)
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.setSelectionRange(start + pasteText.length, start + pasteText.length)
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
45
yarn.lock
45
yarn.lock
@@ -1785,6 +1785,7 @@ __metadata:
|
||||
eslint-plugin-simple-import-sort: "npm:^12.1.1"
|
||||
eslint-plugin-unused-imports: "npm:^4.0.0"
|
||||
gpt-tokens: "npm:^1.3.10"
|
||||
html2canvas: "npm:^1.4.1"
|
||||
i18next: "npm:^23.11.5"
|
||||
localforage: "npm:^1.10.0"
|
||||
lodash: "npm:^4.17.21"
|
||||
@@ -2290,6 +2291,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"base64-arraybuffer@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "base64-arraybuffer@npm:1.0.2"
|
||||
checksum: 10c0/3acac95c70f9406e87a41073558ba85b6be9dbffb013a3d2a710e3f2d534d506c911847d5d9be4de458af6362c676de0a5c4c2d7bdf4def502d00b313368e72f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"base64-js@npm:^1.3.1, base64-js@npm:^1.5.1":
|
||||
version: 1.5.1
|
||||
resolution: "base64-js@npm:1.5.1"
|
||||
@@ -2881,6 +2889,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-line-break@npm:^2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "css-line-break@npm:2.1.0"
|
||||
dependencies:
|
||||
utrie: "npm:^1.0.2"
|
||||
checksum: 10c0/b2222d99d5daf7861ecddc050244fdce296fad74b000dcff6bdfb1eb16dc2ef0b9ffe2c1c965e3239bd05ebe9eadb6d5438a91592fa8648d27a338e827cf9048
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"css-to-react-native@npm:3.2.0":
|
||||
version: 3.2.0
|
||||
resolution: "css-to-react-native@npm:3.2.0"
|
||||
@@ -4669,6 +4686,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html2canvas@npm:^1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "html2canvas@npm:1.4.1"
|
||||
dependencies:
|
||||
css-line-break: "npm:^2.1.0"
|
||||
text-segmentation: "npm:^1.0.3"
|
||||
checksum: 10c0/6de86f75762b00948edf2ea559f16da0a1ec3facc4a8a7d3f35fcec59bb0c5970463478988ae3d9082152e0173690d46ebf4082e7ac803dd4817bae1d355c0db
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "http-cache-semantics@npm:4.1.1"
|
||||
@@ -8750,6 +8777,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"text-segmentation@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "text-segmentation@npm:1.0.3"
|
||||
dependencies:
|
||||
utrie: "npm:^1.0.2"
|
||||
checksum: 10c0/8b9ae8524e3a332371060d0ca62f10ad49a13e954719ea689a6c3a8b8c15c8a56365ede2bb91c322fb0d44b6533785f0da603e066b7554d052999967fb72d600
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"text-table@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "text-table@npm:0.2.0"
|
||||
@@ -9179,6 +9215,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"utrie@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "utrie@npm:1.0.2"
|
||||
dependencies:
|
||||
base64-arraybuffer: "npm:^1.0.2"
|
||||
checksum: 10c0/eaffe645bd81a39e4bc3abb23df5895e9961dbdd49748ef3b173529e8b06ce9dd1163e9705d5309a1c61ee41ffcb825e2043bc0fd1659845ffbdf4b1515dfdb4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:^10.0.0":
|
||||
version: 10.0.0
|
||||
resolution: "uuid@npm:10.0.0"
|
||||
|
||||
Reference in New Issue
Block a user