From 4d553beb85cdccb651d5e7dfe0c1aa156a4e3154 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Wed, 18 Jun 2025 17:08:43 +0800 Subject: [PATCH] fix: series bugs of quick assistant --- 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 | 33 +- .../src/windows/mini/chat/ChatWindow.tsx | 12 +- .../windows/mini/chat/components/Messages.tsx | 37 +- .../src/windows/mini/home/HomeWindow.tsx | 542 +++++++++++------- .../windows/mini/home/components/Footer.tsx | 32 +- .../windows/mini/home/components/InputBar.tsx | 8 +- 11 files changed, 409 insertions(+), 270 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 95d679149c..ad6e134fc5 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 96c68e03cd..a19e9e7e3d 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 4928b69a57..07b6f9afda 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 d85730409c..b815f18aca 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 48d429635e..0cd910e523 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..b221e713c7 100644 --- a/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx +++ b/src/renderer/src/pages/settings/ModelSettings/ModelSettings.tsx @@ -188,22 +188,29 @@ const ModelSettings: FC = () => { {!quickAssistantId ? null : ( )} 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..1a0c414aef 100644 --- a/src/renderer/src/windows/mini/chat/components/Messages.tsx +++ b/src/renderer/src/windows/mini/chat/components/Messages.tsx @@ -1,9 +1,10 @@ +import { LoadingOutlined } from '@ant-design/icons' import Scrollbar from '@renderer/components/Scrollbar' 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 { last } from 'lodash' -import { FC, useRef } from 'react' +import { FC } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -12,40 +13,19 @@ 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) { @@ -55,7 +35,8 @@ const Messages: FC = ({ assistant, route }) => { } }) 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..0339217cbe 100644 --- a/src/renderer/src/windows/mini/home/HomeWindow.tsx +++ b/src/renderer/src/windows/mini/home/HomeWindow.tsx @@ -1,27 +1,32 @@ import { isMac } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' -import { useDefaultAssistant, useDefaultModel } 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 { + getAssistantById, + getDefaultAssistant, + getDefaultModel, + 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 { selectMessagesForTopic } from '@renderer/store/newMessage' +import { Assistant, 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 { abortCompletion } from '@renderer/utils/abortController' +import { isAbortError } from '@renderer/utils/error' +import { createMainTextBlock, createThinkingBlock } from '@renderer/utils/messageUtils/create' 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 { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -33,28 +38,61 @@ import Footer from './components/Footer' import InputBar from './components/InputBar' const HomeWindow: FC = () => { + const { language, readClipboardAtStartup, windowStyle } = useSettings() + const { theme } = useTheme() + const { t } = useTranslation() + 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 [userInputText, setUserInputText] = 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() + //indicator for loading(thinking/streaming) + const [isLoading, setIsLoading] = useState(false) + //indicator for wether the first message is outputted + const [isOutputted, setIsOutputted] = useState(false) const { quickAssistantId } = useAppSelector((state) => state.llm) + const currentAssistant = useRef(null) + const currentTopic = useRef(null) + const currentAskId = useRef('') + + const inputBarRef = useRef(null) + const featureMenusRef = useRef(null) + const referenceText = selectedText || clipboardText || userInputText + + const content = isFirstMessage + ? (referenceText === userInputText ? userInputText : `${referenceText}\n\n${userInputText}`).trim() + : userInputText.trim() + + //init the assistant and topic + useEffect(() => { + if (quickAssistantId) { + currentAssistant.current = getAssistantById(quickAssistantId) || getDefaultAssistant() + } else { + currentAssistant.current = getDefaultAssistant() + } + + if (!currentAssistant.current?.model) { + currentAssistant.current.model = getDefaultModel() + } + currentTopic.current = getDefaultTopic(currentAssistant.current?.id) + }, [quickAssistantId]) + + useEffect(() => { + i18n.changeLanguage(language || navigator.language || defaultLanguage) + }, [language]) + + // 当路由为home时,初始化isFirstMessage为true + useEffect(() => { + if (route === 'home') { + setIsFirstMessage(true) + } + }, [route]) const readClipboard = useCallback(async () => { if (!readClipboardAtStartup) return @@ -66,6 +104,12 @@ const HomeWindow: FC = () => { } }, [readClipboardAtStartup, lastClipboardText]) + const clearClipboard = () => { + setClipboardText('') + setSelectedText('') + focusInput() + } + const focusInput = () => { if (inputBarRef.current) { const input = inputBarRef.current.querySelector('input') @@ -81,15 +125,19 @@ const HomeWindow: FC = () => { focusInput() }, [readClipboard]) + 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 = () => window.api.miniWindow.hide() const handleKeyDown = (e: React.KeyboardEvent) => { // 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程 @@ -115,7 +163,7 @@ const HomeWindow: FC = () => { } else { // 目前文本框只在'chat'时可以继续输入,这里相当于 route === 'chat' setRoute('chat') - onSendMessage().then() + handleSendMessage().then() focusInput() } } @@ -124,7 +172,7 @@ const HomeWindow: FC = () => { case 'Backspace': { textChange(() => { - if (text.length === 0) { + if (userInputText.length === 0) { clearClipboard() } }) @@ -148,137 +196,209 @@ 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 - } - - 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(content) || !currentAssistant.current || !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, content].filter(Boolean).join('\n\n'), + assistant: currentAssistant.current, + 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.current, + 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) + + setIsFirstMessage(false) + setUserInputText('') + + await fetchChatCompletion({ + messages: messagesForContext, + assistant: { ...currentAssistant.current, 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.BLOCK_COMPLETE: + case ChunkType.ERROR: + 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 + // onError(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] + [content, currentAssistant] ) - const clearClipboard = () => { - setClipboardText('') - setSelectedText('') - focusInput() + const handleEsc = () => { + if (isLoading) { + handlePause() + } else { + if (route === 'home') { + handleCloseWindow() + } else { + //if we go back to home, we should clear the topic + + //clear the topic messages in order to reduce memory usage + store.dispatch(newMessagesActions.clearTopicMessages(currentTopic.current!.id)) + + //reset the topic + if (currentAssistant.current?.id) { + currentTopic.current = getDefaultTopic(currentAssistant.current.id) + } + + setRoute('home') + setUserInputText('') + } + } } - // If the input is focused, the `Esc` callback will not be triggered here. - useHotkeys('esc', () => { - if (route === 'home') { - onCloseWindow() - } else { - setRoute('home') - setText('') - } - }) + const handlePause = () => { + if (currentAskId.current) { + // const topicId = currentTopic.current!.id - useEffect(() => { - window.electron.ipcRenderer.on(IpcChannel.ShowMiniWindow, onWindowShow) + // const topicMessages = selectMessagesForTopic(store.getState(), topicId) + // if (!topicMessages) return - return () => { - window.electron.ipcRenderer.removeAllListeners(IpcChannel.ShowMiniWindow) - } - }, [onWindowShow, onSendMessage, setRoute]) + // const streamingMessages = topicMessages.filter((m) => m.status === 'processing' || m.status === 'pending') + // const askIds = [...new Set(streamingMessages?.map((m) => m.askId).filter((id) => !!id) as string[])] - // 当路由为home时,初始化isFirstMessage为true - useEffect(() => { - if (route === 'home') { - setIsFirstMessage(true) + // for (const askId of askIds) { + // abortCompletion(askId) + // } + // store.dispatch(newMessagesActions.setTopicLoading({ topicId: topicId, loading: false })) + + abortCompletion(currentAskId.current) + // store.dispatch(newMessagesActions.setTopicLoading({ topicId: currentTopic.current!.id, loading: false })) + setIsLoading(false) + setIsOutputted(true) + currentAskId.current = '' } - }, [route]) + } const backgroundColor = () => { // ONLY MAC: when transparent style + light theme: use vibrancy effect @@ -286,88 +406,94 @@ const HomeWindow: FC = () => { if (isMac && windowStyle === 'transparent' && theme === ThemeMode.light) { return 'transparent' } - return 'var(--color-background)' } - if (['chat', 'summary', 'explanation'].includes(route)) { - return ( - - {route === 'chat' && ( - <> - - - - )} - {['summary', 'explanation'].includes(route) && ( -
- -
- )} - - -