mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 22:52:08 +08:00
Merge 08dabc6187 into eb7a2cc85a
This commit is contained in:
commit
e3f8a6ac89
@ -33,6 +33,11 @@ export class WindowService {
|
|||||||
private wasMainWindowFocused: boolean = false
|
private wasMainWindowFocused: boolean = false
|
||||||
private lastRendererProcessCrashTime: number = 0
|
private lastRendererProcessCrashTime: number = 0
|
||||||
|
|
||||||
|
// 记录是否是 miniWindow 隐藏时调用了 app.hide()
|
||||||
|
private appHiddenByMiniWindow: boolean = false
|
||||||
|
// 记录当前主窗口 show 是否是为了“恢复 app 给 miniWindow 用”
|
||||||
|
private isRestoringAppForMiniWindow: boolean = false
|
||||||
|
|
||||||
public static getInstance(): WindowService {
|
public static getInstance(): WindowService {
|
||||||
if (!WindowService.instance) {
|
if (!WindowService.instance) {
|
||||||
WindowService.instance = new WindowService()
|
WindowService.instance = new WindowService()
|
||||||
@ -194,28 +199,15 @@ export class WindowService {
|
|||||||
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
|
mainWindow.webContents.send(IpcChannel.FullscreenStatusChanged, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// set the zoom factor again when the window is going to resize
|
|
||||||
//
|
|
||||||
// this is a workaround for the known bug that
|
|
||||||
// the zoom factor is reset to cached value when window is resized after routing to other page
|
|
||||||
// see: https://github.com/electron/electron/issues/10572
|
|
||||||
//
|
|
||||||
// and resize ipc
|
|
||||||
//
|
|
||||||
mainWindow.on('will-resize', () => {
|
mainWindow.on('will-resize', () => {
|
||||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||||
mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize())
|
mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize())
|
||||||
})
|
})
|
||||||
|
|
||||||
// set the zoom factor again when the window is going to restore
|
|
||||||
// minimize and restore will cause zoom reset
|
|
||||||
mainWindow.on('restore', () => {
|
mainWindow.on('restore', () => {
|
||||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||||
})
|
})
|
||||||
|
|
||||||
// ARCH: as `will-resize` is only for Win & Mac,
|
|
||||||
// linux has the same problem, use `resize` listener instead
|
|
||||||
// but `resize` will fliker the ui
|
|
||||||
if (isLinux) {
|
if (isLinux) {
|
||||||
mainWindow.on('resize', () => {
|
mainWindow.on('resize', () => {
|
||||||
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
mainWindow.webContents.setZoomFactor(configManager.getZoomFactor())
|
||||||
@ -231,27 +223,7 @@ export class WindowService {
|
|||||||
mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize())
|
mainWindow.webContents.send(IpcChannel.Windows_Resize, mainWindow.getSize())
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加Escape键退出全屏的支持
|
// Escape 处理全屏的逻辑已注释
|
||||||
// mainWindow.webContents.on('before-input-event', (event, input) => {
|
|
||||||
// // 当按下Escape键且窗口处于全屏状态时退出全屏
|
|
||||||
// if (input.key === 'Escape' && !input.alt && !input.control && !input.meta && !input.shift) {
|
|
||||||
// if (mainWindow.isFullScreen()) {
|
|
||||||
// // 获取 shortcuts 配置
|
|
||||||
// const shortcuts = configManager.getShortcuts()
|
|
||||||
// const exitFullscreenShortcut = shortcuts.find((s) => s.key === 'exit_fullscreen')
|
|
||||||
// if (exitFullscreenShortcut == undefined) {
|
|
||||||
// mainWindow.setFullScreen(false)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// if (exitFullscreenShortcut?.enabled) {
|
|
||||||
// event.preventDefault()
|
|
||||||
// mainWindow.setFullScreen(false)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return
|
|
||||||
// })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
|
private setupWebContentsHandlers(mainWindow: BrowserWindow) {
|
||||||
@ -294,7 +266,9 @@ export class WindowService {
|
|||||||
const fileName = url.replace('http://file/', '')
|
const fileName = url.replace('http://file/', '')
|
||||||
const storageDir = getFilesDir()
|
const storageDir = getFilesDir()
|
||||||
const filePath = storageDir + '/' + fileName
|
const filePath = storageDir + '/' + fileName
|
||||||
shell.openPath(filePath).catch((err) => logger.error('Failed to open file:', err))
|
shell
|
||||||
|
.openPath(filePath)
|
||||||
|
.catch((err) => logger.error('Failed to open file:', err))
|
||||||
} else {
|
} else {
|
||||||
shell.openExternal(details.url)
|
shell.openExternal(details.url)
|
||||||
}
|
}
|
||||||
@ -356,8 +330,6 @@ export class WindowService {
|
|||||||
|
|
||||||
// 没有开启托盘,或者开启了托盘,但设置了直接关闭,应执行直接退出
|
// 没有开启托盘,或者开启了托盘,但设置了直接关闭,应执行直接退出
|
||||||
if (!isShowTray || (isShowTray && !isTrayOnClose)) {
|
if (!isShowTray || (isShowTray && !isTrayOnClose)) {
|
||||||
// 如果是Windows或Linux,直接退出
|
|
||||||
// mac按照系统默认行为,不退出
|
|
||||||
if (isWin || isLinux) {
|
if (isWin || isLinux) {
|
||||||
return app.quit()
|
return app.quit()
|
||||||
}
|
}
|
||||||
@ -392,6 +364,18 @@ export class WindowService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.on('show', () => {
|
mainWindow.on('show', () => {
|
||||||
|
// 无论什么原因 show,说明 app 已经不再是“被 miniWindow 隐藏”的状态
|
||||||
|
this.appHiddenByMiniWindow = false
|
||||||
|
|
||||||
|
// 如果是为了从 app.hide() 恢复,仅仅为了 miniWindow,则不要让主窗口抢戏
|
||||||
|
if (isMac && this.isRestoringAppForMiniWindow) {
|
||||||
|
this.isRestoringAppForMiniWindow = false
|
||||||
|
// 保持 Spotlight 一样的体验:只显示 miniWindow,把主窗口继续隐藏
|
||||||
|
mainWindow.hide()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正常情况下:主窗口显示时隐藏 miniWindow
|
||||||
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
if (this.miniWindow && !this.miniWindow.isDestroyed()) {
|
||||||
this.miniWindow.hide()
|
this.miniWindow.hide()
|
||||||
}
|
}
|
||||||
@ -403,34 +387,20 @@ export class WindowService {
|
|||||||
this.miniWindow.hide()
|
this.miniWindow.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显式展示主窗口时,不再认为 app 是被 miniWindow 隐藏的
|
||||||
|
this.appHiddenByMiniWindow = false
|
||||||
|
this.isRestoringAppForMiniWindow = false
|
||||||
|
|
||||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
if (this.mainWindow.isMinimized()) {
|
if (this.mainWindow.isMinimized()) {
|
||||||
this.mainWindow.restore()
|
this.mainWindow.restore()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* About setVisibleOnAllWorkspaces
|
|
||||||
*
|
|
||||||
* [macOS] Known Issue
|
|
||||||
* setVisibleOnAllWorkspaces true/false will NOT bring window to current desktop in Mac (works fine with Windows)
|
|
||||||
* AppleScript may be a solution, but it's not worth
|
|
||||||
*
|
|
||||||
* [Linux] Known Issue
|
|
||||||
* setVisibleOnAllWorkspaces 在 Linux 环境下(特别是 KDE Wayland)会导致窗口进入"假弹出"状态
|
|
||||||
* 因此在 Linux 环境下不执行这两行代码
|
|
||||||
*/
|
|
||||||
if (!isLinux) {
|
if (!isLinux) {
|
||||||
this.mainWindow.setVisibleOnAllWorkspaces(true)
|
this.mainWindow.setVisibleOnAllWorkspaces(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* [macOS] After being closed in fullscreen, the fullscreen behavior will become strange when window shows again
|
|
||||||
* So we need to set it to FALSE explicitly.
|
|
||||||
* althougle other platforms don't have the issue, but it's a good practice to do so
|
|
||||||
*
|
|
||||||
* Check if window is visible to prevent interrupting fullscreen state when clicking dock icon
|
|
||||||
*/
|
|
||||||
if (this.mainWindow.isFullScreen() && !this.mainWindow.isVisible()) {
|
if (this.mainWindow.isFullScreen() && !this.mainWindow.isVisible()) {
|
||||||
this.mainWindow.setFullScreen(false)
|
this.mainWindow.setFullScreen(false)
|
||||||
}
|
}
|
||||||
@ -446,16 +416,12 @@ export class WindowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public toggleMainWindow() {
|
public toggleMainWindow() {
|
||||||
// should not toggle main window when in full screen
|
|
||||||
// but if the main window is close to tray when it's in full screen, we can show it again
|
|
||||||
// (it's a bug in macos, because we can close the window when it's in full screen, and the state will be remained)
|
|
||||||
if (this.mainWindow?.isFullScreen() && this.mainWindow?.isVisible()) {
|
if (this.mainWindow?.isFullScreen() && this.mainWindow?.isVisible()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) {
|
if (this.mainWindow && !this.mainWindow.isDestroyed() && this.mainWindow.isVisible()) {
|
||||||
if (this.mainWindow.isFocused()) {
|
if (this.mainWindow.isFocused()) {
|
||||||
// if tray is enabled, hide the main window, else do nothing
|
|
||||||
if (configManager.getTray()) {
|
if (configManager.getTray()) {
|
||||||
this.mainWindow.hide()
|
this.mainWindow.hide()
|
||||||
app.dock?.hide()
|
app.dock?.hide()
|
||||||
@ -516,7 +482,7 @@ export class WindowService {
|
|||||||
miniWindowState.manage(this.miniWindow)
|
miniWindowState.manage(this.miniWindow)
|
||||||
|
|
||||||
// miniWindow should show in current desktop
|
// miniWindow should show in current desktop
|
||||||
this.miniWindow?.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
this.miniWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
|
||||||
// make miniWindow always on top of fullscreen apps with level set
|
// make miniWindow always on top of fullscreen apps with level set
|
||||||
// [mac] level higher than 'floating' will cover the pinyin input method
|
// [mac] level higher than 'floating' will cover the pinyin input method
|
||||||
this.miniWindow.setAlwaysOnTop(true, 'floating')
|
this.miniWindow.setAlwaysOnTop(true, 'floating')
|
||||||
@ -569,33 +535,35 @@ export class WindowService {
|
|||||||
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
|
this.wasMainWindowFocused = this.mainWindow?.isFocused() || false
|
||||||
|
|
||||||
// [Windows] hacky fix
|
// [Windows] hacky fix
|
||||||
// the window is minimized only when in Windows platform
|
const wasMinimized = this.miniWindow.isMinimized()
|
||||||
// because it's a workaround for Windows, see `hideMiniWindow()`
|
if (wasMinimized) {
|
||||||
if (this.miniWindow?.isMinimized()) {
|
this.miniWindow.setOpacity(0)
|
||||||
// don't let the window being seen before we finish adjusting the position across screens
|
this.miniWindow.show()
|
||||||
this.miniWindow?.setOpacity(0)
|
}
|
||||||
// DO NOT use `restore()` here, Electron has the bug with screens of different scale factor
|
|
||||||
// We have to use `show()` here, then set the position and bounds
|
// [macOS] 如果之前是 miniWindow 隐藏时调用的 app.hide(),
|
||||||
this.miniWindow?.show()
|
// 那么现在需要先把整个 app show 回来
|
||||||
|
if (isMac && !this.miniWindow.isVisible()) {
|
||||||
|
if (this.appHiddenByMiniWindow) {
|
||||||
|
this.isRestoringAppForMiniWindow = true
|
||||||
|
app.show()
|
||||||
|
} else {
|
||||||
|
this.isRestoringAppForMiniWindow = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const miniWindowBounds = this.miniWindow.getBounds()
|
const miniWindowBounds = this.miniWindow.getBounds()
|
||||||
|
|
||||||
// Check if miniWindow is on the same screen as mouse cursor
|
|
||||||
const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
const cursorDisplay = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
|
||||||
const miniWindowDisplay = screen.getDisplayNearestPoint(miniWindowBounds)
|
const miniWindowDisplay = screen.getDisplayNearestPoint(miniWindowBounds)
|
||||||
|
|
||||||
// Show the miniWindow on the cursor's screen center
|
|
||||||
// If miniWindow is not on the same screen as cursor, move it to cursor's screen center
|
|
||||||
if (cursorDisplay.id !== miniWindowDisplay.id) {
|
if (cursorDisplay.id !== miniWindowDisplay.id) {
|
||||||
const workArea = cursorDisplay.bounds
|
const workArea = cursorDisplay.bounds
|
||||||
|
|
||||||
// use current window size to avoid the bug of Electron with screens of different scale factor
|
|
||||||
const currentBounds = this.miniWindow.getBounds()
|
const currentBounds = this.miniWindow.getBounds()
|
||||||
const miniWindowWidth = currentBounds.width
|
const miniWindowWidth = currentBounds.width
|
||||||
const miniWindowHeight = currentBounds.height
|
const miniWindowHeight = currentBounds.height
|
||||||
|
|
||||||
// move to the center of the cursor's screen
|
|
||||||
const miniWindowX = Math.round(workArea.x + (workArea.width - miniWindowWidth) / 2)
|
const miniWindowX = Math.round(workArea.x + (workArea.width - miniWindowWidth) / 2)
|
||||||
const miniWindowY = Math.round(workArea.y + (workArea.height - miniWindowHeight) / 2)
|
const miniWindowY = Math.round(workArea.y + (workArea.height - miniWindowHeight) / 2)
|
||||||
|
|
||||||
@ -608,8 +576,12 @@ export class WindowService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.miniWindow?.setOpacity(1)
|
if (wasMinimized || !this.miniWindow.isVisible()) {
|
||||||
this.miniWindow?.show()
|
this.miniWindow.setOpacity(1)
|
||||||
|
this.miniWindow.show()
|
||||||
|
} else {
|
||||||
|
this.miniWindow.focus()
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -626,16 +598,18 @@ export class WindowService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
//[macOs/Windows] hacky fix
|
// 记录这次隐藏 miniWindow 时主窗口是否有焦点:
|
||||||
// previous window(not self-app) should be focused again after miniWindow hide
|
// - true: 从主窗口唤起的 quick assistant,关闭时只隐藏 miniWindow
|
||||||
// this workaround is to make previous window focused again after miniWindow hide
|
// - false: 从其他 app 唤起,关闭时隐藏整个 app(mac 上通过 app.hide() 把焦点交回去)
|
||||||
|
this.appHiddenByMiniWindow = !this.wasMainWindowFocused
|
||||||
|
|
||||||
if (isWin) {
|
if (isWin) {
|
||||||
this.miniWindow.setOpacity(0) // don't show the minimizing animation
|
this.miniWindow.setOpacity(0)
|
||||||
this.miniWindow.minimize()
|
this.miniWindow.minimize()
|
||||||
return
|
return
|
||||||
} else if (isMac) {
|
} else if (isMac) {
|
||||||
this.miniWindow.hide()
|
this.miniWindow.hide()
|
||||||
if (!this.wasMainWindowFocused) {
|
if (this.appHiddenByMiniWindow) {
|
||||||
app.hide()
|
app.hide()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -657,7 +631,7 @@ export class WindowService {
|
|||||||
this.showMiniWindow()
|
this.showMiniWindow()
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPinMiniWindow(isPinned) {
|
public setPinMiniWindow(isPinned: boolean) {
|
||||||
this.isPinnedMiniWindow = isPinned
|
this.isPinnedMiniWindow = isPinned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@ const MessageAttachments: FC<Props> = ({ block }) => {
|
|||||||
fileList={[
|
fileList={[
|
||||||
{
|
{
|
||||||
uid: block.file.id,
|
uid: block.file.id,
|
||||||
url: 'file://' + FileManager.getSafePath(block.file),
|
url: 'file://' + FileManager.getFilePath(block.file),
|
||||||
status: 'done' as const,
|
status: 'done' as const,
|
||||||
name: FileManager.formatFileName(block.file),
|
name: FileManager.formatFileName(block.file),
|
||||||
type: block.file.type,
|
type: block.file.type,
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import '@renderer/databases'
|
|||||||
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
import { ErrorBoundary } from '@renderer/components/ErrorBoundary'
|
||||||
import { getToastUtilities } from '@renderer/components/TopView/toast'
|
import { getToastUtilities } from '@renderer/components/TopView/toast'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
import store, { persistor } from '@renderer/store'
|
import store, { persistor, useAppDispatch } from '@renderer/store'
|
||||||
|
import { setFilesPath } from '@renderer/store/runtime'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Provider } from 'react-redux'
|
import { Provider } from 'react-redux'
|
||||||
import { PersistGate } from 'redux-persist/integration/react'
|
import { PersistGate } from 'redux-persist/integration/react'
|
||||||
@ -16,6 +17,14 @@ import HomeWindow from './home/HomeWindow'
|
|||||||
// Inner component that uses the hook after Redux is initialized
|
// Inner component that uses the hook after Redux is initialized
|
||||||
function MiniWindowContent(): React.ReactElement {
|
function MiniWindowContent(): React.ReactElement {
|
||||||
const { customCss } = useSettings()
|
const { customCss } = useSettings()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
// Initialize filesPath for mini window (same as useAppInit in main window)
|
||||||
|
useEffect(() => {
|
||||||
|
window.api.getAppInfo().then((info) => {
|
||||||
|
dispatch(setFilesPath(info.filesPath))
|
||||||
|
})
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let customCssElement = document.getElementById('user-defined-custom-css') as HTMLStyleElement
|
let customCssElement = document.getElementById('user-defined-custom-css') as HTMLStyleElement
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isMac } from '@renderer/config/constant'
|
||||||
|
import { isGenerateImageModel, isVisionModel } from '@renderer/config/models'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useSettings } from '@renderer/hooks/useSettings'
|
import { useSettings } from '@renderer/hooks/useSettings'
|
||||||
@ -7,12 +8,14 @@ import i18n from '@renderer/i18n'
|
|||||||
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
import { fetchChatCompletion } from '@renderer/services/ApiService'
|
||||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||||
import { ConversationService } from '@renderer/services/ConversationService'
|
import { ConversationService } from '@renderer/services/ConversationService'
|
||||||
|
import FileManager from '@renderer/services/FileManager'
|
||||||
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
|
||||||
|
import PasteService from '@renderer/services/PasteService'
|
||||||
import store, { useAppSelector } from '@renderer/store'
|
import store, { useAppSelector } from '@renderer/store'
|
||||||
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
|
import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
|
||||||
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
|
||||||
import { cancelThrottledBlockUpdate, throttledBlockUpdate } from '@renderer/store/thunk/messageThunk'
|
import { cancelThrottledBlockUpdate, throttledBlockUpdate } from '@renderer/store/thunk/messageThunk'
|
||||||
import type { Topic } from '@renderer/types'
|
import type { FileMetadata, Topic } from '@renderer/types'
|
||||||
import { ThemeMode } from '@renderer/types'
|
import { ThemeMode } from '@renderer/types'
|
||||||
import type { Chunk } from '@renderer/types/chunk'
|
import type { Chunk } from '@renderer/types/chunk'
|
||||||
import { ChunkType } from '@renderer/types/chunk'
|
import { ChunkType } from '@renderer/types/chunk'
|
||||||
@ -39,6 +42,7 @@ import type { FeatureMenusRef } from './components/FeatureMenus'
|
|||||||
import FeatureMenus from './components/FeatureMenus'
|
import FeatureMenus from './components/FeatureMenus'
|
||||||
import Footer from './components/Footer'
|
import Footer from './components/Footer'
|
||||||
import InputBar from './components/InputBar'
|
import InputBar from './components/InputBar'
|
||||||
|
import PastedFilesPreview from './components/PastedFilesPreview'
|
||||||
|
|
||||||
const logger = loggerService.withContext('HomeWindow')
|
const logger = loggerService.withContext('HomeWindow')
|
||||||
|
|
||||||
@ -51,6 +55,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
const [isFirstMessage, setIsFirstMessage] = useState(true)
|
const [isFirstMessage, setIsFirstMessage] = useState(true)
|
||||||
|
|
||||||
const [userInputText, setUserInputText] = useState('')
|
const [userInputText, setUserInputText] = useState('')
|
||||||
|
const [files, setFiles] = useState<FileMetadata[]>([])
|
||||||
|
|
||||||
const [clipboardText, setClipboardText] = useState('')
|
const [clipboardText, setClipboardText] = useState('')
|
||||||
const lastClipboardTextRef = useRef<string | null>(null)
|
const lastClipboardTextRef = useRef<string | null>(null)
|
||||||
@ -73,6 +78,19 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
const inputBarRef = useRef<HTMLDivElement>(null)
|
const inputBarRef = useRef<HTMLDivElement>(null)
|
||||||
const featureMenusRef = useRef<FeatureMenusRef>(null)
|
const featureMenusRef = useRef<FeatureMenusRef>(null)
|
||||||
|
|
||||||
|
// 检查当前助手的模型是否支持图片(复用主窗口逻辑)
|
||||||
|
const isVisionSupported = useMemo(() => isVisionModel(currentAssistant.model), [currentAssistant.model])
|
||||||
|
const isGenerateImageSupported = useMemo(() => isGenerateImageModel(currentAssistant.model), [currentAssistant.model])
|
||||||
|
const canAddImageFile = useMemo(
|
||||||
|
() => isVisionSupported || isGenerateImageSupported,
|
||||||
|
[isVisionSupported, isGenerateImageSupported]
|
||||||
|
)
|
||||||
|
|
||||||
|
const supportedImageExts = useMemo(
|
||||||
|
() => (canAddImageFile ? ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'] : []),
|
||||||
|
[canAddImageFile]
|
||||||
|
)
|
||||||
|
|
||||||
const referenceText = useMemo(() => clipboardText || userInputText, [clipboardText, userInputText])
|
const referenceText = useMemo(() => clipboardText || userInputText, [clipboardText, userInputText])
|
||||||
|
|
||||||
const userContent = useMemo(() => {
|
const userContent = useMemo(() => {
|
||||||
@ -82,6 +100,8 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
return userInputText.trim()
|
return userInputText.trim()
|
||||||
}, [isFirstMessage, referenceText, userInputText])
|
}, [isFirstMessage, referenceText, userInputText])
|
||||||
|
|
||||||
|
const hasChatInput = useMemo(() => Boolean(userContent) || files.length > 0, [files.length, userContent])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
i18n.changeLanguage(language || navigator.language || defaultLanguage)
|
||||||
}, [language])
|
}, [language])
|
||||||
@ -166,7 +186,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
if (isLoading) return
|
if (isLoading) return
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (userContent) {
|
if (userContent || files.length > 0) {
|
||||||
if (route === 'home') {
|
if (route === 'home') {
|
||||||
featureMenusRef.current?.useFeature()
|
featureMenusRef.current?.useFeature()
|
||||||
} else {
|
} else {
|
||||||
@ -213,6 +233,25 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
setUserInputText(e.target.value)
|
setUserInputText(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
async (event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
// 复用 PasteService,根据 supportedImageExts 自动过滤不支持的文件类型
|
||||||
|
// 当模型不支持图片时,supportedImageExts 为空数组,PasteService 会显示提示
|
||||||
|
await PasteService.handlePaste(
|
||||||
|
event.nativeEvent,
|
||||||
|
supportedImageExts,
|
||||||
|
setFiles,
|
||||||
|
setUserInputText,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
userInputText,
|
||||||
|
undefined,
|
||||||
|
t
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[supportedImageExts, t, userInputText]
|
||||||
|
)
|
||||||
|
|
||||||
const handleError = (error: Error) => {
|
const handleError = (error: Error) => {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setError(error.message)
|
setError(error.message)
|
||||||
@ -220,17 +259,22 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (prompt?: string) => {
|
async (prompt?: string) => {
|
||||||
if (isEmpty(userContent) || !currentTopic.current) {
|
if ((isEmpty(userContent) && files.length === 0) || !currentTopic.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const topicId = currentTopic.current.id
|
const topicId = currentTopic.current.id
|
||||||
|
|
||||||
|
const uploadedFiles = files.length ? await FileManager.uploadFiles(files) : []
|
||||||
|
|
||||||
|
const content = [prompt, userContent].filter(Boolean).join('\n\n') || undefined
|
||||||
|
|
||||||
const { message: userMessage, blocks } = getUserMessage({
|
const { message: userMessage, blocks } = getUserMessage({
|
||||||
content: [prompt, userContent].filter(Boolean).join('\n\n'),
|
content,
|
||||||
assistant: currentAssistant,
|
assistant: currentAssistant,
|
||||||
topic: currentTopic.current
|
topic: currentTopic.current,
|
||||||
|
files: uploadedFiles
|
||||||
})
|
})
|
||||||
|
|
||||||
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
|
||||||
@ -272,6 +316,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
|
|
||||||
setIsFirstMessage(false)
|
setIsFirstMessage(false)
|
||||||
setUserInputText('')
|
setUserInputText('')
|
||||||
|
setFiles([])
|
||||||
|
|
||||||
const newAssistant = cloneDeep(currentAssistant)
|
const newAssistant = cloneDeep(currentAssistant)
|
||||||
if (!newAssistant.settings) {
|
if (!newAssistant.settings) {
|
||||||
@ -452,9 +497,13 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
currentAskId.current = ''
|
currentAskId.current = ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[userContent, currentAssistant]
|
[files, userContent, currentAssistant]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleRemoveFile = useCallback((filePath: string) => {
|
||||||
|
setFiles((prevFiles) => prevFiles.filter((file) => file.path !== filePath))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
if (currentAskId.current) {
|
if (currentAskId.current) {
|
||||||
abortCompletion(currentAskId.current)
|
abortCompletion(currentAskId.current)
|
||||||
@ -546,8 +595,10 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
handleKeyDown={handleKeyDown}
|
handleKeyDown={handleKeyDown}
|
||||||
handleChange={handleChange}
|
handleChange={handleChange}
|
||||||
|
handlePaste={handlePaste}
|
||||||
ref={inputBarRef}
|
ref={inputBarRef}
|
||||||
/>
|
/>
|
||||||
|
<PastedFilesPreview files={files} onRemove={handleRemoveFile} />
|
||||||
<Divider style={{ margin: '10px 0' }} />
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -590,8 +641,10 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
handleKeyDown={handleKeyDown}
|
handleKeyDown={handleKeyDown}
|
||||||
handleChange={handleChange}
|
handleChange={handleChange}
|
||||||
|
handlePaste={handlePaste}
|
||||||
ref={inputBarRef}
|
ref={inputBarRef}
|
||||||
/>
|
/>
|
||||||
|
<PastedFilesPreview files={files} onRemove={handleRemoveFile} />
|
||||||
<Divider style={{ margin: '10px 0' }} />
|
<Divider style={{ margin: '10px 0' }} />
|
||||||
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
|
||||||
<Main>
|
<Main>
|
||||||
@ -599,6 +652,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
|||||||
setRoute={setRoute}
|
setRoute={setRoute}
|
||||||
onSendMessage={handleSendMessage}
|
onSendMessage={handleSendMessage}
|
||||||
text={userContent}
|
text={userContent}
|
||||||
|
hasChatInput={hasChatInput}
|
||||||
ref={featureMenusRef}
|
ref={featureMenusRef}
|
||||||
/>
|
/>
|
||||||
</Main>
|
</Main>
|
||||||
|
|||||||
@ -11,6 +11,7 @@ interface FeatureMenusProps {
|
|||||||
text: string
|
text: string
|
||||||
setRoute: Dispatch<SetStateAction<'translate' | 'summary' | 'chat' | 'explanation' | 'home'>>
|
setRoute: Dispatch<SetStateAction<'translate' | 'summary' | 'chat' | 'explanation' | 'home'>>
|
||||||
onSendMessage: (prompt?: string) => void
|
onSendMessage: (prompt?: string) => void
|
||||||
|
hasChatInput: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureMenusRef {
|
export interface FeatureMenusRef {
|
||||||
@ -23,6 +24,7 @@ export interface FeatureMenusRef {
|
|||||||
const FeatureMenus = ({
|
const FeatureMenus = ({
|
||||||
ref,
|
ref,
|
||||||
text,
|
text,
|
||||||
|
hasChatInput,
|
||||||
setRoute,
|
setRoute,
|
||||||
onSendMessage
|
onSendMessage
|
||||||
}: FeatureMenusProps & { ref?: React.RefObject<FeatureMenusRef | null> }) => {
|
}: FeatureMenusProps & { ref?: React.RefObject<FeatureMenusRef | null> }) => {
|
||||||
@ -36,7 +38,7 @@ const FeatureMenus = ({
|
|||||||
title: t('miniwindow.feature.chat'),
|
title: t('miniwindow.feature.chat'),
|
||||||
active: true,
|
active: true,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (text) {
|
if (hasChatInput) {
|
||||||
setRoute('chat')
|
setRoute('chat')
|
||||||
onSendMessage()
|
onSendMessage()
|
||||||
}
|
}
|
||||||
@ -68,7 +70,7 @@ const FeatureMenus = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[onSendMessage, setRoute, t, text]
|
[hasChatInput, onSendMessage, setRoute, t, text]
|
||||||
)
|
)
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
|
|||||||
@ -14,6 +14,7 @@ interface InputBarProps {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
||||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
handlePaste: (e: React.ClipboardEvent<HTMLInputElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const InputBar = ({
|
const InputBar = ({
|
||||||
@ -23,7 +24,8 @@ const InputBar = ({
|
|||||||
placeholder,
|
placeholder,
|
||||||
loading,
|
loading,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleChange
|
handleChange,
|
||||||
|
handlePaste
|
||||||
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
|
||||||
const inputRef = useRef<InputRef>(null)
|
const inputRef = useRef<InputRef>(null)
|
||||||
const { setTimeoutTimer } = useTimer()
|
const { setTimeoutTimer } = useTimer()
|
||||||
@ -40,6 +42,7 @@ const InputBar = ({
|
|||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
|
onPaste={handlePaste}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
</InputWrapper>
|
</InputWrapper>
|
||||||
|
|||||||
@ -0,0 +1,82 @@
|
|||||||
|
import { CloseOutlined, FileImageOutlined, FileOutlined } from '@ant-design/icons'
|
||||||
|
import type { FileMetadata } from '@renderer/types'
|
||||||
|
import { FileTypes } from '@renderer/types'
|
||||||
|
import { Tooltip } from 'antd'
|
||||||
|
import type { FC } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
interface PastedFilesPreviewProps {
|
||||||
|
files: FileMetadata[]
|
||||||
|
onRemove: (filePath: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const PastedFilesPreview: FC<PastedFilesPreviewProps> = ({ files, onRemove }) => {
|
||||||
|
if (!files.length) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{files.map((file) => (
|
||||||
|
<FileChip key={file.path} className="nodrag">
|
||||||
|
<IconWrapper>{file.type === FileTypes.IMAGE ? <FileImageOutlined /> : <FileOutlined />}</IconWrapper>
|
||||||
|
<Tooltip title={file.name} placement="topLeft">
|
||||||
|
<FileName>{file.name}</FileName>
|
||||||
|
</Tooltip>
|
||||||
|
<RemoveButton onClick={() => onRemove(file.path)}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</RemoveButton>
|
||||||
|
</FileChip>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0 2px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const FileChip = styled.div`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-background-opacity);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
max-width: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const IconWrapper = styled.span`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
`
|
||||||
|
|
||||||
|
const FileName = styled.span`
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const RemoveButton = styled.button`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export default PastedFilesPreview
|
||||||
Loading…
Reference in New Issue
Block a user