From 1ac4d660e51bf16068930747dfd8e8e94a29bcfa Mon Sep 17 00:00:00 2001 From: fullex <106392080+0xfullex@users.noreply.github.com> Date: Thu, 19 Jun 2025 00:14:32 +0800 Subject: [PATCH] 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. --- src/renderer/src/i18n/locales/en-us.json | 3 +- src/renderer/src/i18n/locales/ja-jp.json | 3 +- src/renderer/src/i18n/locales/ru-ru.json | 3 +- src/renderer/src/i18n/locales/zh-cn.json | 3 +- src/renderer/src/i18n/locales/zh-tw.json | 3 +- .../settings/ModelSettings/ModelSettings.tsx | 35 +- src/renderer/src/store/llm.ts | 6 +- .../src/windows/mini/chat/ChatWindow.tsx | 12 +- .../windows/mini/chat/components/Messages.tsx | 50 +- .../src/windows/mini/home/HomeWindow.tsx | 653 +++++++++++------- .../windows/mini/home/components/Footer.tsx | 77 ++- .../windows/mini/home/components/InputBar.tsx | 14 +- 12 files changed, 533 insertions(+), 329 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 79218a6cb4..c881d8d464 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -755,7 +755,8 @@ "backspace_clear": "Backspace to clear", "esc": "ESC to {{action}}", "esc_back": "return", - "esc_close": "close" + "esc_close": "close", + "esc_pause": "pause" }, "input": { "placeholder": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 3addc9ceae..5d199d23d3 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -752,10 +752,11 @@ }, "footer": { "copy_last_message": "C キーを押してコピー", + "backspace_clear": "バックスペースを押してクリアします", "esc": "ESC キーを押して{{action}}", "esc_back": "戻る", "esc_close": "ウィンドウを閉じる", - "backspace_clear": "バックスペースを押してクリアします" + "esc_pause": "一時停止" }, "input": { "placeholder": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index a73c1c986d..a3429caf31 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -752,10 +752,11 @@ }, "footer": { "copy_last_message": "Нажмите C для копирования", + "backspace_clear": "Нажмите Backspace, чтобы очистить", "esc": "Нажмите ESC {{action}}", "esc_back": "возвращения", "esc_close": "закрытия окна", - "backspace_clear": "Нажмите Backspace, чтобы очистить" + "esc_pause": "пауза" }, "input": { "placeholder": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index e0fd0c6baa..93ca7677c1 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -755,7 +755,8 @@ "backspace_clear": "按 Backspace 清空", "esc": "按 ESC {{action}}", "esc_back": "返回", - "esc_close": "关闭" + "esc_close": "关闭", + "esc_pause": "暂停" }, "input": { "placeholder": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index a07610aa29..195f33e9d9 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -752,10 +752,11 @@ }, "footer": { "copy_last_message": "按 C 鍵複製", + "backspace_clear": "按 Backspace 清空", "esc": "按 ESC {{action}}", "esc_back": "返回", "esc_close": "關閉視窗", - "backspace_clear": "按 Backspace 清空" + "esc_pause": "暫停" }, "input": { "placeholder": { diff --git a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx index d61767b3af..fbfc8bb05b 100644 --- a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx @@ -170,7 +170,7 @@ const ModelSettings: FC = () => { dispatch(setQuickAssistantId(null))} + onClick={() => dispatch(setQuickAssistantId(''))} selected={!quickAssistantId}> {t('settings.models.use_model')} @@ -188,22 +188,29 @@ const ModelSettings: FC = () => { {!quickAssistantId ? null : ( )} diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index d54b4a447a..a281bc977b 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -29,7 +29,7 @@ export interface LlmState { defaultModel: Model topicNamingModel: Model translateModel: Model - quickAssistantId: string | null + quickAssistantId: string settings: LlmSettings } @@ -534,7 +534,7 @@ export const initialState: LlmState = { defaultModel: SYSTEM_MODELS.defaultModel[0], topicNamingModel: SYSTEM_MODELS.defaultModel[1], translateModel: SYSTEM_MODELS.defaultModel[2], - quickAssistantId: null, + quickAssistantId: '', providers: INITIAL_PROVIDERS, settings: { ollama: { @@ -650,7 +650,7 @@ const llmSlice = createSlice({ state.translateModel = action.payload.model }, - setQuickAssistantId: (state, action: PayloadAction) => { + setQuickAssistantId: (state, action: PayloadAction) => { state.quickAssistantId = action.payload }, setOllamaKeepAliveTime: (state, action: PayloadAction) => { diff --git a/src/renderer/src/windows/mini/chat/ChatWindow.tsx b/src/renderer/src/windows/mini/chat/ChatWindow.tsx index d59169ba6f..ff57494428 100644 --- a/src/renderer/src/windows/mini/chat/ChatWindow.tsx +++ b/src/renderer/src/windows/mini/chat/ChatWindow.tsx @@ -1,18 +1,22 @@ import Scrollbar from '@renderer/components/Scrollbar' -import { Assistant } from '@renderer/types' +import { Assistant, Topic } from '@renderer/types' import { FC } from 'react' import styled from 'styled-components' import Messages from './components/Messages' interface Props { route: string - assistant: Assistant + assistant: Assistant | null + topic: Topic | null + isOutputted: boolean } -const ChatWindow: FC = ({ route, assistant }) => { +const ChatWindow: FC = ({ route, assistant, topic, isOutputted }) => { + if (!assistant || !topic) return null + return (
- +
) } diff --git a/src/renderer/src/windows/mini/chat/components/Messages.tsx b/src/renderer/src/windows/mini/chat/components/Messages.tsx index 932446312f..74e689b966 100644 --- a/src/renderer/src/windows/mini/chat/components/Messages.tsx +++ b/src/renderer/src/windows/mini/chat/components/Messages.tsx @@ -1,61 +1,29 @@ +import { LoadingOutlined } from '@ant-design/icons' import Scrollbar from '@renderer/components/Scrollbar' import { useTopicMessages } from '@renderer/hooks/useMessageOperations' -import { Assistant } from '@renderer/types' -import { getMainTextContent } from '@renderer/utils/messageUtils/find' -import { last } from 'lodash' -import { FC, useRef } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' -import { useTranslation } from 'react-i18next' +import { Assistant, Topic } from '@renderer/types' +import { FC } from 'react' import styled from 'styled-components' import MessageItem from './Message' interface Props { assistant: Assistant + topic: Topic route: string + isOutputted: boolean } interface ContainerProps { right?: boolean } -const Messages: FC = ({ assistant, route }) => { - // const [messages, setMessages] = useState([]) - const messages = useTopicMessages(assistant.topics[0].id) - const containerRef = useRef(null) - const messagesRef = useRef(messages) +const Messages: FC = ({ assistant, topic, route, isOutputted }) => { + const messages = useTopicMessages(topic.id) - 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 ( - + + {!isOutputted && } {[...messages].reverse().map((message, index) => ( ))} diff --git a/src/renderer/src/windows/mini/home/HomeWindow.tsx b/src/renderer/src/windows/mini/home/HomeWindow.tsx index 7ea0fd6959..24c4fbcf20 100644 --- a/src/renderer/src/windows/mini/home/HomeWindow.tsx +++ b/src/renderer/src/windows/mini/home/HomeWindow.tsx @@ -1,27 +1,27 @@ import { isMac } from '@renderer/config/constant' 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 i18n from '@renderer/i18n' 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 store, { useAppSelector } from '@renderer/store' -import { upsertManyBlocks } from '@renderer/store/messageBlock' -import { updateOneBlock, upsertOneBlock } from '@renderer/store/messageBlock' -import { newMessagesActions } from '@renderer/store/newMessage' -import { Assistant, ThemeMode } from '@renderer/types' +import { updateOneBlock, upsertManyBlocks, upsertOneBlock } from '@renderer/store/messageBlock' +import { newMessagesActions, selectMessagesForTopic } from '@renderer/store/newMessage' +import { ThemeMode, Topic } from '@renderer/types' import { Chunk, ChunkType } from '@renderer/types/chunk' -import { AssistantMessageStatus } from '@renderer/types/newMessage' -import { MessageBlockStatus } from '@renderer/types/newMessage' -import { createMainTextBlock } from '@renderer/utils/messageUtils/create' +import { AssistantMessageStatus, MessageBlockStatus } from '@renderer/types/newMessage' +import { abortCompletion } from '@renderer/utils/abortController' +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 { IpcChannel } from '@shared/IpcChannel' import { Divider } from 'antd' -import dayjs from 'dayjs' import { isEmpty } from 'lodash' -import React, { FC, useCallback, useEffect, useRef, useState } from 'react' -import { useHotkeys } from 'react-hotkeys-hook' +import { last } from 'lodash' +import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -33,63 +33,111 @@ import Footer from './components/Footer' import InputBar from './components/InputBar' 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({} as Assistant) - const [text, setText] = useState('') - const [lastClipboardText, setLastClipboardText] = useState(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 { theme } = useTheme() const { t } = useTranslation() - const inputBarRef = useRef(null) - const featureMenusRef = useRef(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(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(null) const { quickAssistantId } = useAppSelector((state) => state.llm) + const { assistant: currentAssistant } = useAssistant(quickAssistantId) - const readClipboard = useCallback(async () => { - if (!readClipboardAtStartup) return + const currentTopic = useRef(getDefaultTopic(currentAssistant.id)) + const currentAskId = useRef('') - const text = await navigator.clipboard.readText().catch(() => null) - if (text && text !== lastClipboardText) { - setLastClipboardText(text) - setClipboardText(text.trim()) + const inputBarRef = useRef(null) + const featureMenusRef = useRef(null) + + 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) { const input = inputBarRef.current.querySelector('input') if (input) { 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 () => { featureMenusRef.current?.resetSelectedIndex() - readClipboard().then() + await readClipboard() 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(() => { readClipboard() }, [readClipboard]) - useEffect(() => { - i18n.changeLanguage(language || navigator.language || defaultLanguage) - }, [language]) - - const onCloseWindow = () => window.api.miniWindow.hide() + const handleCloseWindow = useCallback(() => window.api.miniWindow.hide(), []) const handleKeyDown = (e: React.KeyboardEvent) => { // 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程 @@ -97,10 +145,7 @@ const HomeWindow: FC = () => { // 例子,中文输入法候选词过程使用`Enter`直接上屏字母,日文输入法候选词过程使用`Enter`输入假名 // 输入法可以`Esc`终止候选词过程 // 这两个例子的`Enter`和`Esc`快捷助手都不应该响应 - if (e.nativeEvent.isComposing) { - return - } - if (e.key === 'Process') { + if (e.nativeEvent.isComposing || e.key === 'Process') { return } @@ -108,14 +153,16 @@ const HomeWindow: FC = () => { case 'Enter': case 'NumpadEnter': { + if (isLoading) return + e.preventDefault() - if (content) { + if (userContent) { if (route === 'home') { featureMenusRef.current?.useFeature() } else { - // 目前文本框只在'chat'时可以继续输入,这里相当于 route === 'chat' + // Currently text input is only available in 'chat' mode setRoute('chat') - onSendMessage().then() + handleSendMessage() focusInput() } } @@ -123,11 +170,9 @@ const HomeWindow: FC = () => { break case 'Backspace': { - textChange(() => { - if (text.length === 0) { - clearClipboard() - } - }) + if (userInputText.length === 0) { + clearClipboard() + } } break case 'ArrowUp': @@ -148,226 +193,345 @@ const HomeWindow: FC = () => { break case 'Escape': { - setText('') - setRoute('home') - route === 'home' && onCloseWindow() + handleEsc() } break } } const handleChange = (e: React.ChangeEvent) => { - setText(e.target.value) + setUserInputText(e.target.value) } - useEffect(() => { - const defaultCurrentAssistant = { - ...defaultAssistant, - model: defaultModel - } + const handleError = (error: Error) => { + setIsLoading(false) + setError(error.message) + } - if (quickAssistantId) { - // 獲取指定助手,如果不存在則使用默認助手 - 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( + const handleSendMessage = useCallback( async (prompt?: string) => { - if (isEmpty(content)) { + if (isEmpty(userContent) || !currentTopic.current) { 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 })) - store.dispatch(upsertManyBlocks(blocks)) + try { + const topicId = currentTopic.current.id - const assistant = currentAssistant - let blockId: string | null = null - let blockContent: string = '' + const { message: userMessage, blocks } = getUserMessage({ + content: [prompt, userContent].filter(Boolean).join('\n\n'), + assistant: currentAssistant, + topic: currentTopic.current + }) - const assistantMessage = getAssistantMessage({ assistant, topic: assistant.topics[0] }) - store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) + store.dispatch(newMessagesActions.addMessage({ topicId, message: userMessage })) + store.dispatch(upsertManyBlocks(blocks)) - fetchChatCompletion({ - messages: [userMessage], - assistant: { ...assistant, settings: { streamOutput: true } }, - onChunkReceived: (chunk: Chunk) => { - if (chunk.type === ChunkType.TEXT_DELTA) { - blockContent += chunk.text - 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 } })) + const assistantMessage = getAssistantMessage({ + assistant: currentAssistant, + topic: currentTopic.current + }) + assistantMessage.askId = userMessage.id + currentAskId.current = userMessage.id + + store.dispatch(newMessagesActions.addMessage({ topicId, message: assistantMessage })) + + const allMessagesForTopic = selectMessagesForTopic(store.getState(), topicId) + const userMessageIndex = allMessagesForTopic.findIndex((m) => m?.id === userMessage.id) + + const messagesForContext = allMessagesForTopic + .slice(0, userMessageIndex + 1) + .filter((m) => m && !m.status?.includes('ing')) + + let blockId: string | null = null + let blockContent: string = '' + let thinkingBlockId: string | null = null + let thinkingBlockContent: string = '' + + 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 } })) - store.dispatch( - newMessagesActions.updateMessage({ - topicId, - messageId: assistantMessage.id, - updates: { status: AssistantMessageStatus.SUCCESS } - }) - ) - } - } - }) - - setIsFirstMessage(false) - setText('') // ✅ 清除输入框内容 + }) + } catch (err) { + if (isAbortError(err)) return + handleError(err instanceof Error ? err : new Error('An error occurred')) + console.error('Error fetching result:', err) + } finally { + setIsLoading(false) + setIsOutputted(true) + currentAskId.current = '' + } }, - [content, currentAssistant, topic] + [userContent, currentAssistant] ) - const clearClipboard = () => { - setClipboardText('') - setSelectedText('') - focusInput() - } + const handlePause = useCallback(() => { + if (currentAskId.current) { + abortCompletion(currentAskId.current) + setIsLoading(false) + setIsOutputted(true) + currentAskId.current = '' + } + }, []) - // If the input is focused, the `Esc` callback will not be triggered here. - useHotkeys('esc', () => { - if (route === 'home') { - onCloseWindow() + const handleEsc = useCallback(() => { + if (isLoading) { + handlePause() } else { - setRoute('home') - setText('') + if (route === 'home') { + 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(() => { - window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow) + const handleCopy = useCallback(() => { + if (!currentTopic.current) return - return () => { - window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow) + const messages = selectMessagesForTopic(store.getState(), currentTopic.current.id) + 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 - useEffect(() => { - if (route === 'home') { - setIsFirstMessage(true) - } - }, [route]) - - const backgroundColor = () => { + const backgroundColor = useMemo(() => { // ONLY MAC: when transparent style + light theme: use vibrancy effect // because the dark style under mac's vibrancy effect has not been implemented if (isMac && windowStyle === 'transparent' && theme === ThemeMode.light) { return 'transparent' } - return 'var(--color-background)' - } + }, [windowStyle, theme]) - if (['chat', 'summary', 'explanation'].includes(route)) { - return ( - - {route === 'chat' && ( - <> - - - - )} - {['summary', 'explanation'].includes(route) && ( -
- -
- )} - - -