refactor(QuickAssistant): fix loop rendering & support context/pause/thinking block (#7336)

* fix: series bugs of quick assistant

* fix: update quick assistant ID handling and improve error management in HomeWindow

* refactor(HomeWindow, Messages): streamline clipboard handling and improve component structure

- Removed unused imports and hotkey functionality from Messages component.
- Refactored clipboard management in HomeWindow to use refs for better performance.
- Enhanced user input handling and state management in HomeWindow.
- Updated InputBar to accept assistant prop instead of model for better clarity.
- Improved Footer component to handle copy functionality and pin state more effectively.

* Enhance Footer component: add rotation animation to pin icon and adjust margin

- Updated the Pin icon in the Footer component to include a rotation animation based on the pin state.
- Adjusted the margin of the PinButtonArea for improved layout consistency.

* refactor(HomeWindow): improve clipboard handling and input placeholder logic

- Updated clipboard reading logic to check for document focus in addition to startup settings.
- Consolidated key event handling to streamline input processing.
- Enhanced placeholder logic in InputBar to reflect the current assistant's name or model more accurately.
This commit is contained in:
fullex 2025-06-19 00:14:32 +08:00 committed by GitHub
parent b2e9f33f37
commit 1ac4d660e5
12 changed files with 533 additions and 329 deletions

View File

@ -755,7 +755,8 @@
"backspace_clear": "Backspace to clear", "backspace_clear": "Backspace to clear",
"esc": "ESC to {{action}}", "esc": "ESC to {{action}}",
"esc_back": "return", "esc_back": "return",
"esc_close": "close" "esc_close": "close",
"esc_pause": "pause"
}, },
"input": { "input": {
"placeholder": { "placeholder": {

View File

@ -752,10 +752,11 @@
}, },
"footer": { "footer": {
"copy_last_message": "C キーを押してコピー", "copy_last_message": "C キーを押してコピー",
"backspace_clear": "バックスペースを押してクリアします",
"esc": "ESC キーを押して{{action}}", "esc": "ESC キーを押して{{action}}",
"esc_back": "戻る", "esc_back": "戻る",
"esc_close": "ウィンドウを閉じる", "esc_close": "ウィンドウを閉じる",
"backspace_clear": "バックスペースを押してクリアします" "esc_pause": "一時停止"
}, },
"input": { "input": {
"placeholder": { "placeholder": {

View File

@ -752,10 +752,11 @@
}, },
"footer": { "footer": {
"copy_last_message": "Нажмите C для копирования", "copy_last_message": "Нажмите C для копирования",
"backspace_clear": "Нажмите Backspace, чтобы очистить",
"esc": "Нажмите ESC {{action}}", "esc": "Нажмите ESC {{action}}",
"esc_back": "возвращения", "esc_back": "возвращения",
"esc_close": "закрытия окна", "esc_close": "закрытия окна",
"backspace_clear": "Нажмите Backspace, чтобы очистить" "esc_pause": "пауза"
}, },
"input": { "input": {
"placeholder": { "placeholder": {

View File

@ -755,7 +755,8 @@
"backspace_clear": "按 Backspace 清空", "backspace_clear": "按 Backspace 清空",
"esc": "按 ESC {{action}}", "esc": "按 ESC {{action}}",
"esc_back": "返回", "esc_back": "返回",
"esc_close": "关闭" "esc_close": "关闭",
"esc_pause": "暂停"
}, },
"input": { "input": {
"placeholder": { "placeholder": {

View File

@ -752,10 +752,11 @@
}, },
"footer": { "footer": {
"copy_last_message": "按 C 鍵複製", "copy_last_message": "按 C 鍵複製",
"backspace_clear": "按 Backspace 清空",
"esc": "按 ESC {{action}}", "esc": "按 ESC {{action}}",
"esc_back": "返回", "esc_back": "返回",
"esc_close": "關閉視窗", "esc_close": "關閉視窗",
"backspace_clear": "按 Backspace 清空" "esc_pause": "暫停"
}, },
"input": { "input": {
"placeholder": { "placeholder": {

View File

@ -170,7 +170,7 @@ const ModelSettings: FC = () => {
<HStack alignItems="center" gap={0}> <HStack alignItems="center" gap={0}>
<StyledButton <StyledButton
type={!quickAssistantId ? 'primary' : 'default'} type={!quickAssistantId ? 'primary' : 'default'}
onClick={() => dispatch(setQuickAssistantId(null))} onClick={() => dispatch(setQuickAssistantId(''))}
selected={!quickAssistantId}> selected={!quickAssistantId}>
{t('settings.models.use_model')} {t('settings.models.use_model')}
</StyledButton> </StyledButton>
@ -188,22 +188,29 @@ const ModelSettings: FC = () => {
{!quickAssistantId ? null : ( {!quickAssistantId ? null : (
<HStack alignItems="center" style={{ marginTop: 12 }}> <HStack alignItems="center" style={{ marginTop: 12 }}>
<Select <Select
value={quickAssistantId} value={quickAssistantId || defaultAssistant.id}
style={{ width: 360 }} style={{ width: 360 }}
onChange={(value) => dispatch(setQuickAssistantId(value))} onChange={(value) => dispatch(setQuickAssistantId(value))}
placeholder={t('settings.models.quick_assistant_selection')}> placeholder={t('settings.models.quick_assistant_selection')}>
{assistants.map((a) => ( <Select.Option key={defaultAssistant.id} value={defaultAssistant.id}>
<Select.Option key={a.id} value={a.id}> <AssistantItem>
<AssistantItem> <ModelAvatar model={defaultAssistant.model || defaultModel} size={18} />
<ModelAvatar model={a.model || defaultModel} size={18} /> <AssistantName>{defaultAssistant.name}</AssistantName>
<AssistantName>{a.name}</AssistantName> <Spacer />
<Spacer /> <DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag>
{a.id === defaultAssistant.id && ( </AssistantItem>
<DefaultTag isCurrent={true}>{t('settings.models.quick_assistant_default_tag')}</DefaultTag> </Select.Option>
)} {assistants
</AssistantItem> .filter((a) => a.id !== defaultAssistant.id)
</Select.Option> .map((a) => (
))} <Select.Option key={a.id} value={a.id}>
<AssistantItem>
<ModelAvatar model={a.model || defaultModel} size={18} />
<AssistantName>{a.name}</AssistantName>
<Spacer />
</AssistantItem>
</Select.Option>
))}
</Select> </Select>
</HStack> </HStack>
)} )}

View File

@ -29,7 +29,7 @@ export interface LlmState {
defaultModel: Model defaultModel: Model
topicNamingModel: Model topicNamingModel: Model
translateModel: Model translateModel: Model
quickAssistantId: string | null quickAssistantId: string
settings: LlmSettings settings: LlmSettings
} }
@ -534,7 +534,7 @@ export const initialState: LlmState = {
defaultModel: SYSTEM_MODELS.defaultModel[0], defaultModel: SYSTEM_MODELS.defaultModel[0],
topicNamingModel: SYSTEM_MODELS.defaultModel[1], topicNamingModel: SYSTEM_MODELS.defaultModel[1],
translateModel: SYSTEM_MODELS.defaultModel[2], translateModel: SYSTEM_MODELS.defaultModel[2],
quickAssistantId: null, quickAssistantId: '',
providers: INITIAL_PROVIDERS, providers: INITIAL_PROVIDERS,
settings: { settings: {
ollama: { ollama: {
@ -650,7 +650,7 @@ const llmSlice = createSlice({
state.translateModel = action.payload.model state.translateModel = action.payload.model
}, },
setQuickAssistantId: (state, action: PayloadAction<string | null>) => { setQuickAssistantId: (state, action: PayloadAction<string>) => {
state.quickAssistantId = action.payload state.quickAssistantId = action.payload
}, },
setOllamaKeepAliveTime: (state, action: PayloadAction<number>) => { setOllamaKeepAliveTime: (state, action: PayloadAction<number>) => {

View File

@ -1,18 +1,22 @@
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { Assistant } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { FC } from 'react' import { FC } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Messages from './components/Messages' import Messages from './components/Messages'
interface Props { interface Props {
route: string route: string
assistant: Assistant assistant: Assistant | null
topic: Topic | null
isOutputted: boolean
} }
const ChatWindow: FC<Props> = ({ route, assistant }) => { const ChatWindow: FC<Props> = ({ route, assistant, topic, isOutputted }) => {
if (!assistant || !topic) return null
return ( return (
<Main className="bubble"> <Main className="bubble">
<Messages assistant={{ ...assistant }} route={route} /> <Messages assistant={assistant} topic={topic} route={route} isOutputted={isOutputted} />
</Main> </Main>
) )
} }

View File

@ -1,61 +1,29 @@
import { LoadingOutlined } from '@ant-design/icons'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { useTopicMessages } from '@renderer/hooks/useMessageOperations' import { useTopicMessages } from '@renderer/hooks/useMessageOperations'
import { Assistant } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { getMainTextContent } from '@renderer/utils/messageUtils/find' import { FC } from 'react'
import { last } from 'lodash'
import { FC, useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import MessageItem from './Message' import MessageItem from './Message'
interface Props { interface Props {
assistant: Assistant assistant: Assistant
topic: Topic
route: string route: string
isOutputted: boolean
} }
interface ContainerProps { interface ContainerProps {
right?: boolean right?: boolean
} }
const Messages: FC<Props> = ({ assistant, route }) => { const Messages: FC<Props> = ({ assistant, topic, route, isOutputted }) => {
// const [messages, setMessages] = useState<Message[]>([]) const messages = useTopicMessages(topic.id)
const messages = useTopicMessages(assistant.topics[0].id)
const containerRef = useRef<HTMLDivElement>(null)
const messagesRef = useRef(messages)
const { t } = useTranslation()
messagesRef.current = messages
// const onSendMessage = useCallback(
// async (message: Message) => {
// setMessages((prev) => {
// const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] })
// store.dispatch(newMessagesActions.addMessage({ topicId: assistant.topics[0].id, message: assistantMessage }))
// const messages = prev.concat([message, assistantMessage])
// return messages
// })
// },
// [assistant]
// )
// useEffect(() => {
// const unsubscribes = [EventEmitter.on(EVENT_NAMES.SEND_MESSAGE, onSendMessage)]
// return () => unsubscribes.forEach((unsub) => unsub())
// }, [assistant.id])
useHotkeys('c', () => {
const lastMessage = last(messages)
if (lastMessage) {
const content = getMainTextContent(lastMessage)
navigator.clipboard.writeText(content)
window.message.success(t('message.copy.success'))
}
})
return ( return (
<Container id="messages" key={assistant.id} ref={containerRef}> <Container id="messages" key={assistant.id}>
{!isOutputted && <LoadingOutlined style={{ fontSize: 16 }} spin />}
{[...messages].reverse().map((message, index) => ( {[...messages].reverse().map((message, index) => (
<MessageItem key={message.id} message={message} index={index} total={messages.length} route={route} /> <MessageItem key={message.id} message={message} index={index} total={messages.length} route={route} />
))} ))}

View File

@ -1,27 +1,27 @@
import { isMac } from '@renderer/config/constant' import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useDefaultAssistant, useDefaultModel } from '@renderer/hooks/useAssistant' import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import i18n from '@renderer/i18n' import i18n from '@renderer/i18n'
import { fetchChatCompletion } from '@renderer/services/ApiService' import { fetchChatCompletion } from '@renderer/services/ApiService'
import { getAssistantById } from '@renderer/services/AssistantService' import { getDefaultTopic } from '@renderer/services/AssistantService'
import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService' import { getAssistantMessage, getUserMessage } from '@renderer/services/MessagesService'
import store, { useAppSelector } from '@renderer/store' import store, { useAppSelector } from '@renderer/store'
import { upsertManyBlocks } from '@renderer/store/messageBlock' import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock'
import { updateOneBlock, upsertOneBlock } from '@renderer/store/messageBlock' import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage'
import { newMessagesActions } from '@renderer/store/newMessage' import { ThemeMode, Topic } from '@renderer/types'
import { Assistant, ThemeMode } from '@renderer/types'
import { Chunk, ChunkType } from '@renderer/types/chunk' import { Chunk, ChunkType } from '@renderer/types/chunk'
import { AssistantMessageStatus } from '@renderer/types/newMessage' import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage'
import { MessageBlockStatus } from '@renderer/types/newMessage' import { abortCompletion } from '@renderer/utils/abortController'
import { createMainTextBlock } from '@renderer/utils/messageUtils/create' import { isAbortError } from '@renderer/utils/error'
import { createMainTextBlock, createThinkingBlock } from '@renderer/utils/messageUtils/create'
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { defaultLanguage } from '@shared/config/constant' import { defaultLanguage } from '@shared/config/constant'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { Divider } from 'antd' import { Divider } from 'antd'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import React, { FC, useCallback, useEffect, useRef, useState } from 'react' import { last } from 'lodash'
import { useHotkeys } from 'react-hotkeys-hook' import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -33,63 +33,111 @@ import Footer from './components/Footer'
import InputBar from './components/InputBar' import InputBar from './components/InputBar'
const HomeWindow: FC = () => { const HomeWindow: FC = () => {
const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
const [isFirstMessage, setIsFirstMessage] = useState(true)
const [clipboardText, setClipboardText] = useState('')
const [selectedText, setSelectedText] = useState('')
const [currentAssistant, setCurrentAssistant] = useState<Assistant>({} as Assistant)
const [text, setText] = useState('')
const [lastClipboardText, setLastClipboardText] = useState<string | null>(null)
const textChange = useState(() => {})[1]
const { defaultAssistant } = useDefaultAssistant()
const topic = defaultAssistant.topics[0]
const { defaultModel } = useDefaultModel()
const model = currentAssistant.model || defaultModel
const { language, readClipboardAtStartup, windowStyle } = useSettings() const { language, readClipboardAtStartup, windowStyle } = useSettings()
const { theme } = useTheme() const { theme } = useTheme()
const { t } = useTranslation() const { t } = useTranslation()
const inputBarRef = useRef<HTMLDivElement>(null)
const featureMenusRef = useRef<FeatureMenusRef>(null)
const referenceText = selectedText || clipboardText || text
const content = isFirstMessage ? (referenceText === text ? text : `${referenceText}\n\n${text}`).trim() : text.trim() const [route, setRoute] = useState<'home' | 'chat' | 'translate' | 'summary' | 'explanation'>('home')
const [isFirstMessage, setIsFirstMessage] = useState(true)
const [userInputText, setUserInputText] = useState('')
const [clipboardText, setClipboardText] = useState('')
const lastClipboardTextRef = useRef<string | null>(null)
const [isPinned, setIsPinned] = useState(false)
// Indicator for loading(thinking/streaming)
const [isLoading, setIsLoading] = useState(false)
// Indicator for whether the first message is outputted
const [isOutputted, setIsOutputted] = useState(false)
const [error, setError] = useState<string | null>(null)
const { quickAssistantId } = useAppSelector((state) => state.llm) const { quickAssistantId } = useAppSelector((state) => state.llm)
const { assistant: currentAssistant } = useAssistant(quickAssistantId)
const readClipboard = useCallback(async () => { const currentTopic = useRef<Topic>(getDefaultTopic(currentAssistant.id))
if (!readClipboardAtStartup) return const currentAskId = useRef('')
const text = await navigator.clipboard.readText().catch(() => null) const inputBarRef = useRef<HTMLDivElement>(null)
if (text && text !== lastClipboardText) { const featureMenusRef = useRef<FeatureMenusRef>(null)
setLastClipboardText(text)
setClipboardText(text.trim()) const referenceText = useMemo(() => clipboardText || userInputText, [clipboardText, userInputText])
const userContent = useMemo(() => {
if (isFirstMessage) {
return referenceText === userInputText ? userInputText : `${referenceText}\n\n${userInputText}`.trim()
} }
}, [readClipboardAtStartup, lastClipboardText]) return userInputText.trim()
}, [isFirstMessage, referenceText, userInputText])
const focusInput = () => { useEffect(() => {
i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language])
// Reset state when switching to home route
useEffect(() => {
if (route === 'home') {
setIsFirstMessage(true)
setError(null)
}
}, [route])
const focusInput = useCallback(() => {
if (inputBarRef.current) { if (inputBarRef.current) {
const input = inputBarRef.current.querySelector('input') const input = inputBarRef.current.querySelector('input')
if (input) { if (input) {
input.focus() input.focus()
} }
} }
} }, [])
// Use useCallback with stable dependencies to avoid infinite loops
const readClipboard = useCallback(async () => {
if (!readClipboardAtStartup || !document.hasFocus()) return
try {
const text = await navigator.clipboard.readText()
if (text && text !== lastClipboardTextRef.current) {
lastClipboardTextRef.current = text
setClipboardText(text.trim())
}
} catch (error) {
// Silently handle clipboard read errors (common in some environments)
console.warn('Failed to read clipboard:', error)
}
}, [readClipboardAtStartup])
const clearClipboard = useCallback(async () => {
setClipboardText('')
lastClipboardTextRef.current = null
focusInput()
}, [focusInput])
const onWindowShow = useCallback(async () => { const onWindowShow = useCallback(async () => {
featureMenusRef.current?.resetSelectedIndex() featureMenusRef.current?.resetSelectedIndex()
readClipboard().then() await readClipboard()
focusInput() focusInput()
}, [readClipboard]) }, [readClipboard, focusInput])
useEffect(() => {
window.api.miniWindow.setPin(isPinned)
}, [isPinned])
useEffect(() => {
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow)
return () => {
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow)
}
}, [onWindowShow])
useEffect(() => { useEffect(() => {
readClipboard() readClipboard()
}, [readClipboard]) }, [readClipboard])
useEffect(() => { const handleCloseWindow = useCallback(() => window.api.miniWindow.hide(), [])
i18n.changeLanguage(language || navigator.language || defaultLanguage)
}, [language])
const onCloseWindow = () => window.api.miniWindow.hide()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程 // 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程
@ -97,10 +145,7 @@ const HomeWindow: FC = () => {
// 例子,中文输入法候选词过程使用`Enter`直接上屏字母,日文输入法候选词过程使用`Enter`输入假名 // 例子,中文输入法候选词过程使用`Enter`直接上屏字母,日文输入法候选词过程使用`Enter`输入假名
// 输入法可以`Esc`终止候选词过程 // 输入法可以`Esc`终止候选词过程
// 这两个例子的`Enter`和`Esc`快捷助手都不应该响应 // 这两个例子的`Enter`和`Esc`快捷助手都不应该响应
if (e.nativeEvent.isComposing) { if (e.nativeEvent.isComposing || e.key === 'Process') {
return
}
if (e.key === 'Process') {
return return
} }
@ -108,14 +153,16 @@ const HomeWindow: FC = () => {
case 'Enter': case 'Enter':
case 'NumpadEnter': case 'NumpadEnter':
{ {
if (isLoading) return
e.preventDefault() e.preventDefault()
if (content) { if (userContent) {
if (route === 'home') { if (route === 'home') {
featureMenusRef.current?.useFeature() featureMenusRef.current?.useFeature()
} else { } else {
// 目前文本框只在'chat'时可以继续输入,这里相当于 route === 'chat' // Currently text input is only available in 'chat' mode
setRoute('chat') setRoute('chat')
onSendMessage().then() handleSendMessage()
focusInput() focusInput()
} }
} }
@ -123,11 +170,9 @@ const HomeWindow: FC = () => {
break break
case 'Backspace': case 'Backspace':
{ {
textChange(() => { if (userInputText.length === 0) {
if (text.length === 0) { clearClipboard()
clearClipboard() }
}
})
} }
break break
case 'ArrowUp': case 'ArrowUp':
@ -148,226 +193,345 @@ const HomeWindow: FC = () => {
break break
case 'Escape': case 'Escape':
{ {
setText('') handleEsc()
setRoute('home')
route === 'home' && onCloseWindow()
} }
break break
} }
} }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value) setUserInputText(e.target.value)
} }
useEffect(() => { const handleError = (error: Error) => {
const defaultCurrentAssistant = { setIsLoading(false)
...defaultAssistant, setError(error.message)
model: defaultModel }
}
if (quickAssistantId) { const handleSendMessage = useCallback(
// 獲取指定助手,如果不存在則使用默認助手
const assistantFromId = getAssistantById(quickAssistantId)
const currentAssistant = assistantFromId || defaultCurrentAssistant
// 如果助手本身沒有設定模型,則使用預設模型
if (!currentAssistant.model) {
currentAssistant.model = defaultModel
}
setCurrentAssistant(currentAssistant)
} else {
setCurrentAssistant(defaultCurrentAssistant)
}
}, [quickAssistantId, defaultAssistant, defaultModel])
const onSendMessage = useCallback(
async (prompt?: string) => { async (prompt?: string) => {
if (isEmpty(content)) { if (isEmpty(userContent) || !currentTopic.current) {
return return
} }
const topic = currentAssistant.topics[0]
const messageParams = {
role: 'user',
content: [prompt, content].filter(Boolean).join('\n\n'),
assistant: currentAssistant,
topic,
createdAt: dayjs().format('YYYY-MM-DD HH:mm:ss'),
status: 'success'
}
const topicId = topic.id
const { message: userMessage, blocks } = getUserMessage(messageParams)
store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage })) try {
store.dispatch(upsertManyBlocks(blocks)) const topicId = currentTopic.current.id
const assistant = currentAssistant const { message: userMessage, blocks } = getUserMessage({
let blockId: string | null = null content: [prompt, userContent].filter(Boolean).join('\n\n'),
let blockContent: string = '' assistant: currentAssistant,
topic: currentTopic.current
})
const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] }) store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage }))
store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) store.dispatch(upsertManyBlocks(blocks))
fetchChatCompletion({ const assistantMessage = getAssistantMessage({
messages: [userMessage], assistant: currentAssistant,
assistant: { ...assistant, settings: { streamOutput: true } }, topic: currentTopic.current
onChunkReceived: (chunk: Chunk) => { })
if (chunk.type === ChunkType.TEXT_DELTA) { assistantMessage.askId = userMessage.id
blockContent += chunk.text currentAskId.current = userMessage.id
if (!blockId) {
const block = createMainTextBlock(assistantMessage.id, chunk.text, { store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage }))
status: MessageBlockStatus.STREAMING
}) const allMessagesForTopic = selectMessagesForTopic(store.getState(), topicId)
blockId = block.id const userMessageIndex = allMessagesForTopic.findIndex((m) => m?.id === userMessage.id)
store.dispatch(
newMessagesActions.updateMessage({ const messagesForContext = allMessagesForTopic
topicId, .slice(0, userMessageIndex + 1)
messageId: assistantMessage.id, .filter((m) => m && !m.status?.includes('ing'))
updates: { blockInstruction: { id: block.id } }
}) let blockId: string | null = null
) let blockContent: string = ''
store.dispatch(upsertOneBlock(block)) let thinkingBlockId: string | null = null
} else { let thinkingBlockContent: string = ''
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
setIsLoading(true)
setIsOutputted(false)
setError(null)
setIsFirstMessage(false)
setUserInputText('')
await fetchChatCompletion({
messages: messagesForContext,
assistant: { ...currentAssistant, settings: { streamOutput: true } },
onChunkReceived: (chunk: Chunk) => {
switch (chunk.type) {
case ChunkType.THINKING_DELTA:
{
thinkingBlockContent += chunk.text
setIsOutputted(true)
if (!thinkingBlockId) {
const block = createThinkingBlock(assistantMessage.id, chunk.text, {
status: MessageBlockStatus.STREAMING,
thinking_millsec: chunk.thinking_millsec
})
thinkingBlockId = block.id
store.dispatch(
newMessagesActions.updateMessage({
topicId,
messageId: assistantMessage.id,
updates: { blockInstruction: { id: block.id } }
})
)
store.dispatch(upsertOneBlock(block))
} else {
store.dispatch(
updateOneBlock({
id: thinkingBlockId,
changes: { content: thinkingBlockContent, thinking_millsec: chunk.thinking_millsec }
})
)
}
}
break
case ChunkType.THINKING_COMPLETE:
{
if (thinkingBlockId) {
store.dispatch(
updateOneBlock({
id: thinkingBlockId,
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: chunk.thinking_millsec }
})
)
}
}
break
case ChunkType.TEXT_DELTA:
{
blockContent += chunk.text
setIsOutputted(true)
if (!blockId) {
const block = createMainTextBlock(assistantMessage.id, chunk.text, {
status: MessageBlockStatus.STREAMING
})
blockId = block.id
store.dispatch(
newMessagesActions.updateMessage({
topicId,
messageId: assistantMessage.id,
updates: { blockInstruction: { id: block.id } }
})
)
store.dispatch(upsertOneBlock(block))
} else {
store.dispatch(updateOneBlock({ id: blockId, changes: { content: blockContent } }))
}
}
break
case ChunkType.TEXT_COMPLETE:
{
blockId &&
store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.SUCCESS } }))
store.dispatch(
newMessagesActions.updateMessage({
topicId,
messageId: assistantMessage.id,
updates: { status: AssistantMessageStatus.SUCCESS }
})
)
}
break
case ChunkType.ERROR: {
//stop the thinking timer
const isAborted = isAbortError(chunk.error)
const possibleBlockId = thinkingBlockId || blockId
if (possibleBlockId) {
store.dispatch(
updateOneBlock({
id: possibleBlockId,
changes: {
status: isAborted ? MessageBlockStatus.PAUSED : MessageBlockStatus.ERROR
}
})
)
}
if (!isAborted) {
throw new Error(chunk.error.message)
}
}
//fall through
case ChunkType.BLOCK_COMPLETE:
setIsLoading(false)
setIsOutputted(true)
currentAskId.current = ''
break
} }
} }
if (chunk.type === ChunkType.TEXT_COMPLETE) { })
blockId && store.dispatch(updateOneBlock({ id: blockId, changes: { status: MessageBlockStatus.SUCCESS } })) } catch (err) {
store.dispatch( if (isAbortError(err)) return
newMessagesActions.updateMessage({ handleError(err instanceof Error ? err : new Error('An error occurred'))
topicId, console.error('Error fetching result:', err)
messageId: assistantMessage.id, } finally {
updates: { status: AssistantMessageStatus.SUCCESS } setIsLoading(false)
}) setIsOutputted(true)
) currentAskId.current = ''
} }
}
})
setIsFirstMessage(false)
setText('') // ✅ 清除输入框内容
}, },
[content, currentAssistant, topic] [userContent, currentAssistant]
) )
const clearClipboard = () => { const handlePause = useCallback(() => {
setClipboardText('') if (currentAskId.current) {
setSelectedText('') abortCompletion(currentAskId.current)
focusInput() setIsLoading(false)
} setIsOutputted(true)
currentAskId.current = ''
}
}, [])
// If the input is focused, the `Esc` callback will not be triggered here. const handleEsc = useCallback(() => {
useHotkeys('esc', () => { if (isLoading) {
if (route === 'home') { handlePause()
onCloseWindow()
} else { } else {
setRoute('home') if (route === 'home') {
setText('') handleCloseWindow()
} else {
// Clear the topic messages to reduce memory usage
if (currentTopic.current) {
store.dispatch(newMessagesActions.clearTopicMessages(currentTopic.current.id))
}
// Reset the topic
currentTopic.current = getDefaultTopic(currentAssistant.id)
setError(null)
setRoute('home')
setUserInputText('')
}
} }
}) }, [isLoading, route, handleCloseWindow, currentAssistant.id, handlePause])
useEffect(() => { const handleCopy = useCallback(() => {
window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow) if (!currentTopic.current) return
return () => { const messages = selectMessagesForTopic(store.getState(), currentTopic.current.id)
window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow) const lastMessage = last(messages)
if (lastMessage) {
const content = getMainTextContent(lastMessage)
navigator.clipboard.writeText(content)
window.message.success(t('message.copy.success'))
} }
}, [onWindowShow, onSendMessage, setRoute]) }, [currentTopic, t])
// 当路由为home时初始化isFirstMessage为true const backgroundColor = useMemo(() => {
useEffect(() => {
if (route === 'home') {
setIsFirstMessage(true)
}
}, [route])
const backgroundColor = () => {
// ONLY MAC: when transparent style + light theme: use vibrancy effect // ONLY MAC: when transparent style + light theme: use vibrancy effect
// because the dark style under mac's vibrancy effect has not been implemented // because the dark style under mac's vibrancy effect has not been implemented
if (isMac && windowStyle === 'transparent' && theme === ThemeMode.light) { if (isMac && windowStyle === 'transparent' && theme === ThemeMode.light) {
return 'transparent' return 'transparent'
} }
return 'var(--color-background)' return 'var(--color-background)'
} }, [windowStyle, theme])
if (['chat', 'summary', 'explanation'].includes(route)) { // Memoize placeholder text
return ( const inputPlaceholder = useMemo(() => {
<Container style={{ backgroundColor: backgroundColor() }}> if (referenceText && route === 'home') {
{route === 'chat' && ( return t('miniwindow.input.placeholder.title')
<> }
<InputBar return t('miniwindow.input.placeholder.empty', {
text={text} model: quickAssistantId ? currentAssistant.name : currentAssistant.model.name
model={model} })
referenceText={referenceText} }, [referenceText, route, t, quickAssistantId, currentAssistant])
placeholder={
quickAssistantId
? t('miniwindow.input.placeholder.empty', { model: currentAssistant.name })
: t('miniwindow.input.placeholder.empty', { model: model.name })
}
handleKeyDown={handleKeyDown}
handleChange={handleChange}
ref={inputBarRef}
/>
<Divider style={{ margin: '10px 0' }} />
</>
)}
{['summary', 'explanation'].includes(route) && (
<div style={{ marginTop: 10 }}>
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
</div>
)}
<ChatWindow route={route} assistant={currentAssistant ?? defaultAssistant} />
<Divider style={{ margin: '10px 0' }} />
<Footer route={route} onExit={() => setRoute('home')} />
</Container>
)
}
if (route === 'translate') { // Memoize footer props
return ( const baseFooterProps = useMemo(
<Container style={{ backgroundColor: backgroundColor() }}> () => ({
<TranslateWindow text={referenceText} /> route,
<Divider style={{ margin: '10px 0' }} /> loading: isLoading,
<Footer route={route} onExit={() => setRoute('home')} /> onEsc: handleEsc,
</Container> setIsPinned,
) isPinned
} }),
[route, isLoading, handleEsc, isPinned]
return (
<Container style={{ backgroundColor: backgroundColor() }}>
<InputBar
text={text}
model={model}
referenceText={referenceText}
placeholder={
referenceText && route === 'home'
? t('miniwindow.input.placeholder.title')
: quickAssistantId
? t('miniwindow.input.placeholder.empty', { model: currentAssistant.name })
: t('miniwindow.input.placeholder.empty', { model: model.name })
}
handleKeyDown={handleKeyDown}
handleChange={handleChange}
ref={inputBarRef}
/>
<Divider style={{ margin: '10px 0' }} />
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
<Main>
<FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} text={content} ref={featureMenusRef} />
</Main>
<Divider style={{ margin: '10px 0' }} />
<Footer
route={route}
canUseBackspace={text.length > 0 || clipboardText.length == 0}
clearClipboard={clearClipboard}
onExit={() => {
setRoute('home')
setText('')
onCloseWindow()
}}
/>
</Container>
) )
switch (route) {
case 'chat':
case 'summary':
case 'explanation':
return (
<Container style={{ backgroundColor }}>
{route === 'chat' && (
<>
<InputBar
text={userInputText}
assistant={currentAssistant}
referenceText={referenceText}
placeholder={inputPlaceholder}
loading={isLoading}
handleKeyDown={handleKeyDown}
handleChange={handleChange}
ref={inputBarRef}
/>
<Divider style={{ margin: '10px 0' }} />
</>
)}
{['summary', 'explanation'].includes(route) && (
<div style={{ marginTop: 10 }}>
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
</div>
)}
<ChatWindow
route={route}
assistant={currentAssistant}
topic={currentTopic.current}
isOutputted={isOutputted}
/>
{error && <ErrorMsg>{error}</ErrorMsg>}
<Divider style={{ margin: '10px 0' }} />
<Footer key="footer" {...baseFooterProps} onCopy={handleCopy} />
</Container>
)
case 'translate':
return (
<Container style={{ backgroundColor }}>
<TranslateWindow text={referenceText} />
<Divider style={{ margin: '10px 0' }} />
<Footer key="footer" {...baseFooterProps} />
</Container>
)
// Home
default:
return (
<Container style={{ backgroundColor }}>
<InputBar
text={userInputText}
assistant={currentAssistant}
referenceText={referenceText}
placeholder={inputPlaceholder}
loading={isLoading}
handleKeyDown={handleKeyDown}
handleChange={handleChange}
ref={inputBarRef}
/>
<Divider style={{ margin: '10px 0' }} />
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
<Main>
<FeatureMenus
setRoute={setRoute}
onSendMessage={handleSendMessage}
text={userContent}
ref={featureMenusRef}
/>
</Main>
<Divider style={{ margin: '10px 0' }} />
<Footer
key="footer"
{...baseFooterProps}
canUseBackspace={userInputText.length > 0 || clipboardText.length === 0}
clearClipboard={clearClipboard}
/>
</Container>
)
}
} }
const Container = styled.div` const Container = styled.div`
@ -388,4 +552,15 @@ const Main = styled.main`
overflow: hidden; overflow: hidden;
` `
const ErrorMsg = styled.div`
color: var(--color-error);
background: rgba(255, 0, 0, 0.15);
border: 1px solid var(--color-error);
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
font-size: 13px;
word-break: break-all;
`
export default HomeWindow export default HomeWindow

View File

@ -1,25 +1,45 @@
import { ArrowLeftOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, LoadingOutlined } from '@ant-design/icons'
import { Tag as AntdTag, Tooltip } from 'antd' import { Tag as AntdTag, Tooltip } from 'antd'
import { CircleArrowLeft, Copy, Pin } from 'lucide-react' import { CircleArrowLeft, Copy, Pin } from 'lucide-react'
import { FC, useState } from 'react' import { FC } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
interface FooterProps { interface FooterProps {
route: string route: string
canUseBackspace?: boolean canUseBackspace?: boolean
loading?: boolean
setIsPinned: (isPinned: boolean) => void
isPinned: boolean
clearClipboard?: () => void clearClipboard?: () => void
onExit: () => void onEsc: () => void
onCopy?: () => void
} }
const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExit }) => { const Footer: FC<FooterProps> = ({
route,
canUseBackspace,
loading,
clearClipboard,
onEsc,
setIsPinned,
isPinned,
onCopy
}) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isPinned, setIsPinned] = useState(false)
const onClickPin = () => { useHotkeys('esc', () => {
window.api.miniWindow.setPin(!isPinned).then(() => { onEsc()
setIsPinned(!isPinned) })
})
useHotkeys('c', () => {
handleCopy()
})
const handleCopy = () => {
if (loading || !onCopy) return
onCopy()
} }
return ( return (
@ -27,11 +47,21 @@ const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExi
<FooterText> <FooterText>
<Tag <Tag
bordered={false} bordered={false}
icon={<CircleArrowLeft size={14} color="var(--color-text)" />} icon={
loading ? (
<LoadingOutlined style={{ fontSize: 12, color: 'var(--color-error)', padding: 0 }} spin />
) : (
<CircleArrowLeft size={14} color="var(--color-text)" />
)
}
className="nodrag" className="nodrag"
onClick={() => onExit()}> onClick={onEsc}>
{t('miniwindow.footer.esc', { {t('miniwindow.footer.esc', {
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back') action: loading
? t('miniwindow.footer.esc_pause')
: route === 'home'
? t('miniwindow.footer.esc_close')
: t('miniwindow.footer.esc_back')
})} })}
</Tag> </Tag>
{route === 'home' && !canUseBackspace && ( {route === 'home' && !canUseBackspace && (
@ -44,19 +74,27 @@ const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExi
{t('miniwindow.footer.backspace_clear')} {t('miniwindow.footer.backspace_clear')}
</Tag> </Tag>
)} )}
{route !== 'home' && ( {route !== 'home' && !loading && (
<Tag <Tag
bordered={false} bordered={false}
icon={<Copy size={14} color="var(--color-text)" />} icon={<Copy size={14} color="var(--color-text)" />}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
className="nodrag"> className="nodrag"
onClick={handleCopy}>
{t('miniwindow.footer.copy_last_message')} {t('miniwindow.footer.copy_last_message')}
</Tag> </Tag>
)} )}
</FooterText> </FooterText>
<PinButtonArea onClick={() => onClickPin()} className="nodrag"> <PinButtonArea onClick={() => setIsPinned(!isPinned)} className="nodrag">
<Tooltip title={t('miniwindow.tooltip.pin')} mouseEnterDelay={0.8} placement="left"> <Tooltip title={t('miniwindow.tooltip.pin')} mouseEnterDelay={0.8} placement="left">
<Pin size={14} stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'} /> <Pin
size={14}
stroke={isPinned ? 'var(--color-primary)' : 'var(--color-text)'}
style={{
transform: isPinned ? 'rotate(40deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease-in-out'
}}
/>
</Tooltip> </Tooltip>
</PinButtonArea> </PinButtonArea>
</WindowFooter> </WindowFooter>
@ -84,6 +122,7 @@ const PinButtonArea = styled.div`
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
margin-right: 5px;
` `
const Tag = styled(AntdTag)` const Tag = styled(AntdTag)`
@ -91,6 +130,12 @@ const Tag = styled(AntdTag)`
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
transition: all 0.2s ease-in-out;
&:hover {
background-color: var(--color-background-soft);
color: var(--color-primary);
}
` `
export default Footer export default Footer

View File

@ -1,5 +1,5 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { useRuntime } from '@renderer/hooks/useRuntime' import { Assistant } from '@renderer/types'
import { Input as AntdInput } from 'antd' import { Input as AntdInput } from 'antd'
import { InputRef } from 'rc-input/lib/interface' import { InputRef } from 'rc-input/lib/interface'
import React, { useRef } from 'react' import React, { useRef } from 'react'
@ -7,9 +7,10 @@ import styled from 'styled-components'
interface InputBarProps { interface InputBarProps {
text: string text: string
model: any assistant: Assistant
referenceText: string referenceText: string
placeholder: string placeholder: string
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
} }
@ -17,19 +18,19 @@ interface InputBarProps {
const InputBar = ({ const InputBar = ({
ref, ref,
text, text,
model, assistant,
placeholder, placeholder,
loading,
handleKeyDown, handleKeyDown,
handleChange handleChange
}: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => { }: InputBarProps & { ref?: React.RefObject<HTMLDivElement | null> }) => {
const { generating } = useRuntime()
const inputRef = useRef<InputRef>(null) const inputRef = useRef<InputRef>(null)
if (!generating) { if (!loading) {
setTimeout(() => inputRef.current?.input?.focus(), 0) setTimeout(() => inputRef.current?.input?.focus(), 0)
} }
return ( return (
<InputWrapper ref={ref}> <InputWrapper ref={ref}>
<ModelAvatar model={model} size={30} /> {assistant.model && <ModelAvatar model={assistant.model} size={30} />}
<Input <Input
value={text} value={text}
placeholder={placeholder} placeholder={placeholder}
@ -37,7 +38,6 @@ const InputBar = ({
autoFocus autoFocus
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onChange={handleChange} onChange={handleChange}
disabled={generating}
ref={inputRef} ref={inputRef}
/> />
</InputWrapper> </InputWrapper>