This commit is contained in:
who is 2025-12-18 05:53:51 +00:00 committed by GitHub
commit e3f8a6ac89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 226 additions and 102 deletions

View File

@ -28,11 +28,16 @@ export class WindowService {
private mainWindow: BrowserWindow | null = null private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null private miniWindow: BrowserWindow | null = null
private isPinnedMiniWindow: boolean = false private isPinnedMiniWindow: boolean = false
//hacky-fix: store the focused status of mainWindow before miniWindow shows // hacky-fix: store the focused status of mainWindow before miniWindow shows
//to restore the focus status when miniWindow hides // to restore the focus status when miniWindow hides
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()
@ -93,13 +98,13 @@ export class WindowService {
this.setupMainWindow(this.mainWindow, mainWindowState) this.setupMainWindow(this.mainWindow, mainWindowState)
//preload miniWindow to resolve series of issues about miniWindow in Mac // preload miniWindow to resolve series of issues about miniWindow in Mac
const enableQuickAssistant = configManager.getEnableQuickAssistant() const enableQuickAssistant = configManager.getEnableQuickAssistant()
if (enableQuickAssistant && !this.miniWindow) { if (enableQuickAssistant && !this.miniWindow) {
this.miniWindow = this.createMiniWindow(true) this.miniWindow = this.createMiniWindow(true)
} }
//init the MinApp webviews' useragent // init the MinApp webviews' useragent
initSessionUserAgent() initSessionUserAgent()
return this.mainWindow return this.mainWindow
@ -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()
} }
@ -375,12 +347,12 @@ export class WindowService {
mainWindow.hide() mainWindow.hide()
//for mac users, should hide dock icon if close to tray // for mac users, should hide dock icon if close to tray
if (isMac && isTrayOnClose) { if (isMac && isTrayOnClose) {
app.dock?.hide() app.dock?.hide()
mainWindow.once('show', () => { mainWindow.once('show', () => {
//restore the window can hide by cmd+h when the window is shown again // restore the window can hide by cmd+h when the window is shown again
// https://github.com/electron/electron/pull/47970 // https://github.com/electron/electron/pull/47970
app.dock?.show() app.dock?.show()
}) })
@ -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()
@ -515,10 +481,10 @@ 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')
this.miniWindow.on('ready-to-show', () => { this.miniWindow.on('ready-to-show', () => {
@ -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 唤起,关闭时隐藏整个 appmac 上通过 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
} }
@ -681,4 +655,4 @@ export class WindowService {
} }
} }
export const windowService = WindowService.getInstance() export const windowService = WindowService.getInstance()

View File

@ -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,

View File

@ -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

View File

@ -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>

View File

@ -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, () => ({

View File

@ -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>

View File

@ -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