Compare commits

...

25 Commits

Author SHA1 Message Date
kangfenmao
45dd76e281 chore(version): 0.8.9 2024-11-03 01:26:23 +08:00
kangfenmao
568d4814e3 feat: remove image compress 2024-11-03 01:26:00 +08:00
kangfenmao
9468f3b511 refactor: main code 2024-11-02 23:32:59 +08:00
kangfenmao
04af940144 feat: export to word 2024-11-02 21:45:23 +08:00
kangfenmao
e33d9ac0ae fix: 在对话中插入图片时,应该自动压缩一下 #132 2024-11-02 15:30:41 +08:00
kangfenmao
cd835b7c36 fix: Ctrl + - 缩放时 缩到最小 再缩的话会报错 #266 2024-11-02 15:13:43 +08:00
kangfenmao
dd4239da87 fix: 默认模型在模型服务中禁用后显示错误 #266 2024-11-02 15:11:36 +08:00
kangfenmao
41c3895da4 feat: add message card style switch 2024-11-01 21:50:40 +08:00
kangfenmao
2e9c7d0830 fix: chat text color 2024-11-01 18:14:23 +08:00
kangfenmao
8ea73e14c9 style(message): user message use black color 2024-11-01 17:43:21 +08:00
kangfenmao
3791556b13 chore(version): 0.8.8 2024-11-01 12:39:11 +08:00
kangfenmao
e0dab5cf5b feat: set topic position to left 2024-11-01 12:28:13 +08:00
kangfenmao
1785e7df0a feat: dynamic handling for tab groups 2024-11-01 12:18:48 +08:00
kangfenmao
6cb1846b23 fix: ui and layout 2024-11-01 12:06:30 +08:00
kangfenmao
21243579b3 feat: added sorting functionality and updated translations 2024-11-01 11:46:11 +08:00
kangfenmao
0d2ad2e4c3 feat: improved ui layout and added reusable add agent card component 2024-11-01 11:00:17 +08:00
kangfenmao
071a3950cd feat: topic position set default to left 2024-10-31 17:11:32 +08:00
Teo
dc6066b74c refactor(navbar): 移除未使用的代码 2024-10-31 16:51:38 +08:00
Teo
ce55d8d0e7 refactor(navbar): 移除未使用的代码 2024-10-31 16:51:38 +08:00
Teo
d4ae321cd2 style(toggleTheme): 将主题切换按钮移到左下角菜单栏中 2024-10-31 16:51:38 +08:00
Teo
89dd35c98d style(chat): 对话界面改为左右布局 2024-10-31 16:51:38 +08:00
Teo
b8c70a3061 style(markdown): 小代码块不换行 2024-10-31 16:51:38 +08:00
kangfenmao
968a749aaa chore(version): 0.8.7 2024-10-31 15:01:37 +08:00
kangfenmao
e2fc593624 style: update responsive container styling for paintingslist component 2024-10-31 14:39:27 +08:00
kangfenmao
0e1674ce6c feat: added new painting functionality with mac device restriction 2024-10-31 14:35:05 +08:00
55 changed files with 1291 additions and 542 deletions

View File

@@ -63,7 +63,6 @@ electronDownload:
afterSign: scripts/notarize.js
releaseInfo:
releaseNotes: |
全新的智能体界面 by @cawabj
新增绘图模块
文件管理界面优化
修复可以同时启动多个应用问题
支持聊天气泡样式和简洁样式切换
支持导出对话为 Word 文档
错误修复

View File

@@ -1,6 +1,6 @@
{
"name": "CherryStudio",
"version": "0.8.6",
"version": "0.8.9",
"private": true,
"description": "A powerful AI assistant for producer.",
"main": "./out/main/index.js",
@@ -39,12 +39,14 @@
"@electron-toolkit/preload": "^3.0.0",
"@electron-toolkit/utils": "^3.0.0",
"archiver": "^7.0.1",
"docx": "^9.0.2",
"electron-log": "^5.1.5",
"electron-store": "^8.2.0",
"electron-updater": "^6.3.9",
"electron-window-state": "^5.0.3",
"fs-extra": "^11.2.0",
"html2canvas": "^1.4.1",
"markdown-it": "^14.1.0",
"officeparser": "^4.1.1",
"unzipper": "^0.12.3",
"webdav": "4.11.4"
@@ -60,6 +62,7 @@
"@reduxjs/toolkit": "^2.2.5",
"@types/fs-extra": "^11",
"@types/lodash": "^4.17.5",
"@types/markdown-it": "^14",
"@types/node": "^18.19.9",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",

View File

@@ -1,25 +1,15 @@
import fs from 'node:fs'
import { app } from 'electron'
import Store from 'electron-store'
import path from 'path'
import { getDataPath } from './utils'
const isDev = process.env.NODE_ENV === 'development'
isDev && app.setPath('userData', app.getPath('userData') + 'Dev')
const getDataPath = () => {
const dataPath = path.join(app.getPath('userData'), 'Data')
if (!fs.existsSync(dataPath)) {
fs.mkdirSync(dataPath, { recursive: true })
}
return dataPath
if (isDev) {
app.setPath('userData', app.getPath('userData') + 'Dev')
}
export const DATA_PATH = getDataPath()
export const appConfig = new Store()
export const titleBarOverlayDark = {
height: 40,
color: '#00000000',

View File

@@ -3,7 +3,7 @@ import { app, BrowserWindow } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { registerZoomShortcut } from './shortcut'
import { registerZoomShortcut } from './services/ShortcutService'
import { updateUserDataPath } from './utils/upgrade'
import { createMainWindow } from './window'

View File

@@ -2,20 +2,22 @@ import path from 'node:path'
import { BrowserWindow, ipcMain, session, shell } from 'electron'
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import FileManager from './services/FileManager'
import { configManager } from './services/ConfigManager'
import { ExportService } from './services/ExportService'
import FileStorage from './services/FileStorage'
import { compress, decompress } from './utils/zip'
import { createMinappWindow } from './window'
const fileManager = new FileManager()
const fileManager = new FileStorage()
const backupManager = new BackupManager()
const exportService = new ExportService(fileManager)
export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
const { autoUpdater } = new AppUpdater(mainWindow)
// IPC
ipcMain.handle('app:info', () => ({
version: app.getVersion(),
isPackaged: app.isPackaged,
@@ -23,23 +25,36 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
filesPath: path.join(app.getPath('userData'), 'Data', 'Files')
}))
ipcMain.handle('open-website', (_, url: string) => {
shell.openExternal(url)
ipcMain.handle('app:proxy', (_, proxy: string) => session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {}))
ipcMain.handle('app:reload', () => mainWindow.reload())
ipcMain.handle('open:website', (_, url: string) => shell.openExternal(url))
// theme
ipcMain.handle('app:set-theme', (_, theme: 'light' | 'dark') => {
configManager.setTheme(theme)
mainWindow?.setTitleBarOverlay &&
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
})
ipcMain.handle('set-proxy', (_, proxy: string) => {
session.defaultSession.setProxy(proxy ? { proxyRules: proxy } : {})
// check for update
ipcMain.handle('app:check-for-update', async () => {
return {
currentVersion: autoUpdater.currentVersion,
update: await autoUpdater.checkForUpdates()
}
})
ipcMain.handle('reload', () => mainWindow.reload())
// zip
ipcMain.handle('zip:compress', (_, text: string) => compress(text))
ipcMain.handle('zip:decompress', (_, text: Buffer) => decompress(text))
// backup
ipcMain.handle('backup:backup', backupManager.backup)
ipcMain.handle('backup:restore', backupManager.restore)
ipcMain.handle('backup:backupToWebdav', backupManager.backupToWebdav)
ipcMain.handle('backup:restoreFromWebdav', backupManager.restoreFromWebdav)
// file
ipcMain.handle('file:open', fileManager.open)
ipcMain.handle('file:save', fileManager.save)
ipcMain.handle('file:select', fileManager.selectFile)
@@ -54,7 +69,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('file:saveImage', fileManager.saveImage)
ipcMain.handle('file:base64Image', fileManager.base64Image)
ipcMain.handle('file:download', fileManager.downloadFile)
ipcMain.handle('file:copy', fileManager.copyFile)
// minapp
ipcMain.handle('minapp', (_, args) => {
createMinappWindow({
url: args.url,
@@ -66,17 +83,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
})
})
ipcMain.handle('set-theme', (_, theme: 'light' | 'dark') => {
appConfig.set('theme', theme)
mainWindow?.setTitleBarOverlay &&
mainWindow.setTitleBarOverlay(theme === 'dark' ? titleBarOverlayDark : titleBarOverlayLight)
})
// 触发检查更新(此方法用于被渲染线程调用,例如页面点击检查更新按钮来调用此方法)
ipcMain.handle('check-for-update', async () => {
return {
currentVersion: autoUpdater.currentVersion,
update: await autoUpdater.checkForUpdates()
}
})
// export
ipcMain.handle('export:word', exportService.exportToWord)
}

View File

@@ -0,0 +1,19 @@
import Store from 'electron-store'
export class ConfigManager {
private store: Store
constructor() {
this.store = new Store()
}
getTheme(): 'light' | 'dark' {
return this.store.get('theme', 'light') as 'light' | 'dark'
}
setTheme(theme: 'light' | 'dark') {
this.store.set('theme', theme)
}
}
export const configManager = new ConfigManager()

View File

@@ -0,0 +1,222 @@
/* eslint-disable no-case-declarations */
// ExportService
import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, ShadingType, TextRun } from 'docx'
import { dialog } from 'electron'
import Logger from 'electron-log'
import MarkdownIt from 'markdown-it'
import FileStorage from './FileStorage'
export class ExportService {
private fileManager: FileStorage
private md: MarkdownIt
constructor(fileManager: FileStorage) {
this.fileManager = fileManager
this.md = new MarkdownIt()
}
private convertMarkdownToDocxElements(markdown: string) {
const tokens = this.md.parse(markdown, {})
const elements: any[] = []
let listLevel = 0
const processInlineTokens = (tokens: any[]): TextRun[] => {
const runs: TextRun[] = []
for (const token of tokens) {
switch (token.type) {
case 'text':
runs.push(new TextRun(token.content))
break
case 'strong':
runs.push(new TextRun({ text: token.content, bold: true }))
break
case 'em':
runs.push(new TextRun({ text: token.content, italics: true }))
break
case 'code_inline':
runs.push(new TextRun({ text: token.content, font: 'Consolas', size: 20 }))
break
}
}
return runs
}
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
switch (token.type) {
case 'heading_open':
// 获取标题级别 (h1 -> h6)
const level = parseInt(token.tag.slice(1)) as 1 | 2 | 3 | 4 | 5 | 6
const headingText = tokens[i + 1].content
elements.push(
new Paragraph({
text: headingText,
heading: HeadingLevel[`HEADING_${level}`],
spacing: {
before: 240,
after: 120
}
})
)
i += 2 // 跳过内容标记和闭合标记
break
case 'paragraph_open':
const inlineTokens = tokens[i + 1].children || []
elements.push(
new Paragraph({
children: processInlineTokens(inlineTokens),
spacing: {
before: 120,
after: 120
}
})
)
i += 2
break
case 'bullet_list_open':
listLevel++
break
case 'bullet_list_close':
listLevel--
break
case 'list_item_open':
const itemInlineTokens = tokens[i + 2].children || []
elements.push(
new Paragraph({
children: [
new TextRun({ text: '•', bold: true }),
new TextRun({ text: '\t' }),
...processInlineTokens(itemInlineTokens)
],
indent: {
left: listLevel * 720
}
})
)
i += 3
break
case 'fence': // 代码块
const codeLines = token.content.split('\n')
elements.push(
new Paragraph({
children: codeLines.map(
(line) =>
new TextRun({
text: line + '\n',
font: 'Consolas',
size: 20,
break: 1
})
),
shading: {
type: ShadingType.SOLID,
color: 'F5F5F5'
},
spacing: {
before: 120,
after: 120
},
border: {
top: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
left: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' },
right: { style: BorderStyle.SINGLE, size: 1, color: 'DDDDDD' }
}
})
)
break
case 'hr':
elements.push(
new Paragraph({
children: [new TextRun({ text: '─'.repeat(50), color: '999999' })],
alignment: AlignmentType.CENTER
})
)
break
case 'blockquote_open':
const quoteText = tokens[i + 2].content
elements.push(
new Paragraph({
children: [
new TextRun({
text: quoteText,
italics: true
})
],
indent: {
left: 720
},
border: {
left: {
style: BorderStyle.SINGLE,
size: 3,
color: 'CCCCCC'
}
},
spacing: {
before: 120,
after: 120
}
})
)
i += 3
break
}
}
return elements
}
public exportToWord = async (_: Electron.IpcMainInvokeEvent, markdown: string, fileName: string): Promise<void> => {
try {
const elements = this.convertMarkdownToDocxElements(markdown)
const doc = new Document({
styles: {
paragraphStyles: [
{
id: 'Normal',
name: 'Normal',
run: {
size: 24,
font: 'Arial'
}
}
]
},
sections: [
{
properties: {},
children: elements
}
]
})
const buffer = await Packer.toBuffer(doc)
const filePath = dialog.showSaveDialogSync({
title: '保存文件',
filters: [{ name: 'Word Document', extensions: ['docx'] }],
defaultPath: fileName
})
if (filePath) {
await this.fileManager.writeFile(_, filePath, buffer)
Logger.info('[ExportService] Document exported successfully')
}
} catch (error) {
Logger.error('[ExportService] Export to Word failed:', error)
throw error
}
}
}

View File

@@ -1,4 +1,4 @@
import { documentExts } from '@main/constant'
import { documentExts, imageExts } from '@main/constant'
import { getFileType } from '@main/utils/file'
import { FileType } from '@types'
import * as crypto from 'crypto'
@@ -19,7 +19,7 @@ import * as path from 'path'
import { chdir } from 'process'
import { v4 as uuidv4 } from 'uuid'
class FileManager {
class FileStorage {
private storageDir = path.join(app.getPath('userData'), 'Data', 'Files')
private tempDir = path.join(app.getPath('temp'), 'CherryStudio')
@@ -119,6 +119,31 @@ class FileManager {
return Promise.all(fileMetadataPromises)
}
private async compressImage(sourcePath: string, destPath: string): Promise<void> {
try {
const stats = fs.statSync(sourcePath)
const fileSizeInMB = stats.size / (1024 * 1024)
// 如果图片大于1MB才进行压缩
if (fileSizeInMB > 1) {
try {
await fs.promises.copyFile(sourcePath, destPath)
logger.info('[FileStorage] Image compressed successfully:', sourcePath)
} catch (jimpError) {
logger.error('[FileStorage] Image compression failed:', jimpError)
await fs.promises.copyFile(sourcePath, destPath)
}
} else {
// 小图片直接复制
await fs.promises.copyFile(sourcePath, destPath)
}
} catch (error) {
logger.error('[FileStorage] Image handling failed:', error)
// 错误情况下直接复制原文件
await fs.promises.copyFile(sourcePath, destPath)
}
}
public uploadFile = async (_: Electron.IpcMainInvokeEvent, file: FileType): Promise<FileType> => {
const duplicateFile = await this.findDuplicateFile(file.path)
@@ -128,10 +153,18 @@ class FileManager {
const uuid = uuidv4()
const origin_name = path.basename(file.path)
const ext = path.extname(origin_name)
const ext = path.extname(origin_name).toLowerCase()
const destPath = path.join(this.storageDir, uuid + ext)
await fs.promises.copyFile(file.path, destPath)
logger.info('[FileStorage] Uploading file:', file.path)
// 根据文件类型选择处理方式
if (imageExts.includes(ext)) {
await this.compressImage(file.path, destPath)
} else {
await fs.promises.copyFile(file.path, destPath)
}
const stats = await fs.promises.stat(destPath)
const fileType = getFileType(ext)
@@ -265,7 +298,7 @@ class FileManager {
fileName: string,
content: string,
options?: SaveDialogOptions
): Promise<void> => {
): Promise<string | null> => {
try {
const result: SaveDialogReturnValue = await dialog.showSaveDialog({
title: '保存文件',
@@ -276,8 +309,11 @@ class FileManager {
if (!result.canceled && result.filePath) {
await writeFileSync(result.filePath, content, { encoding: 'utf-8' })
}
return result.filePath
} catch (err) {
logger.error('[IPC - Error]', 'An error occurred saving the file:', err)
return null
}
}
@@ -372,7 +408,7 @@ class FileManager {
return fileMetadata
} catch (error) {
logger.error('[FileManager] Download file error:', error)
logger.error('[FileStorage] Download file error:', error)
throw error
}
}
@@ -395,6 +431,25 @@ class FileManager {
return mimeToExtension[mimeType] || '.bin'
}
public copyFile = async (_: Electron.IpcMainInvokeEvent, id: string, destPath: string): Promise<void> => {
try {
const sourcePath = path.join(this.storageDir, id)
// 确保目标目录存在
const destDir = path.dirname(destPath)
if (!fs.existsSync(destDir)) {
await fs.promises.mkdir(destDir, { recursive: true })
}
// 复制文件
await fs.promises.copyFile(sourcePath, destPath)
logger.info('[FileStorage] File copied successfully:', { from: sourcePath, to: destPath })
} catch (error) {
logger.error('[FileStorage] Copy file failed:', error)
throw error
}
}
}
export default FileManager
export default FileStorage

View File

@@ -0,0 +1,53 @@
import { BrowserWindow, globalShortcut } from 'electron'
export function registerZoomShortcut(mainWindow: BrowserWindow) {
const registerShortcuts = () => {
// 注册放大快捷键 (Ctrl+Plus 或 Cmd+Plus)
globalShortcut.register('CommandOrControl+=', () => {
if (mainWindow) {
const currentZoom = mainWindow.webContents.getZoomFactor()
const newZoom = currentZoom + 0.1
// Prevent zoom factor from exceeding reasonable limits
if (newZoom <= 5.0) {
mainWindow.webContents.setZoomFactor(newZoom)
}
}
})
// 注册缩小快捷键 (Ctrl+Minus 或 Cmd+Minus)
globalShortcut.register('CommandOrControl+-', () => {
if (mainWindow) {
const currentZoom = mainWindow.webContents.getZoomFactor()
const newZoom = currentZoom - 0.1
// Prevent zoom factor from going below 0.1
if (newZoom >= 0.1) {
mainWindow.webContents.setZoomFactor(newZoom)
}
}
})
// 注册重置缩放快捷键 (Ctrl+0 或 Cmd+0)
globalShortcut.register('CommandOrControl+0', () => {
if (mainWindow) {
mainWindow.webContents.setZoomFactor(1)
}
})
}
const unregisterShortcuts = () => {
globalShortcut.unregister('CommandOrControl+=')
globalShortcut.unregister('CommandOrControl+-')
globalShortcut.unregister('CommandOrControl+0')
}
// 当窗口获得焦点时注册快捷键
mainWindow.on('focus', registerShortcuts)
// 当窗口失去焦点时注销快捷键
mainWindow.on('blur', unregisterShortcuts)
// 初始注册(如果窗口已经处于焦点状态)
if (mainWindow.isFocused()) {
registerShortcuts()
}
}

View File

@@ -1,26 +0,0 @@
import { BrowserWindow, globalShortcut } from 'electron'
export function registerZoomShortcut(mainWindow: BrowserWindow) {
// 注册放大快捷键 (Ctrl+Plus 或 Cmd+Plus)
globalShortcut.register('CommandOrControl+=', () => {
if (mainWindow) {
const currentZoom = mainWindow.webContents.getZoomFactor()
mainWindow.webContents.setZoomFactor(currentZoom + 0.1)
}
})
// 注册缩小快捷键 (Ctrl+Minus 或 Cmd+Minus)
globalShortcut.register('CommandOrControl+-', () => {
if (mainWindow) {
const currentZoom = mainWindow.webContents.getZoomFactor()
mainWindow.webContents.setZoomFactor(currentZoom - 0.1)
}
})
// 注册重置缩放快捷键 (Ctrl+0 或 Cmd+0)
globalShortcut.register('CommandOrControl+0', () => {
if (mainWindow) {
mainWindow.webContents.setZoomFactor(1)
}
})
}

View File

@@ -1,3 +1,4 @@
import fs from 'node:fs'
import path from 'node:path'
import { app } from 'electron'
@@ -5,3 +6,11 @@ import { app } from 'electron'
export function getResourcePath() {
return path.join(app.getAppPath(), 'resources')
}
export function getDataPath() {
const dataPath = path.join(app.getPath('userData'), 'Data')
if (!fs.existsSync(dataPath)) {
fs.mkdirSync(dataPath, { recursive: true })
}
return dataPath
}

View File

@@ -4,7 +4,8 @@ import windowStateKeeper from 'electron-window-state'
import { join } from 'path'
import icon from '../../build/icon.png?asset'
import { appConfig, titleBarOverlayDark, titleBarOverlayLight } from './config'
import { titleBarOverlayDark, titleBarOverlayLight } from './config'
import { configManager } from './services/ConfigManager'
export function createMainWindow() {
// Load the previous state with fallback to defaults
@@ -13,7 +14,7 @@ export function createMainWindow() {
defaultHeight: 670
})
const theme = appConfig.get('theme') || 'light'
const theme = configManager.getTheme()
// Create the browser window.
const isMac = process.platform === 'darwin'

View File

@@ -20,8 +20,10 @@ declare global {
setTheme: (theme: 'light' | 'dark') => void
minApp: (options: { url: string; windowOptions?: Electron.BrowserWindowConstructorOptions }) => void
reload: () => void
compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string>
zip: {
compress: (text: string) => Promise<Buffer>
decompress: (text: Buffer) => Promise<string>
}
backup: {
backup: (fileName: string, data: string, destinationPath?: string) => Promise<Readable>
restore: (backupPath: string) => Promise<string>
@@ -39,10 +41,18 @@ declare global {
create: (fileName: string) => Promise<string>
write: (filePath: string, data: Uint8Array | string) => Promise<void>
open: (options?: OpenDialogOptions) => Promise<{ fileName: string; filePath: string; content: Buffer } | null>
save: (path: string, content: string | NodeJS.ArrayBufferView, options?: SaveDialogOptions) => void
save: (
path: string,
content: string | NodeJS.ArrayBufferView,
options?: SaveDialogOptions
) => Promise<string | null>
saveImage: (name: string, data: string) => void
base64Image: (fileId: string) => Promise<{ mime: string; base64: string; data: string }>
download: (url: string) => Promise<FileType | null>
copy: (fileId: string, destPath: string) => Promise<void>
}
export: {
toWord: (markdown: string, fileName: string) => Promise<void>
}
}
}

View File

@@ -5,14 +5,16 @@ import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
// Custom APIs for renderer
const api = {
getAppInfo: () => ipcRenderer.invoke('app:info'),
checkForUpdate: () => ipcRenderer.invoke('check-for-update'),
openWebsite: (url: string) => ipcRenderer.invoke('open-website', url),
setProxy: (proxy: string) => ipcRenderer.invoke('set-proxy', proxy),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('set-theme', theme),
reload: () => ipcRenderer.invoke('app:reload'),
setProxy: (proxy: string) => ipcRenderer.invoke('app:proxy', proxy),
checkForUpdate: () => ipcRenderer.invoke('app:check-for-update'),
setTheme: (theme: 'light' | 'dark') => ipcRenderer.invoke('app:set-theme', theme),
openWebsite: (url: string) => ipcRenderer.invoke('open:website', url),
minApp: (url: string) => ipcRenderer.invoke('minapp', url),
reload: () => ipcRenderer.invoke('reload'),
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text),
zip: {
compress: (text: string) => ipcRenderer.invoke('zip:compress', text),
decompress: (text: Buffer) => ipcRenderer.invoke('zip:decompress', text)
},
backup: {
backup: (fileName: string, data: string, destinationPath?: string) =>
ipcRenderer.invoke('backup:backup', fileName, data, destinationPath),
@@ -36,7 +38,11 @@ const api = {
selectFolder: () => ipcRenderer.invoke('file:selectFolder'),
saveImage: (name: string, data: string) => ipcRenderer.invoke('file:saveImage', name, data),
base64Image: (fileId: string) => ipcRenderer.invoke('file:base64Image', fileId),
download: (url: string) => ipcRenderer.invoke('file:download', url)
download: (url: string) => ipcRenderer.invoke('file:download', url),
copy: (fileId: string, destPath: string) => ipcRenderer.invoke('file:copy', fileId, destPath)
},
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
}
}

View File

@@ -34,6 +34,7 @@
--color-icon-white: #ffffff;
--color-border: #ffffff24;
--color-border-soft: #ffffff20;
--color-border-mute: #ffffff11;
--color-error: #f44336;
--color-link: #1677ff;
--color-code-background: #323232;
@@ -51,6 +52,11 @@
--assistants-width: 275px;
--topic-list-width: 275px;
--settings-width: 250px;
--chat-background: #111111;
--chat-background-user: #28b561;
--chat-background-assistant: #2c2c2c;
--chat-text-user: var(--color-black);
}
body[theme-mode='light'] {
@@ -83,6 +89,7 @@ body[theme-mode='light'] {
--color-icon-white: #000000;
--color-border: #00000028;
--color-border-soft: #00000028;
--color-border-mute: #00000011;
--color-error: #f44336;
--color-link: #1677ff;
--color-code-background: #e3e3e3;
@@ -91,6 +98,11 @@ body[theme-mode='light'] {
--navbar-background-mac: rgba(255, 255, 255, 0.6);
--navbar-background: rgba(255, 255, 255);
--chat-background: #f3f3f3;
--chat-background-user: #95ec69;
--chat-background-assistant: #ffffff;
--chat-text-user: var(--color-text);
}
*,
@@ -156,7 +168,6 @@ body,
body[os='mac'] {
#content-container {
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
border-left: 0.5px solid var(--color-border);
box-shadow: 0 0 15px 1px rgba(0, 0, 0, 0.05);
}

View File

@@ -95,7 +95,7 @@
}
span {
word-break: break-all;
white-space: pre;
}
code {
@@ -107,6 +107,8 @@
background: var(--color-background-mute);
padding: 3px 5px;
border-radius: 5px;
word-break: keep-all;
white-space: pre;
}
pre {

View File

@@ -57,7 +57,7 @@ const TranslateButton: FC<Props> = ({ text, onTranslated, disabled, style }) =>
return (
<Button
icon={<TranslationOutlined />}
icon={<TranslationOutlined style={{ fontSize: 14 }} />}
onClick={handleTranslate}
disabled={disabled || isTranslating}
loading={isTranslating}

View File

@@ -1,6 +1,7 @@
import { FileSearchOutlined, FolderOutlined, PictureOutlined, TranslationOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { isLocalAi, UserAvatar } from '@renderer/config/env'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
@@ -21,6 +22,7 @@ const Sidebar: FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { windowStyle } = useSettings()
const { theme, toggleTheme } = useTheme()
const isRoute = (path: string): string => (pathname === path ? 'active' : '')
const isRoutes = (path: string): string => (pathname.startsWith(path) ? 'active' : '')
@@ -86,6 +88,14 @@ const Sidebar: FC = () => {
</Menus>
</MainMenus>
<Menus onClick={MinApp.onClose}>
<Icon onClick={() => toggleTheme()}>
{theme === 'dark' ? (
<i className="iconfont icon-theme icon-dark1" />
) : (
<i className="iconfont icon-theme icon-theme-light" />
)}
</Icon>
<StyledLink onClick={() => to(isLocalAi ? '/settings/assistant' : '/settings/provider')}>
<Icon className={pathname.startsWith('/settings') ? 'active' : ''}>
<i className="iconfont icon-setting" />

View File

@@ -8,7 +8,7 @@ export const isMac = platform === 'darwin'
export const isWindows = platform === 'win32' || platform === 'win64'
export const isLinux = platform === 'linux'
export const imageExts = ['.jpg', '.png', '.jpeg']
export const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
export const documentExts = ['.pdf', '.docx', '.pptx', '.xlsx', '.odt', '.odp', '.ods']
export const textExts = [
'.txt', // 普通文本文件

View File

@@ -28,3 +28,12 @@ export function useSettings() {
}
}
}
export function useMessageStyle() {
const { messageStyle } = useSettings()
const isBubbleStyle = messageStyle === 'bubble'
return {
isBubbleStyle
}
}

View File

@@ -64,7 +64,10 @@
"upgrade.success.content": "Please restart the application to complete the upgrade",
"upgrade.success.button": "Restart",
"topic.added": "New topic added",
"save.success.title": "Saved successfully"
"save.success.title": "Saved successfully",
"message.style": "Message Style",
"message.style.bubble": "Bubble",
"message.style.plain": "Plain"
},
"chat": {
"save": "Save",
@@ -81,6 +84,7 @@
"topics.export.title": "Export",
"topics.export.image": "Export as image",
"topics.export.md": "Export as markdown",
"topics.export.word": "Export as Word",
"input.new_topic": "New Topic",
"input.topics": " Topics ",
"input.clear": "Clear",
@@ -154,7 +158,7 @@
"guidance_scale_tip": "Classifier Free Guidance. How close you want the model to stick to your prompt when looking for a related image to show you",
"negative_prompt": "Negative Prompt",
"negative_prompt_tip": "Describe what you don't want included in the image",
"prompt_placeholder": "Describe the image you want to create, e.g. 'A serene lake at sunset with mountains in the background'",
"prompt_placeholder": "Describe the image you want to create, e.g. A serene lake at sunset with mountains in the background",
"regenerate.confirm": "This will replace your existing generated images. Do you want to continue?"
},
"files": {
@@ -196,7 +200,8 @@
"edit.message.empty.content": "Conversation input content cannot be empty",
"edit.model.select.title": "Select Model",
"edit.settings.hide_preset_messages": "Hide Preset Message",
"search.no_results": "No results found"
"search.no_results": "No results found",
"sorting.title": "Sorting"
},
"minapp": {
"title": "MinApp"
@@ -383,6 +388,16 @@
"words": {
"knowledgeGraph": "Knowledge Graph",
"visualization": "Visualization"
},
"export": {
"attached_files": "Attached Files",
"user": "User",
"assistant": "Assistant",
"created": "Created",
"last_updated": "Last Updated",
"messages": "Messages",
"conversation_details": "Conversation Details",
"conversation_history": "Conversation History"
}
}
}

View File

@@ -64,7 +64,10 @@
"upgrade.success.content": "重启用以完成升级",
"upgrade.success.button": "重启",
"topic.added": "话题添加成功",
"save.success.title": "保存成功"
"save.success.title": "保存成功",
"message.style": "消息样式",
"message.style.bubble": "气泡",
"message.style.plain": "简洁"
},
"chat": {
"save": "保存",
@@ -81,6 +84,7 @@
"topics.export.title": "导出",
"topics.export.image": "导出为图片",
"topics.export.md": "导出为 Markdown",
"topics.export.word": "导出为 Word",
"input.new_topic": "新话题",
"input.topics": " 话题 ",
"input.clear": "清空消息",
@@ -154,7 +158,7 @@
"guidance_scale_tip": "无分类器指导。控制模型在寻找相关图像时对提示词的遵循程度",
"negative_prompt": "反向提示词",
"negative_prompt_tip": "描述你不想在图片中出现的内容",
"prompt_placeholder": "描述你想创建的图片,例如:'一个宁静的湖泊,夕阳西下,远处是群山'",
"prompt_placeholder": "描述你想创建的图片,例如:一个宁静的湖泊,夕阳西下,远处是群山",
"regenerate.confirm": "这将覆盖已生成的图片,是否继续?"
},
"files": {
@@ -196,7 +200,8 @@
"edit.message.empty.content": "会话输入内容不能为空",
"edit.model.select.title": "选择模型",
"edit.settings.hide_preset_messages": "隐藏预设消息",
"search.no_results": "没有找到相关智能体"
"search.no_results": "没有找到相关智能体",
"sorting.title": "排序"
},
"minapp": {
"title": "小程序"
@@ -383,6 +388,16 @@
"words": {
"knowledgeGraph": "知识图谱",
"visualization": "可视化"
},
"export": {
"attached_files": "附件",
"user": "用户",
"assistant": "助手",
"created": "创建时间",
"last_updated": "最后更新",
"messages": "消息数",
"conversation_details": "会话详情",
"conversation_history": "会话历史"
}
}
}

View File

@@ -64,7 +64,10 @@
"upgrade.success.content": "請重新啟動應用以完成升級",
"upgrade.success.button": "重新啟動",
"topic.added": "新話題已添加",
"save.success.title": "保存成功"
"save.success.title": "保存成功",
"message.style": "消息樣式",
"message.style.bubble": "氣泡",
"message.style.plain": "簡潔"
},
"chat": {
"save": "保存",
@@ -81,6 +84,7 @@
"topics.export.title": "匯出",
"topics.export.image": "匯出為圖片",
"topics.export.md": "匯出為 Markdown",
"topics.export.word": "導出為 Word",
"input.new_topic": "新話題",
"input.topics": " 話題 ",
"input.clear": "清除",
@@ -154,7 +158,7 @@
"guidance_scale_tip": "無分類器指導。控制模型在尋找相關圖像時對提示詞的遵循程度",
"negative_prompt": "反向提示詞",
"negative_prompt_tip": "描述你不想在圖片中出現的內容",
"prompt_placeholder": "描述你想創建的圖片,例如:'一個寧靜的湖泊,夕陽西下,遠處是群山'",
"prompt_placeholder": "描述你想創建的圖片,例如:一個寧靜的湖泊,夕陽西下,遠處是群山",
"regenerate.confirm": "這將覆蓋已生成的圖片,是否繼續?"
},
"files": {
@@ -196,7 +200,8 @@
"edit.message.empty.content": "會話輸入內容不能為空",
"edit.model.select.title": "選擇模型",
"edit.settings.hide_preset_messages": "隱藏預設消息",
"search.no_results": "沒有找到相關智能體"
"search.no_results": "沒有找到相關智能體",
"sorting.title": "排序"
},
"minapp": {
"title": "小程序"
@@ -383,6 +388,16 @@
"words": {
"knowledgeGraph": "知識圖譜",
"visualization": "可視化"
},
"export": {
"attached_files": "附件",
"user": "用戶",
"assistant": "助手",
"created": "創建時間",
"last_updated": "最後更新",
"messages": "訊息數",
"conversation_details": "會話詳情",
"conversation_history": "會話歷史"
}
}
}

View File

@@ -1,199 +0,0 @@
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import AssistantSettingsPopup from '@renderer/components/AssistantSettings'
import DragableList from '@renderer/components/DragableList'
import { useAgents } from '@renderer/hooks/useAgents'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { Agent } from '@renderer/types'
import { Button, Col, Typography } from 'antd'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import AddAgentPopup from './components/AddAgentPopup'
import AgentCard from './components/AgentCard'
interface Props {
onClick?: (agent: Agent) => void
cardStyle?: 'new' | 'old'
}
const Agents: React.FC<Props> = ({ onClick, cardStyle = 'old' }) => {
const { t } = useTranslation()
const { agents, removeAgent, updateAgents } = useAgents()
const [dragging, setDragging] = useState(false)
const handleDelete = useCallback(
(agent: Agent) => {
window.modal.confirm({
centered: true,
content: t('agents.delete.popup.content'),
onOk: () => removeAgent(agent.id)
})
},
[removeAgent, t]
)
if (cardStyle === 'new') {
return (
<>
{agents.map((agent) => {
const dropdownMenuItems = [
{
key: 'edit',
label: t('agents.edit.title'),
icon: <EditOutlined />,
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
},
{
key: 'create',
label: t('agents.add.button'),
icon: <PlusOutlined />,
onClick: () => createAssistantFromAgent(agent)
},
{
key: 'delete',
label: t('common.delete'),
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDelete(agent)
}
]
const contextMenuItems = [
{
label: t('agents.edit.title'),
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
},
{
label: t('agents.add.button'),
onClick: () => createAssistantFromAgent(agent)
},
{
label: t('common.delete'),
onClick: () => handleDelete(agent)
}
]
return (
<Col span={8} xxl={6} key={agent.id}>
<AgentCard
agent={agent}
onClick={() => onClick?.(agent)}
contextMenu={contextMenuItems}
menuItems={dropdownMenuItems}
/>
</Col>
)
})}
</>
)
}
return (
<Container>
<div style={{ paddingBottom: dragging ? 30 : 0 }}>
<Typography.Title level={5} style={{ marginBottom: 16 }}>
{t('agents.my_agents')}
</Typography.Title>
{agents.length > 0 && (
<DragableList
list={agents}
onUpdate={updateAgents}
onDragStart={() => setDragging(true)}
onDragEnd={() => setDragging(false)}>
{(agent: Agent) => {
const dropdownMenuItems = [
{
key: 'edit',
label: t('agents.edit.title'),
icon: <EditOutlined />,
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
},
{
key: 'create',
label: t('agents.add.button'),
icon: <PlusOutlined />,
onClick: () => createAssistantFromAgent(agent)
},
{
key: 'delete',
label: t('common.delete'),
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDelete(agent)
}
]
const contextMenuItems = [
{
label: t('agents.edit.title'),
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
},
{
label: t('agents.add.button'),
onClick: () => createAssistantFromAgent(agent)
},
{
label: t('common.delete'),
onClick: () => handleDelete(agent)
}
]
return (
<AgentCard
agent={agent}
onClick={() => onClick?.(agent)}
contextMenu={contextMenuItems}
menuItems={dropdownMenuItems}
/>
)
}}
</DragableList>
)}
{!dragging && (
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={() => AddAgentPopup.show()}
style={{ borderRadius: 20, height: 34 }}>
{t('agents.add.title')}
</Button>
)}
<div style={{ height: 10 }} />
</div>
</Container>
)
}
const Container = styled.div`
padding: 10px 15px;
display: flex;
flex-direction: column;
min-height: calc(100vh - var(--navbar-height));
min-width: var(--assistants-width);
max-width: var(--assistants-width);
overflow-y: auto;
overflow-x: hidden;
&::-webkit-scrollbar {
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-track {
border-radius: 3px;
background: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background: var(--color-scrollbar-thumb);
transition: all 0.2s ease-in-out;
}
&:hover::-webkit-scrollbar-thumb {
background: var(--color-scrollbar-thumb);
}
`
export default Agents

View File

@@ -1,4 +1,4 @@
import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
import { SearchOutlined } from '@ant-design/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import Scrollbar from '@renderer/components/Scrollbar'
import SystemAgents from '@renderer/config/agents.json'
@@ -13,9 +13,8 @@ import ReactMarkdown from 'react-markdown'
import styled from 'styled-components'
import { groupTranslations } from './agentGroupTranslations'
import Agents from './Agents'
import AddAgentPopup from './components/AddAgentPopup'
import AgentCard from './components/AgentCard'
import MyAgents from './components/MyAgents'
const { Title } = Typography
@@ -45,7 +44,7 @@ const AgentsPage: FC = () => {
const { t, i18n } = useTranslation()
const filteredAgentGroups = useMemo(() => {
const groups = search.trim() ? {} : { : [] }
const groups = { : [] }
if (!search.trim()) {
Object.entries(agentGroups).forEach(([group, agents]) => {
@@ -113,8 +112,8 @@ const AgentsPage: FC = () => {
const tabItems = useMemo(() => {
let groups = Object.keys(filteredAgentGroups)
groups = groups.filter((g) => g !== '我的' && g !== '办公')
groups = ['我的', '办公', ...groups]
groups = groups.includes('办公') ? [groups[0], '办公', ...groups.slice(1)] : groups
return groups.map((group, i) => {
const id = String(i + 1)
@@ -128,17 +127,12 @@ const AgentsPage: FC = () => {
<Title level={5} key={group} style={{ marginBottom: 16 }}>
{localizedGroupName}
</Title>
<Row gutter={[25, 25]}>
<Row gutter={[20, 20]}>
{group === '我的' ? (
<>
<Col span={8} xxl={6}>
<AddAgentCard onClick={() => AddAgentPopup.show()} />
</Col>
<Agents onClick={onAddAgentConfirm} cardStyle="new" />
</>
<MyAgents onClick={onAddAgentConfirm} search={search} />
) : (
filteredAgentGroups[group]?.map((agent, index) => (
<Col span={8} xxl={6} key={group + index}>
<Col span={6} key={group + index}>
<AgentCard onClick={() => onAddAgentConfirm(getAgentFromSystemAgent(agent))} agent={agent as any} />
</Col>
))
@@ -148,10 +142,10 @@ const AgentsPage: FC = () => {
)
}
})
}, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm])
}, [filteredAgentGroups, getLocalizedGroupName, onAddAgentConfirm, search])
return (
<StyledContainer>
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none', justifyContent: 'space-between' }}>
{t('agents.title')}
@@ -173,7 +167,7 @@ const AgentsPage: FC = () => {
<ContentContainer id="content-container">
<AssistantsContainer>
{tabItems.length > 0 ? (
<Tabs tabPosition="left" animated items={tabItems} />
<Tabs tabPosition="right" animated items={tabItems} />
) : (
<EmptyView>
<Empty description={t('agents.search.no_results')} />
@@ -181,11 +175,11 @@ const AgentsPage: FC = () => {
)}
</AssistantsContainer>
</ContentContainer>
</StyledContainer>
</Container>
)
}
const StyledContainer = styled.div`
const Container = styled.div`
display: flex;
flex: 1;
flex-direction: column;
@@ -199,6 +193,7 @@ const ContentContainer = styled.div`
justify-content: center;
height: 100%;
padding: 0 10px;
padding-left: 0;
`
const AssistantsContainer = styled.div`
@@ -211,7 +206,8 @@ const AssistantsContainer = styled.div`
const TabContent = styled(Scrollbar)`
height: calc(100vh - var(--navbar-height));
padding: 10px 10px 10px 15px;
margin-right: 4px;
margin-right: -4px;
padding-bottom: 20px !important;
overflow-x: hidden;
`
@@ -235,7 +231,11 @@ const Tabs = styled(TabsAntd)`
flex: 1;
flex-direction: row-reverse;
.ant-tabs-tabpane {
padding-left: 0 !important;
padding-right: 0 !important;
}
.ant-tabs-nav {
min-width: 140px;
max-width: 140px;
}
.ant-tabs-nav-list {
padding: 10px 8px;
@@ -247,8 +247,9 @@ const Tabs = styled(TabsAntd)`
margin: 0 !important;
border-radius: 20px;
margin-bottom: 5px !important;
font-size: 14px;
justify-content: center;
font-size: 13px;
justify-content: left;
padding: 7px 12px !important;
&:hover {
color: var(--color-text) !important;
background-color: var(--color-background-soft);
@@ -259,8 +260,8 @@ const Tabs = styled(TabsAntd)`
border-right: none;
}
.ant-tabs-content-holder {
border-left: none;
border-right: 0.5px solid var(--color-border);
border-left: 0.5px solid var(--color-border);
border-right: none;
}
.ant-tabs-ink-bar {
display: none;
@@ -275,33 +276,4 @@ const Tabs = styled(TabsAntd)`
}
`
const AddAgentCard = styled(({ onClick, className }: { onClick: () => void; className?: string }) => {
const { t } = useTranslation()
return (
<div className={className} onClick={onClick}>
<PlusOutlined style={{ fontSize: 24 }} />
<span style={{ marginTop: 10 }}>{t('agents.add.title')}</span>
</div>
)
})`
width: 100%;
height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--color-background);
border-radius: 15px;
border: 1px dashed var(--color-border);
cursor: pointer;
transition: all 0.3s ease;
color: var(--color-text-soft);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
`
export default AgentsPage

View File

@@ -0,0 +1,41 @@
import { PlusOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface AddAgentCardProps {
onClick: () => void
className?: string
}
const AddAgentCard = ({ onClick, className }: AddAgentCardProps) => {
const { t } = useTranslation()
return (
<StyledCard className={className} onClick={onClick}>
<PlusOutlined style={{ fontSize: 24 }} />
<span style={{ marginTop: 10 }}>{t('agents.add.title')}</span>
</StyledCard>
)
}
const StyledCard = styled.div`
width: 100%;
height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--color-background);
border-radius: 15px;
border: 1px dashed var(--color-border);
cursor: pointer;
transition: all 0.3s ease;
color: var(--color-text-soft);
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
`
export default AddAgentCard

View File

@@ -17,9 +17,63 @@ interface Props {
}[]
}
const AgentCard: React.FC<Props> = ({ agent, onClick, contextMenu, menuItems }) => {
const emoji = agent.emoji || getLeadingEmoji(agent.name)
const prompt = (agent.description || agent.prompt).substring(0, 100).replace(/\\n/g, '')
const content = (
<Container onClick={onClick}>
{emoji && <BannerBackground className="banner-background">{emoji}</BannerBackground>}
<EmojiContainer className="emoji-container">{emoji}</EmojiContainer>
{menuItems && (
<MenuContainer onClick={(e) => e.stopPropagation()}>
<Dropdown
menu={{
items: menuItems.map((item) => ({
...item,
onClick: (e) => {
e.domEvent.stopPropagation()
e.domEvent.preventDefault()
setTimeout(() => {
item.onClick()
}, 0)
}
}))
}}
trigger={['click']}
placement="bottomRight">
<EllipsisOutlined style={{ cursor: 'pointer', fontSize: 20 }} />
</Dropdown>
</MenuContainer>
)}
<CardInfo className="card-info">
<AgentName>{agent.name}</AgentName>
<AgentPrompt className="agent-prompt">{prompt}...</AgentPrompt>
</CardInfo>
</Container>
)
if (contextMenu) {
return (
<Dropdown
menu={{
items: contextMenu.map((item) => ({
key: item.label,
label: item.label,
onClick: () => item.onClick()
}))
}}
trigger={['contextMenu']}>
{content}
</Dropdown>
)
}
return content
}
const Container = styled.div`
width: 100%;
height: 220px;
height: 180px;
display: flex;
flex-direction: column;
align-items: center;
@@ -36,7 +90,7 @@ const Container = styled.div`
&::before {
content: '';
width: 100%;
height: 80px;
height: 70px;
position: absolute;
top: 0;
left: 0;
@@ -51,41 +105,21 @@ const Container = styled.div`
z-index: 1;
}
&:hover::before {
width: 100%;
height: 100%;
border-radius: 15px;
}
&:hover .card-info {
transform: translateY(-15px);
padding: 0 20px;
.agent-prompt {
opacity: 1;
transform: translateY(0);
}
}
&:hover .emoji-container {
transform: scale(0.6);
margin-top: 5px;
}
&:hover .banner-background {
height: 100%;
.agent-prompt {
opacity: 1;
transform: translateY(0);
}
`
const EmojiContainer = styled.div`
width: 70px;
height: 70px;
min-width: 70px;
min-height: 70px;
width: 55px;
height: 55px;
min-width: 55px;
min-height: 55px;
background-color: var(--color-background);
border-radius: 50%;
border: 4px solid var(--color-border);
margin-top: 20px;
margin-top: 8px;
transition: all 0.5s ease;
display: flex;
align-items: center;
@@ -97,7 +131,7 @@ const CardInfo = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 5px;
transition: all 0.5s ease;
padding: 0 15px;
width: 100%;
@@ -119,14 +153,14 @@ const AgentName = styled.span`
const AgentPrompt = styled.p`
color: var(--color-text-soft);
font-size: 14px;
font-size: 12px;
max-width: 100%;
opacity: 0;
transform: translateY(20px);
transition: all 0.5s ease;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
@@ -137,13 +171,13 @@ const BannerBackground = styled.div`
top: 0;
left: 0;
width: 100%;
height: 80px;
height: 70px;
display: flex;
justify-content: center;
align-items: center;
font-size: 500px;
opacity: 0.1;
filter: blur(10px);
filter: blur(8px);
z-index: 0;
overflow: hidden;
transition: all 0.5s ease;
@@ -171,57 +205,4 @@ const MenuContainer = styled.div`
}
`
const AgentCard: React.FC<Props> = ({ agent, onClick, contextMenu, menuItems }) => {
const emoji = agent.emoji || getLeadingEmoji(agent.name)
const content = (
<Container onClick={onClick}>
{agent.emoji && <BannerBackground className="banner-background">{agent.emoji}</BannerBackground>}
<EmojiContainer className="emoji-container">{emoji}</EmojiContainer>
{menuItems && (
<MenuContainer onClick={(e) => e.stopPropagation()}>
<Dropdown
menu={{
items: menuItems.map((item) => ({
...item,
onClick: (e) => {
e.domEvent.stopPropagation()
e.domEvent.preventDefault()
setTimeout(() => {
item.onClick()
}, 0)
}
}))
}}
trigger={['click']}
placement="bottomRight">
<EllipsisOutlined style={{ cursor: 'pointer' }} />
</Dropdown>
</MenuContainer>
)}
<CardInfo className="card-info">
<AgentName>{agent.name}</AgentName>
<AgentPrompt className="agent-prompt">{(agent.description || agent.prompt).substring(0, 100)}...</AgentPrompt>
</CardInfo>
</Container>
)
if (contextMenu) {
return (
<Dropdown
menu={{
items: contextMenu.map((item) => ({
key: item.label,
label: item.label,
onClick: () => item.onClick()
}))
}}
trigger={['contextMenu']}>
{content}
</Dropdown>
)
}
return content
}
export default AgentCard

View File

@@ -0,0 +1,100 @@
import { MenuOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import { Box, HStack } from '@renderer/components/Layout'
import { TopView } from '@renderer/components/TopView'
import { useAgents } from '@renderer/hooks/useAgents'
import { Empty, Modal } from 'antd'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const PopupContainer: React.FC = () => {
const [open, setOpen] = useState(true)
const { t } = useTranslation()
const { agents, updateAgents } = useAgents()
const onOk = () => {
setOpen(false)
}
const onCancel = () => {
setOpen(false)
}
const onClose = async () => {
ManageAgentsPopup.hide()
}
useEffect(() => {
if (agents.length === 0) {
setOpen(false)
}
}, [agents])
return (
<Modal
title={t('agents.manage.title')}
open={open}
onOk={onOk}
onCancel={onCancel}
afterClose={onClose}
footer={null}
centered>
<Container>
{agents.length > 0 && (
<DragableList list={agents} onUpdate={updateAgents}>
{(item) => (
<AgentItem>
<Box mr={8}>
{item.emoji} {item.name}
</Box>
<HStack gap="15px">
<MenuOutlined style={{ cursor: 'move' }} />
</HStack>
</AgentItem>
)}
</DragableList>
)}
{agents.length === 0 && <Empty description="" />}
</Container>
</Modal>
)
}
const Container = styled.div`
padding: 12px 0;
height: 50vh;
overflow-y: auto;
&::-webkit-scrollbar {
display: none;
}
`
const AgentItem = styled.div`
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: 8px;
user-select: none;
background-color: var(--color-background-soft);
margin-bottom: 8px;
.anticon {
font-size: 16px;
color: var(--color-icon);
}
&:hover {
background-color: var(--color-background-mute);
}
`
export default class ManageAgentsPopup {
static topviewId = 0
static hide() {
TopView.hide('ManageAgentsPopup')
}
static show() {
TopView.show(<PopupContainer />, 'ManageAgentsPopup')
}
}

View File

@@ -0,0 +1,109 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SortAscendingOutlined } from '@ant-design/icons'
import AssistantSettingsPopup from '@renderer/components/AssistantSettings'
import { useAgents } from '@renderer/hooks/useAgents'
import { createAssistantFromAgent } from '@renderer/services/AssistantService'
import { Agent } from '@renderer/types'
import { Col } from 'antd'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AddAgentCard from './AddAgentCard'
import AddAgentPopup from './AddAgentPopup'
import AgentCard from './AgentCard'
import ManageAgentsPopup from './ManageAgentsPopup'
interface Props {
onClick?: (agent: Agent) => void
search?: string
}
const MyAgents: React.FC<Props> = ({ onClick, search }) => {
const { t } = useTranslation()
const { agents, removeAgent } = useAgents()
const filteredAgents = useMemo(() => {
if (!search?.trim()) return agents
return agents.filter(
(agent) =>
agent.name.toLowerCase().includes(search.toLowerCase()) ||
agent.description?.toLowerCase().includes(search.toLowerCase())
)
}, [agents, search])
const handleDelete = useCallback(
(agent: Agent) => {
window.modal.confirm({
centered: true,
content: t('agents.delete.popup.content'),
onOk: () => removeAgent(agent.id)
})
},
[removeAgent, t]
)
return (
<>
{filteredAgents.map((agent) => {
const dropdownMenuItems = [
{
key: 'edit',
label: t('agents.edit.title'),
icon: <EditOutlined />,
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
},
{
key: 'create',
label: t('agents.add.button'),
icon: <PlusOutlined />,
onClick: () => createAssistantFromAgent(agent)
},
{
key: 'sort',
label: t('agents.sorting.title'),
icon: <SortAscendingOutlined />,
onClick: () => ManageAgentsPopup.show()
},
{
key: 'delete',
label: t('common.delete'),
icon: <DeleteOutlined />,
danger: true,
onClick: () => handleDelete(agent)
}
]
const contextMenuItems = [
{
label: t('agents.edit.title'),
onClick: () => AssistantSettingsPopup.show({ assistant: agent })
},
{
label: t('agents.add.button'),
onClick: () => createAssistantFromAgent(agent)
},
{
label: t('common.delete'),
onClick: () => handleDelete(agent)
}
]
return (
<Col span={6} key={agent.id}>
<AgentCard
agent={agent}
onClick={() => onClick?.(agent)}
contextMenu={contextMenuItems}
menuItems={dropdownMenuItems}
/>
</Col>
)
})}
<Col span={6}>
<AddAgentCard onClick={() => AddAgentPopup.show()} />
</Col>
</>
)
}
export default MyAgents

View File

@@ -1,3 +1,4 @@
import { SearchOutlined } from '@ant-design/icons'
import { VStack } from '@renderer/components/Layout'
import { useAssistants } from '@renderer/hooks/useAssistant'
import useScrollPosition from '@renderer/hooks/useScrollPosition'
@@ -35,7 +36,7 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
<ListContainer {...props}>
<VStack alignItems="center">
<Empty description={t('history.search.topics.empty')} />
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch}>
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
{t('history.search.messages')}
</Button>
</VStack>
@@ -63,6 +64,13 @@ const TopicsHistory: React.FC<Props> = ({ keywords, onClick, onSearch, ...props
))}
</ListItem>
))}
{keywords.length >= 2 && (
<div style={{ display: 'flex', justifyContent: 'center', width: '100%' }}>
<Button style={{ width: 200, marginTop: 20 }} type="primary" onClick={onSearch} icon={<SearchOutlined />}>
{t('history.search.messages')}
</Button>
</div>
)}
<div style={{ minHeight: 30 }}></div>
</ContainerWrapper>
</ListContainer>

View File

@@ -19,11 +19,11 @@ interface Props {
const Chat: FC<Props> = (props) => {
const { assistant } = useAssistant(props.assistant.id)
const { topicPosition } = useSettings()
const { topicPosition, messageStyle } = useSettings()
const { showTopics } = useShowTopics()
return (
<Container id="chat">
<Container id="chat" className={messageStyle}>
<Main vertical flex={1} justify="space-between">
<Messages
key={props.activeTopic.id}
@@ -52,7 +52,35 @@ const Container = styled.div`
height: 100%;
flex: 1;
justify-content: space-between;
background-color: var(--color-background);
&.bubble {
background-color: var(--chat-background);
.system-prompt {
background-color: var(--chat-background-assistant);
}
.message-content-container {
margin: 5px 0;
border-radius: 8px;
padding: 10px 15px 0 15px;
}
.message-user {
.markdown,
.anticon,
.iconfont,
.message-tokens {
color: var(--chat-text-user);
}
.message-action-button:hover {
background-color: var(--color-white-soft);
}
}
#inputbar {
border-radius: 0;
margin: 0;
border: none;
border-top: 1px solid var(--color-border-mute);
background: var(--color-background);
}
}
`
const Main = styled(Flex)`

View File

@@ -35,8 +35,9 @@ const Container = styled.div`
display: flex;
flex-direction: row;
gap: 10px;
margin: 10px 20px;
margin-right: 0;
padding: 10px 20px;
background: var(--color-background);
border-top: 1px solid var(--color-border-mute);
`
export default AttachmentPreview

View File

@@ -12,7 +12,7 @@ import { isVisionModel } from '@renderer/config/models'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useSettings } from '@renderer/hooks/useSettings'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore'
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@@ -58,6 +58,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
const containerRef = useRef(null)
const { showTopics, toggleShowTopics } = useShowTopics()
const { searching } = useRuntime()
const { isBubbleStyle } = useMessageStyle()
const dispatch = useAppDispatch()
const isVision = useMemo(() => isVisionModel(model), [model])
@@ -299,7 +300,7 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
autoFocus
contextMenu="true"
variant="borderless"
rows={1}
rows={isBubbleStyle ? 2 : 1}
ref={textareaRef}
style={{ fontSize }}
styles={{ textarea: TextareaStyle }}
@@ -375,11 +376,6 @@ const Container = styled.div`
flex-direction: column;
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '10px 15px 8px'
}
const InputBarContainer = styled.div`
border: 1px solid var(--color-border-soft);
transition: all 0.3s ease;
@@ -388,6 +384,11 @@ const InputBarContainer = styled.div`
border-radius: 10px;
`
const TextareaStyle: CSSProperties = {
paddingLeft: 0,
padding: '10px 15px 8px'
}
const Textarea = styled(TextArea)`
padding: 0;
border-radius: 0;

View File

@@ -2,12 +2,12 @@ import { FONT_FAMILY } from '@renderer/config/constant'
import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useModel } from '@renderer/hooks/useModel'
import { useSettings } from '@renderer/hooks/useSettings'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { fetchChatCompletion } from '@renderer/services/ApiService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { estimateMessageUsage } from '@renderer/services/TokenService'
import { Message, Topic } from '@renderer/types'
import { runAsyncFunction } from '@renderer/utils'
import { classNames, runAsyncFunction } from '@renderer/utils'
import { Divider } from 'antd'
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -16,7 +16,7 @@ import styled from 'styled-components'
import MessageContent from './MessageContent'
import MessageHeader from './MessageHeader'
import MessageMenubar from './MessageMenubar'
import MessgeTokens from './MessageTokens'
import MessageTokens from './MessageTokens'
interface Props {
message: Message
@@ -42,11 +42,13 @@ const MessageItem: FC<Props> = ({
const { t } = useTranslation()
const { assistant, setModel } = useAssistant(message.assistantId)
const model = useModel(message.modelId)
const { isBubbleStyle } = useMessageStyle()
const { showMessageDivider, messageFont, fontSize } = useSettings()
const messageContainerRef = useRef<HTMLDivElement>(null)
const isLastMessage = index === 0
const isAssistantMessage = message.role === 'assistant'
const showMenubar = !message.status.includes('ing')
const fontFamily = useMemo(() => {
@@ -54,6 +56,11 @@ const MessageItem: FC<Props> = ({
}, [messageFont])
const messageBorder = showMessageDivider ? undefined : 'none'
const messageBackground = isBubbleStyle
? isAssistantMessage
? 'var(--chat-background-assistant)'
: 'var(--chat-background-user)'
: undefined
const onEditMessage = useCallback(
(msg: Message) => {
@@ -131,13 +138,27 @@ const MessageItem: FC<Props> = ({
}
return (
<MessageContainer key={message.id} className="message" ref={messageContainerRef}>
<MessageContainer
key={message.id}
className={classNames({
message: true,
'message-assistant': isAssistantMessage,
'message-user': !isAssistantMessage
})}
ref={messageContainerRef}
style={isBubbleStyle ? { alignItems: isAssistantMessage ? 'start' : 'end' } : undefined}>
<MessageHeader message={message} assistant={assistant} model={model} />
<MessageContentContainer style={{ fontFamily, fontSize }}>
<MessageContentContainer
className="message-content-container"
style={{ fontFamily, fontSize, background: messageBackground }}>
<MessageContent message={message} model={model} />
{showMenubar && (
<MessageFooter style={{ border: messageBorder, flexDirection: isLastMessage ? 'row-reverse' : undefined }}>
<MessgeTokens message={message} isLastMessage={isLastMessage} />
<MessageFooter
style={{
border: messageBorder,
flexDirection: isLastMessage || isBubbleStyle ? 'row-reverse' : undefined
}}>
<MessageTokens message={message} isLastMessage={isLastMessage} />
<MessageMenubar
message={message}
model={model}
@@ -179,6 +200,7 @@ const MessageContainer = styled.div`
`
const MessageContentContainer = styled.div`
max-width: 100%;
display: flex;
flex: 1;
flex-direction: column;
@@ -195,6 +217,7 @@ const MessageFooter = styled.div`
padding: 2px 0;
margin-top: 2px;
border-top: 0.5px dashed var(--color-border);
gap: 20px;
`
export default memo(MessageItem)

View File

@@ -42,6 +42,8 @@ const MessageContentLoading = styled.div`
flex-direction: row;
align-items: center;
height: 32px;
margin-top: -5px;
margin-bottom: 5px;
`
export default React.memo(MessageContent)

View File

@@ -4,12 +4,12 @@ import { startMinAppById } from '@renderer/config/minapps'
import { getModelLogo } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useSettings } from '@renderer/hooks/useSettings'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { Assistant, Message, Model } from '@renderer/types'
import { firstLetter, removeLeadingEmoji } from '@renderer/utils'
import { Avatar } from 'antd'
import dayjs from 'dayjs'
import { FC, useCallback, useMemo } from 'react'
import { CSSProperties, FC, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@@ -24,8 +24,7 @@ const MessageHeader: FC<Props> = ({ assistant, model, message }) => {
const { theme } = useTheme()
const { userName } = useSettings()
const { t } = useTranslation()
const isAssistantMessage = message.role === 'assistant'
const { isBubbleStyle } = useMessageStyle()
const avatarSource = useMemo(() => {
if (isLocalAi) return AppLogo
@@ -39,15 +38,23 @@ const MessageHeader: FC<Props> = ({ assistant, model, message }) => {
return userName || t('common.you')
}, [message.role, model?.id, model?.name, t, userName])
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
const isAssistantMessage = message.role === 'assistant'
const avatarName = useMemo(() => firstLetter(assistant?.name).toUpperCase(), [assistant?.name])
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
const showMiniApp = () => model?.provider && startMinAppById(model?.provider)
const avatarStyle: CSSProperties | undefined = isBubbleStyle
? {
flexDirection: isAssistantMessage ? 'row' : 'row-reverse',
textAlign: isAssistantMessage ? 'left' : 'right'
}
: undefined
return (
<Container>
<AvatarWrapper>
<AvatarWrapper style={avatarStyle}>
{isAssistantMessage ? (
<Avatar
src={avatarSource}
@@ -79,25 +86,23 @@ const MessageHeader: FC<Props> = ({ assistant, model, message }) => {
}
const Container = styled.div`
margin-right: 10px;
display: flex;
flex-direction: row;
align-items: center;
padding-bottom: 4px;
justify-content: space-between;
`
const AvatarWrapper = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
`
const UserWrap = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
margin-left: 12px;
`
const UserName = styled.div`

View File

@@ -96,27 +96,27 @@ const MessageMenubar: FC<Props> = (props) => {
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
{message.role === 'user' && (
<Tooltip title="Edit" mouseEnterDelay={0.8}>
<ActionButton onClick={onEdit}>
<ActionButton className="message-action-button" onClick={onEdit}>
<EditOutlined />
</ActionButton>
</Tooltip>
)}
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
<ActionButton onClick={onCopy}>
<ActionButton className="message-action-button" onClick={onCopy}>
{!copied && <i className="iconfont icon-copy"></i>}
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
</ActionButton>
</Tooltip>
{canRegenerate && (
<Tooltip title={t('common.regenerate')} mouseEnterDelay={0.8}>
<ActionButton onClick={onSelectModel}>
<ActionButton className="message-action-button" onClick={onSelectModel}>
<SyncOutlined />
</ActionButton>
</Tooltip>
)}
{isAssistantMessage && (
<Tooltip title={t('chat.message.new.branch')} mouseEnterDelay={0.8}>
<ActionButton onClick={onNewBranch}>
<ActionButton className="message-action-button" onClick={onNewBranch}>
<ForkOutlined />
</ActionButton>
</Tooltip>
@@ -127,14 +127,14 @@ const MessageMenubar: FC<Props> = (props) => {
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
onConfirm={() => onDeleteMessage?.(message)}>
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
<ActionButton>
<ActionButton className="message-action-button">
<DeleteOutlined />
</ActionButton>
</Tooltip>
</Popconfirm>
{!isUserMessage && (
<Dropdown menu={{ items: dropdownItems }} trigger={['click']} placement="topRight" arrow>
<ActionButton>
<ActionButton className="message-action-button">
<MenuOutlined />
</ActionButton>
</Dropdown>

View File

@@ -15,7 +15,11 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({
}
if (message.role === 'user') {
return <MessageMetadata onClick={locateMessage}>Tokens: {message?.usage?.total_tokens}</MessageMetadata>
return (
<MessageMetadata className="message-tokens" onClick={locateMessage}>
Tokens: {message?.usage?.total_tokens}
</MessageMetadata>
)
}
if (isLastMessage && generating) {
@@ -24,7 +28,7 @@ const MessgeTokens: React.FC<{ message: Message; isLastMessage: boolean }> = ({
if (message.role === 'assistant') {
return (
<MessageMetadata onClick={locateMessage}>
<MessageMetadata className="message-tokens" onClick={locateMessage}>
Tokens: {message?.usage?.total_tokens} | {message?.usage?.prompt_tokens} | {message?.usage?.completion_tokens}
</MessageMetadata>
)

View File

@@ -217,7 +217,6 @@ const Container = styled(Scrollbar)`
display: flex;
flex-direction: column-reverse;
padding: 10px 0;
background-color: var(--color-background);
padding-bottom: 20px;
overflow-x: hidden;
`

View File

@@ -18,7 +18,7 @@ const Prompt: FC<Props> = ({ assistant }) => {
}
return (
<Container onClick={() => AssistantSettingsPopup.show({ assistant })}>
<Container className="system-prompt" onClick={() => AssistantSettingsPopup.show({ assistant })}>
<Text>{prompt}</Text>
</Container>
)

View File

@@ -3,13 +3,11 @@ import { Navbar, NavbarLeft, NavbarRight } from '@renderer/components/app/Navbar
import AssistantSettingsPopup from '@renderer/components/AssistantSettings'
import { HStack } from '@renderer/components/Layout'
import { isMac, isWindows } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShowAssistants, useShowTopics } from '@renderer/hooks/useStore'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Assistant, Topic } from '@renderer/types'
import { Switch } from 'antd'
import { FC, useCallback } from 'react'
import styled from 'styled-components'
@@ -24,7 +22,6 @@ interface Props {
const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
const { assistant } = useAssistant(activeAssistant.id)
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { theme, toggleTheme } = useTheme()
const { topicPosition } = useSettings()
const { showTopics, toggleShowTopics } = useShowTopics()
@@ -63,12 +60,6 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant }) => {
<SelectModelButton assistant={assistant} />
</HStack>
<HStack alignItems="center">
<ThemeSwitch
checkedChildren={<i className="iconfont icon-theme icon-dark1" />}
unCheckedChildren={<i className="iconfont icon-theme icon-theme-light" />}
checked={theme === 'dark'}
onChange={toggleTheme}
/>
{topicPosition === 'right' && (
<NewButton onClick={toggleShowTopics}>
<i className={`iconfont icon-${showTopics ? 'show' : 'hide'}-sidebar`} />
@@ -118,12 +109,4 @@ const TitleText = styled.span`
font-weight: 500;
`
const ThemeSwitch = styled(Switch)`
-webkit-app-region: no-drag;
margin-right: 10px;
.icon-theme {
font-size: 14px;
}
`
export default HeaderNavbar

View File

@@ -11,6 +11,7 @@ import {
setFontSize,
setMathEngine,
setMessageFont,
setMessageStyle,
setPasteLongTextAsFile,
setRenderInputMessageAsMarkdown,
setShowInputEstimatedTokens,
@@ -35,6 +36,7 @@ const SettingsTab: FC<Props> = (props) => {
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
const { messageStyle } = useSettings()
const { t } = useTranslation()
const dispatch = useAppDispatch()
@@ -214,6 +216,18 @@ const SettingsTab: FC<Props> = (props) => {
/>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('message.message.style')}</SettingRowTitleSmall>
<Select
value={messageStyle}
onChange={(value) => dispatch(setMessageStyle(value))}
style={{ width: 100 }}
size="small">
<Select.Option value="plain">{t('message.message.style.plain')}</Select.Option>
<Select.Option value="bubble">{t('message.message.style.bubble')}</Select.Option>
</Select>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.math_engine')}</SettingRowTitleSmall>
<Select

View File

@@ -17,7 +17,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import store, { useAppSelector } from '@renderer/store'
import { setGenerating } from '@renderer/store/runtime'
import { Assistant, Topic } from '@renderer/types'
import { exportTopicAsMarkdown } from '@renderer/utils/export'
import { exportTopicAsMarkdown, topicToMarkdown } from '@renderer/utils/export'
import { Dropdown, MenuProps } from 'antd'
import dayjs from 'dayjs'
import { findIndex } from 'lodash'
@@ -141,6 +141,14 @@ const Topics: FC<Props> = ({ assistant: _assistant, activeTopic, setActiveTopic
label: t('chat.topics.export.md'),
key: 'markdown',
onClick: () => exportTopicAsMarkdown(topic)
},
{
label: t('chat.topics.export.word'),
key: 'word',
onClick: async () => {
const markdown = await topicToMarkdown(topic)
window.api.export.toWord(markdown, topic.name)
}
}
]
}

View File

@@ -150,6 +150,7 @@ const Container = styled.div`
max-width: var(--assistants-width);
min-width: var(--assistants-width);
height: calc(100vh - var(--navbar-height));
background-color: var(--color-background);
overflow: hidden;
.collapsed {
width: 0;

View File

@@ -1,5 +1,6 @@
import { DeleteOutlined } from '@ant-design/icons'
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import DragableList from '@renderer/components/DragableList'
import Scrollbar from '@renderer/components/Scrollbar'
import { usePaintings } from '@renderer/hooks/usePaintings'
import FileManager from '@renderer/services/FileManager'
import { Painting } from '@renderer/types'
@@ -14,15 +15,22 @@ interface PaintingsListProps {
selectedPainting: Painting
onSelectPainting: (painting: Painting) => void
onDeletePainting: (painting: Painting) => void
onNewPainting: () => void
}
const PaintingsList: FC<PaintingsListProps> = ({ paintings, selectedPainting, onSelectPainting, onDeletePainting }) => {
const PaintingsList: FC<PaintingsListProps> = ({
paintings,
selectedPainting,
onSelectPainting,
onDeletePainting,
onNewPainting
}) => {
const { t } = useTranslation()
const [dragging, setDragging] = useState(false)
const { updatePaintings } = usePaintings()
return (
<Container style={{ paddingBottom: dragging ? 30 : 0 }}>
<Container style={{ paddingBottom: dragging ? 80 : 10 }}>
<DragableList
list={paintings}
onUpdate={updatePaintings}
@@ -47,21 +55,27 @@ const PaintingsList: FC<PaintingsListProps> = ({ paintings, selectedPainting, on
</CanvasWrapper>
)}
</DragableList>
{!dragging && (
<NewPaintingButton onClick={onNewPainting}>
<PlusOutlined />
</NewPaintingButton>
)}
</Container>
)
}
const Container = styled.div`
const Container = styled(Scrollbar)`
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
height: 100%;
gap: 10px;
padding: 10px 0;
padding: 10px;
background-color: var(--color-background);
max-width: 100px;
border-left: 0.5px solid var(--color-border);
height: calc(100vh - var(--navbar-height));
overflow-x: hidden;
`
const CanvasWrapper = styled.div`
@@ -116,4 +130,25 @@ const DeleteButton = styled.div.attrs({ className: 'delete-button' })`
justify-content: center;
`
const NewPaintingButton = styled.div`
width: 80px;
height: 80px;
min-height: 80px;
margin-top: -10px;
background-color: var(--color-background-soft);
cursor: pointer;
transition: background-color 0.2s ease;
border: 1px dashed var(--color-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-2);
&:hover {
background-color: var(--color-background-mute);
border-color: var(--color-primary);
color: var(--color-primary);
}
`
export default PaintingsList

View File

@@ -9,6 +9,7 @@ import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navb
import { VStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
import TranslateButton from '@renderer/components/TranslateButton'
import { isMac } from '@renderer/config/constant'
import { TEXT_TO_IMAGES_MODELS } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import { usePaintings } from '@renderer/hooks/usePaintings'
@@ -252,11 +253,13 @@ const PaintingsPage: FC = () => {
<Container>
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('images.title')}</NavbarCenter>
<NavbarRight style={{ justifyContent: 'flex-end' }}>
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={() => setPainting(addPainting())}>
{t('images.button.new.image')}
</Button>
</NavbarRight>
{isMac && (
<NavbarRight style={{ justifyContent: 'flex-end' }}>
<Button size="small" className="nodrag" icon={<PlusOutlined />} onClick={() => setPainting(addPainting())}>
{t('images.button.new.image')}
</Button>
</NavbarRight>
)}
</Navbar>
<ContentContainer id="content-container">
<LeftContainer>
@@ -379,7 +382,7 @@ const PaintingsPage: FC = () => {
text={textareaRef.current?.resizableTextArea?.textArea?.value}
onTranslated={handleTranslation}
disabled={isLoading}
style={{ marginRight: 6 }}
style={{ marginRight: 6, borderRadius: '50%' }}
/>
<SendMessageButton sendMessage={onGenerate} disabled={isLoading} />
</ToolbarMenu>
@@ -391,6 +394,7 @@ const PaintingsPage: FC = () => {
selectedPainting={painting}
onSelectPainting={onSelectPainting}
onDeletePainting={onDeletePainting}
onNewPainting={() => setPainting(addPainting())}
/>
</ContentContainer>
</Container>
@@ -435,13 +439,11 @@ const MainContainer = styled.div`
const InputContainer = styled.div`
display: flex;
flex-direction: column;
min-height: 100px;
max-height: 100px;
min-height: 95px;
max-height: 95px;
position: relative;
border: 1px solid var(--color-border-soft);
border-top: 1px solid var(--color-border-soft);
transition: all 0.3s ease;
margin: 0 20px 15px 20px;
border-radius: 10px;
`
const Textarea = styled(TextArea)`
@@ -462,7 +464,7 @@ const Toolbar = styled.div`
justify-content: flex-end;
padding: 0 8px;
padding-bottom: 0;
height: 36px;
height: 40px;
`
const ToolbarMenu = styled.div`

View File

@@ -26,7 +26,7 @@ export async function restore() {
const restoreData = await window.api.backup.restore(file.filePath)
data = JSON.parse(restoreData)
} else {
data = JSON.parse(await window.api.decompress(file.content))
data = JSON.parse(await window.api.zip.decompress(file.content))
}
await handleData(data)

View File

@@ -9,7 +9,8 @@ export const getModelUniqId = (m?: Model) => {
export const hasModel = (m?: Model) => {
const allModels = store
.getState()
.llm.providers.map((p) => p.models)
.llm.providers.filter((p) => p.enabled)
.map((p) => p.models)
.flat()
return allModels.find((model) => model.id === m?.id)

View File

@@ -24,7 +24,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 35,
version: 37,
blacklist: ['runtime'],
migrate
},

View File

@@ -614,6 +614,14 @@ const migrateConfig = {
'35': (state: RootState) => {
state.settings.mathEngine = 'KaTeX'
return state
},
'36': (state: RootState) => {
state.settings.topicPosition = 'left'
return state
},
'37': (state: RootState) => {
state.settings.messageStyle = 'plain'
return state
}
}

View File

@@ -24,6 +24,7 @@ export interface SettingsState {
renderInputMessageAsMarkdown: boolean
codeShowLineNumbers: boolean
mathEngine: 'MathJax' | 'KaTeX'
messageStyle: 'plain' | 'bubble'
// webdav 配置 host, user, pass, path
webdavHost: string
webdavUser: string
@@ -44,7 +45,7 @@ const initialState: SettingsState = {
theme: ThemeMode.auto,
windowStyle: 'transparent',
fontSize: 14,
topicPosition: 'right',
topicPosition: 'left',
showTopicTime: false,
pasteLongTextAsFile: false,
clickAssistantToShowTopic: false,
@@ -52,6 +53,7 @@ const initialState: SettingsState = {
renderInputMessageAsMarkdown: true,
codeShowLineNumbers: false,
mathEngine: 'MathJax',
messageStyle: 'plain',
webdavHost: '',
webdavUser: '',
webdavPass: '',
@@ -140,6 +142,9 @@ const settingsSlice = createSlice({
},
setMathEngine: (state, action: PayloadAction<'MathJax' | 'KaTeX'>) => {
state.mathEngine = action.payload
},
setMessageStyle: (state, action: PayloadAction<'plain' | 'bubble'>) => {
state.messageStyle = action.payload
}
}
})
@@ -170,7 +175,8 @@ export const {
setWebdavPass,
setWebdavPath,
setCodeShowLineNumbers,
setMathEngine
setMathEngine,
setMessageStyle
} = settingsSlice.actions
export default settingsSlice.reducer

View File

@@ -1,24 +1,31 @@
import db from '@renderer/databases'
import { Message, Topic } from '@renderer/types'
export const exportMessageAsMarkdown = (message: Message) => {
if (message.role === 'user') {
return `### User\n\n${message.content}`
}
export const messageToMarkdown = (message: Message) => {
const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant'
const titleSection = `### ${roleText}`
const contentSection = message.content
return `### Assistant\n\n${message.content}`
return [titleSection, '', contentSection].join('\n')
}
export const exportMessagesAsMarkdown = (messages: Message[]) => {
return messages.map((message) => exportMessageAsMarkdown(message)).join('\n\n---\n\n')
export const messagesToMarkdown = (messages: Message[]) => {
return messages.map((message) => messageToMarkdown(message)).join('\n\n---\n\n')
}
export const topicToMarkdown = async (topic: Topic) => {
const topicName = `# ${topic.name}`
const topicMessages = await db.topics.get(topic.id)
if (topicMessages) {
return topicName + '\n\n' + messagesToMarkdown(topicMessages.messages)
}
return ''
}
export const exportTopicAsMarkdown = async (topic: Topic) => {
const fileName = topic.name + '.md'
const topicMessages = await db.topics.get(topic.id)
if (topicMessages) {
const title = '# ' + topic.name + `\n\n`
const markdown = exportMessagesAsMarkdown(topicMessages.messages)
window.api.file.save(fileName, title + markdown)
}
const markdown = await topicToMarkdown(topic)
window.api.file.save(fileName, markdown)
}

View File

@@ -4,6 +4,8 @@ import html2canvas from 'html2canvas'
// @ts-ignore next-line`
import { v4 as uuidv4 } from 'uuid'
import { classNames } from './style'
export const runAsyncFunction = async (fn: () => void) => {
await fn()
}
@@ -330,10 +332,6 @@ export function formatFileSize(file: FileType) {
return (size / 1024).toFixed(2) + ' KB'
}
export function classNames(...classes: Array<string | boolean | undefined | null>) {
return classes.filter(Boolean).join(' ')
}
export function sortByEnglishFirst(a: string, b: string) {
const isAEnglish = /^[a-zA-Z]/.test(a)
const isBEnglish = /^[a-zA-Z]/.test(b)
@@ -341,3 +339,5 @@ export function sortByEnglishFirst(a: string, b: string) {
if (!isAEnglish && isBEnglish) return 1
return a.localeCompare(b)
}
export { classNames }

View File

@@ -0,0 +1,45 @@
type ClassValue = string | number | boolean | undefined | null | ClassDictionary | ClassArray
interface ClassDictionary {
[id: string]: any
}
interface ClassArray extends Array<ClassValue> {}
// Example:
// classNames('foo', 'bar'); // => 'foo bar'
// classNames('foo', { bar: true }); // => 'foo bar'
// classNames({ foo: true, bar: false }); // => 'foo'
// classNames(['foo', 'bar']); // => 'foo bar'
// classNames('foo', null, 'bar'); // => 'foo bar'
// classNames({ message: true, 'message-assistant': true }); // => 'message message-assistant'
/**
* 生成 class 字符串
* @param args
* @returns
*/
export function classNames(...args: ClassValue[]): string {
const classes: string[] = []
args.forEach((arg) => {
if (!arg) return
if (typeof arg === 'string' || typeof arg === 'number') {
classes.push(arg.toString())
} else if (Array.isArray(arg)) {
const inner = classNames(...arg)
if (inner) {
classes.push(inner)
}
} else if (typeof arg === 'object') {
Object.entries(arg).forEach(([key, value]) => {
if (value) {
classes.push(key)
}
})
}
})
return classes.filter(Boolean).join(' ')
}

144
yarn.lock
View File

@@ -1910,6 +1910,13 @@ __metadata:
languageName: node
linkType: hard
"@types/linkify-it@npm:^5":
version: 5.0.0
resolution: "@types/linkify-it@npm:5.0.0"
checksum: 10c0/7bbbf45b9dde17bf3f184fee585aef0e7342f6954f0377a24e4ff42ab5a85d5b806aaa5c8d16e2faf2a6b87b2d94467a196b7d2b85c9c7de2f0eaac5487aaab8
languageName: node
linkType: hard
"@types/lodash@npm:^4.17.5":
version: 4.17.7
resolution: "@types/lodash@npm:4.17.7"
@@ -1917,6 +1924,16 @@ __metadata:
languageName: node
linkType: hard
"@types/markdown-it@npm:^14":
version: 14.1.2
resolution: "@types/markdown-it@npm:14.1.2"
dependencies:
"@types/linkify-it": "npm:^5"
"@types/mdurl": "npm:^2"
checksum: 10c0/34f709f0476bd4e7b2ba7c3341072a6d532f1f4cb6f70aef371e403af8a08a7c372ba6907ac426bc618d356dab660c5b872791ff6c1ead80c483e0d639c6f127
languageName: node
linkType: hard
"@types/mathjax@npm:^0.0.40":
version: 0.0.40
resolution: "@types/mathjax@npm:0.0.40"
@@ -1933,6 +1950,13 @@ __metadata:
languageName: node
linkType: hard
"@types/mdurl@npm:^2":
version: 2.0.0
resolution: "@types/mdurl@npm:2.0.0"
checksum: 10c0/cde7bb571630ed1ceb3b92a28f7b59890bb38b8f34cd35326e2df43eebfc74985e6aa6fd4184e307393bad8a9e0783a519a3f9d13c8e03788c0f98e5ec869c5e
languageName: node
linkType: hard
"@types/minimatch@npm:*":
version: 5.1.2
resolution: "@types/minimatch@npm:5.1.2"
@@ -1982,6 +2006,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^22.7.5":
version: 22.8.6
resolution: "@types/node@npm:22.8.6"
dependencies:
undici-types: "npm:~6.19.8"
checksum: 10c0/d3a11f2549234a91a4c5d0ff35ab4bdcb7ba34db4d3f1d189be39b8bd41c19aac98d117150a95a9c5a9d45b1014135477ea240b2b8317c86ae3d3cf1c3b3f8f4
languageName: node
linkType: hard
"@types/plist@npm:^3.0.1":
version: 3.0.5
resolution: "@types/plist@npm:3.0.5"
@@ -2269,6 +2302,7 @@ __metadata:
"@reduxjs/toolkit": "npm:^2.2.5"
"@types/fs-extra": "npm:^11"
"@types/lodash": "npm:^4.17.5"
"@types/markdown-it": "npm:^14"
"@types/node": "npm:^18.19.9"
"@types/react": "npm:^18.2.48"
"@types/react-dom": "npm:^18.2.18"
@@ -2282,6 +2316,7 @@ __metadata:
dayjs: "npm:^1.11.11"
dexie: "npm:^4.0.8"
dexie-react-hooks: "npm:^1.1.7"
docx: "npm:^9.0.2"
dotenv-cli: "npm:^7.4.2"
electron: "npm:^28.3.3"
electron-builder: "npm:^24.9.1"
@@ -2305,6 +2340,7 @@ __metadata:
i18next: "npm:^23.11.5"
localforage: "npm:^1.10.0"
lodash: "npm:^4.17.21"
markdown-it: "npm:^14.1.0"
mime: "npm:^4.0.4"
officeparser: "npm:^4.1.1"
openai: "npm:^4.52.1"
@@ -4259,6 +4295,20 @@ __metadata:
languageName: node
linkType: hard
"docx@npm:^9.0.2":
version: 9.0.2
resolution: "docx@npm:9.0.2"
dependencies:
"@types/node": "npm:^22.7.5"
hash.js: "npm:^1.1.7"
jszip: "npm:^3.10.1"
nanoid: "npm:^5.0.4"
xml: "npm:^1.0.1"
xml-js: "npm:^1.6.8"
checksum: 10c0/6065391bac9384084dd74f8ff1fb04ac781e8d10d0a0174bb62dc338e75130fac57f6413ff3e6b0f264d61850baddcccbfa7994be036c6fbc113127c4422d342
languageName: node
linkType: hard
"dom-walk@npm:^0.1.0":
version: 0.1.2
resolution: "dom-walk@npm:0.1.2"
@@ -5906,6 +5956,16 @@ __metadata:
languageName: node
linkType: hard
"hash.js@npm:^1.1.7":
version: 1.1.7
resolution: "hash.js@npm:1.1.7"
dependencies:
inherits: "npm:^2.0.3"
minimalistic-assert: "npm:^1.0.1"
checksum: 10c0/41ada59494eac5332cfc1ce6b7ebdd7b88a3864a6d6b08a3ea8ef261332ed60f37f10877e0c825aaa4bddebf164fbffa618286aeeec5296675e2671cbfa746c4
languageName: node
linkType: hard
"hasha@npm:^2.2.0":
version: 2.2.0
resolution: "hasha@npm:2.2.0"
@@ -7198,7 +7258,7 @@ __metadata:
languageName: node
linkType: hard
"jszip@npm:^3.1.0":
"jszip@npm:^3.1.0, jszip@npm:^3.10.1":
version: 3.10.1
resolution: "jszip@npm:3.10.1"
dependencies:
@@ -7316,6 +7376,15 @@ __metadata:
languageName: node
linkType: hard
"linkify-it@npm:^5.0.0":
version: 5.0.0
resolution: "linkify-it@npm:5.0.0"
dependencies:
uc.micro: "npm:^2.0.0"
checksum: 10c0/ff4abbcdfa2003472fc3eb4b8e60905ec97718e11e33cca52059919a4c80cc0e0c2a14d23e23d8c00e5402bc5a885cdba8ca053a11483ab3cc8b3c7a52f88e2d
languageName: node
linkType: hard
"load-bmfont@npm:^1.3.1, load-bmfont@npm:^1.4.0":
version: 1.4.2
resolution: "load-bmfont@npm:1.4.2"
@@ -7523,6 +7592,22 @@ __metadata:
languageName: node
linkType: hard
"markdown-it@npm:^14.1.0":
version: 14.1.0
resolution: "markdown-it@npm:14.1.0"
dependencies:
argparse: "npm:^2.0.1"
entities: "npm:^4.4.0"
linkify-it: "npm:^5.0.0"
mdurl: "npm:^2.0.0"
punycode.js: "npm:^2.3.1"
uc.micro: "npm:^2.1.0"
bin:
markdown-it: bin/markdown-it.mjs
checksum: 10c0/9a6bb444181d2db7016a4173ae56a95a62c84d4cbfb6916a399b11d3e6581bf1cc2e4e1d07a2f022ae72c25f56db90fbe1e529fca16fbf9541659dc53480d4b4
languageName: node
linkType: hard
"markdown-table@npm:^3.0.0":
version: 3.0.3
resolution: "markdown-table@npm:3.0.3"
@@ -7793,6 +7878,13 @@ __metadata:
languageName: node
linkType: hard
"mdurl@npm:^2.0.0":
version: 2.0.0
resolution: "mdurl@npm:2.0.0"
checksum: 10c0/633db522272f75ce4788440669137c77540d74a83e9015666a9557a152c02e245b192edc20bc90ae953bbab727503994a53b236b4d9c99bdaee594d0e7dd2ce0
languageName: node
linkType: hard
"memoize-one@npm:^6.0.0":
version: 6.0.0
resolution: "memoize-one@npm:6.0.0"
@@ -8248,6 +8340,13 @@ __metadata:
languageName: node
linkType: hard
"minimalistic-assert@npm:^1.0.1":
version: 1.0.1
resolution: "minimalistic-assert@npm:1.0.1"
checksum: 10c0/96730e5601cd31457f81a296f521eb56036e6f69133c0b18c13fe941109d53ad23a4204d946a0d638d7f3099482a0cec8c9bb6d642604612ce43ee536be3dddd
languageName: node
linkType: hard
"minimatch@npm:9.0.3":
version: 9.0.3
resolution: "minimatch@npm:9.0.3"
@@ -8463,6 +8562,15 @@ __metadata:
languageName: node
linkType: hard
"nanoid@npm:^5.0.4":
version: 5.0.8
resolution: "nanoid@npm:5.0.8"
bin:
nanoid: bin/nanoid.js
checksum: 10c0/0281d3cc0f3d03fb3010b479f28e8ddedfafb9b5d60e54315589ef0a54a0e9cfcb0bf382dd3b620fdad6b72b8c1f5b89a15d55c6b6a9134b58b16eb230b3a3d7
languageName: node
linkType: hard
"napi-build-utils@npm:^1.0.1":
version: 1.0.2
resolution: "napi-build-utils@npm:1.0.2"
@@ -9436,6 +9544,13 @@ __metadata:
languageName: node
linkType: hard
"punycode.js@npm:^2.3.1":
version: 2.3.1
resolution: "punycode.js@npm:2.3.1"
checksum: 10c0/1d12c1c0e06127fa5db56bd7fdf698daf9a78104456a6b67326877afc21feaa821257b171539caedd2f0524027fa38e67b13dd094159c8d70b6d26d2bea4dfdb
languageName: node
linkType: hard
"punycode@npm:^2.1.0, punycode@npm:^2.1.1, punycode@npm:^2.3.1":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
@@ -12036,6 +12151,13 @@ __metadata:
languageName: node
linkType: hard
"uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0":
version: 2.1.0
resolution: "uc.micro@npm:2.1.0"
checksum: 10c0/8862eddb412dda76f15db8ad1c640ccc2f47cdf8252a4a30be908d535602c8d33f9855dfcccb8b8837855c1ce1eaa563f7fa7ebe3c98fd0794351aab9b9c55fa
languageName: node
linkType: hard
"unbox-primitive@npm:^1.0.2":
version: 1.0.2
resolution: "unbox-primitive@npm:1.0.2"
@@ -12065,7 +12187,7 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~6.19.2":
"undici-types@npm:~6.19.2, undici-types@npm:~6.19.8":
version: 6.19.8
resolution: "undici-types@npm:6.19.8"
checksum: 10c0/078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344
@@ -12760,6 +12882,17 @@ __metadata:
languageName: node
linkType: hard
"xml-js@npm:^1.6.8":
version: 1.6.11
resolution: "xml-js@npm:1.6.11"
dependencies:
sax: "npm:^1.2.4"
bin:
xml-js: ./bin/cli.js
checksum: 10c0/c83631057f10bf90ea785cee434a8a1a0030c7314fe737ad9bf568a281083b565b28b14c9e9ba82f11fc9dc582a3a907904956af60beb725be1c9ad4b030bc5a
languageName: node
linkType: hard
"xml-name-validator@npm:^5.0.0":
version: 5.0.0
resolution: "xml-name-validator@npm:5.0.0"
@@ -12784,6 +12917,13 @@ __metadata:
languageName: node
linkType: hard
"xml@npm:^1.0.1":
version: 1.0.1
resolution: "xml@npm:1.0.1"
checksum: 10c0/04bcc9b8b5e7b49392072fbd9c6b0f0958bd8e8f8606fee460318e43991349a68cbc5384038d179ff15aef7d222285f69ca0f067f53d071084eb14c7fdb30411
languageName: node
linkType: hard
"xmlbuilder@npm:>=11.0.1, xmlbuilder@npm:^15.1.1":
version: 15.1.1
resolution: "xmlbuilder@npm:15.1.1"