diff --git a/package.json b/package.json index d6b70fb827..b3b37a694a 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,7 @@ "lru-cache": "^11.1.0", "lucide-react": "^0.487.0", "mime": "^4.0.4", + "motion": "^12.10.5", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", "p-queue": "^8.1.0", diff --git a/src/renderer/src/components/Spinner.tsx b/src/renderer/src/components/Spinner.tsx index fb8e3d35e7..74408fc50d 100644 --- a/src/renderer/src/components/Spinner.tsx +++ b/src/renderer/src/components/Spinner.tsx @@ -1,41 +1,68 @@ import { Search } from 'lucide-react' +import { motion } from 'motion/react' import { useTranslation } from 'react-i18next' -import BarLoader from 'react-spinners/BarLoader' -import styled, { css } from 'styled-components' +import styled from 'styled-components' interface Props { text: string } +// Define variants for the spinner animation +const spinnerVariants = { + defaultColor: { + color: '#2a2a2a' + }, + dimmed: { + color: '#8C9296' + } +} + export default function Spinner({ text }: Props) { const { t } = useTranslation() return ( - - - {t(text)} - - + + + {t(text)} + ) } -const baseContainer = css` +// const baseContainer = css` +// display: flex; +// flex-direction: row; +// align-items: center; +// ` + +// const Container = styled.div` +// ${baseContainer} +// background-color: var(--color-background-mute); +// padding: 10px; +// border-radius: 10px; +// margin-bottom: 10px; +// gap: 10px; +// ` + +// const StatusText = styled.div` +// font-size: 14px; +// line-height: 1.6; +// text-decoration: none; +// color: var(--color-text-1); +// ` +const SearchWrapper = styled.div` display: flex; - flex-direction: row; align-items: center; -` - -const Container = styled.div` - ${baseContainer} - background-color: var(--color-background-mute); - padding: 10px; - border-radius: 10px; - margin-bottom: 10px; - gap: 10px; -` - -const StatusText = styled.div` + gap: 4px; font-size: 14px; - line-height: 1.6; - text-decoration: none; - color: var(--color-text-1); + padding: 10px; + padding-left: 0; ` +const Searching = motion.create(SearchWrapper) diff --git a/src/renderer/src/hooks/useMessageOperations.ts b/src/renderer/src/hooks/useMessageOperations.ts index 86cfd89b88..b6e853f5f3 100644 --- a/src/renderer/src/hooks/useMessageOperations.ts +++ b/src/renderer/src/hooks/useMessageOperations.ts @@ -333,6 +333,36 @@ export function useMessageOperations(topic: Topic) { [dispatch, topic.id] ) + /** + * Removes a specific block from a message. + */ + const removeMessageBlock = useCallback( + async (messageId: string, blockIdToRemove: string) => { + if (!topic?.id) { + console.error('[removeMessageBlock] Topic prop is not valid.') + return + } + + const state = store.getState() + const message = state.messages.entities[messageId] + if (!message || !message.blocks) { + console.error('[removeMessageBlock] Message not found or has no blocks:', messageId) + return + } + + const updatedBlocks = message.blocks.filter((blockId) => blockId !== blockIdToRemove) + + const messageUpdates: Partial & Pick = { + id: messageId, + updatedAt: new Date().toISOString(), + blocks: updatedBlocks + } + + await dispatch(updateMessageAndBlocksThunk(topic.id, messageUpdates, [])) + }, + [dispatch, topic?.id] + ) + return { displayCount, deleteMessage, @@ -348,7 +378,8 @@ export function useMessageOperations(topic: Topic) { resumeMessage, getTranslationUpdater, createTopicBranch, - editMessageBlocks + editMessageBlocks, + removeMessageBlock } } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 50d98803e0..d5500fac41 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1397,7 +1397,10 @@ "models.check.enabled": "Enabled", "models.check.failed": "Failed", "models.check.keys_status_count": "Passed: {{count_passed}} keys, failed: {{count_failed}} keys", - "models.check.model_status_summary": "{{provider}}: {{count_passed}} models passed health checks ({{count_partial}} models had inaccessible keys), {{count_failed}} models completely inaccessible.", + "models.check.model_status_failed": "{{count}} models completely inaccessible", + "models.check.model_status_partial": "{{count}} models had inaccessible keys", + "models.check.model_status_passed": "{{count}} models passed health checks", + "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "No API keys found, please add API keys first.", "models.check.passed": "Passed", "models.check.select_api_key": "Select the API key to use:", @@ -1621,6 +1624,10 @@ "any.language": "Any language", "button.translate": "Translate", "close": "Close", + "closed": "Translation closed", + "copied": "Translation content copied", + "empty": "Translation content is empty", + "not.found": "Translation content not found", "confirm": { "content": "Translation will replace the original text, continue?", "title": "Translation Confirmation" diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index b3e9a8239a..a87d01d6ce 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1395,7 +1395,10 @@ "models.check.enabled": "開く", "models.check.failed": "失敗", "models.check.keys_status_count": "合格:{{count_passed}}個のキー、不合格:{{count_failed}}個のキー", - "models.check.model_status_summary": "{{provider}}: {{count_passed}} 個のモデルが健康チェックを完了しました({{count_partial}} 個のモデルは一部のキーにアクセスできませんでした)、{{count_failed}} 個のモデルは完全にアクセスできませんでした。", + "models.check.model_status_failed": "{{count}} 個のモデルが完全にアクセスできません", + "models.check.model_status_partial": "{{count}} 個のモデルが一部のキーでアクセスできません", + "models.check.model_status_passed": "{{count}} 個のモデルが健康チェックを通過しました", + "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "APIキーが見つかりません。まずAPIキーを追加してください。", "models.check.passed": "成功", "models.check.select_api_key": "使用するAPIキーを選択:", @@ -1621,6 +1624,10 @@ "any.language": "任意の言語", "button.translate": "翻訳", "close": "閉じる", + "closed": "翻訳は閉じられました", + "copied": "翻訳内容がコピーされました", + "empty": "翻訳内容が空です", + "not.found": "翻訳内容が見つかりません", "confirm": { "content": "翻訳すると元のテキストが上書きされます。続行しますか?", "title": "翻訳確認" diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 5047e20e6a..aa1d22cf46 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1395,7 +1395,10 @@ "models.check.enabled": "Включено", "models.check.failed": "Не прошло", "models.check.keys_status_count": "Прошло: {{count_passed}} ключей, Не прошло: {{count_failed}} ключей", - "models.check.model_status_summary": "{{provider}}: {{count_passed}} моделей прошли проверку состояния (из них {{count_partial}} моделей недоступны с некоторыми ключами), {{count_failed}} моделей полностью недоступны.", + "models.check.model_status_failed": "{{count}} моделей полностью недоступны", + "models.check.model_status_partial": "{{count}} моделей недоступны с некоторыми ключами", + "models.check.model_status_passed": "{{count}} моделей прошли проверку состояния", + "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "API ключи не найдены, пожалуйста, добавьте API ключи.", "models.check.passed": "Прошло", "models.check.select_api_key": "Выберите API ключ для использования:", @@ -1621,6 +1624,10 @@ "any.language": "Любой язык", "button.translate": "Перевести", "close": "Закрыть", + "closed": "Перевод закрыт", + "copied": "Содержимое перевода скопировано", + "empty": "Содержимое перевода пусто", + "not.found": "Содержимое перевода не найдено", "confirm": { "content": "Перевод заменит исходный текст, продолжить?", "title": "Перевод подтверждение" @@ -1657,4 +1664,4 @@ "visualization": "Визуализация" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index ab0855fb06..6f68ce6f68 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1397,7 +1397,10 @@ "models.check.enabled": "开启", "models.check.failed": "失败", "models.check.keys_status_count": "通过:{{count_passed}}个密钥,失败:{{count_failed}}个密钥", - "models.check.model_status_summary": "{{provider}}: {{count_passed}} 个模型完成健康检测(其中 {{count_partial}} 个模型用某些密钥无法访问),{{count_failed}} 个模型完全无法访问。", + "models.check.model_status_failed": "{{count}} 个模型完全无法访问", + "models.check.model_status_partial": "其中 {{count}} 个模型用某些密钥无法访问", + "models.check.model_status_passed": "{{count}} 个模型通过健康检测", + "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "未找到API密钥,请先添加API密钥。", "models.check.passed": "通过", "models.check.select_api_key": "选择要使用的API密钥:", @@ -1621,6 +1624,10 @@ "any.language": "任意语言", "button.translate": "翻译", "close": "关闭", + "closed": "翻译已关闭", + "copied": "翻译内容已复制", + "empty": "翻译内容为空", + "not.found": "未找到翻译内容", "confirm": { "content": "翻译后将覆盖原文,是否继续?", "title": "翻译确认" @@ -1657,4 +1664,4 @@ "visualization": "可视化" } } -} \ No newline at end of file +} diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 6065ab925d..3d013c5f14 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1396,7 +1396,10 @@ "models.check.enabled": "開啟", "models.check.failed": "失敗", "models.check.keys_status_count": "通過:{{count_passed}}個密鑰,失敗:{{count_failed}}個密鑰", - "models.check.model_status_summary": "{{provider}}: {{count_passed}} 個模型完成健康檢查(其中 {{count_partial}} 個模型用某些密鑰無法訪問),{{count_failed}} 個模型完全無法訪問。", + "models.check.model_status_failed": "{{count}} 個模型完全無法訪問", + "models.check.model_status_partial": "其中 {{count}} 個模型用某些密鑰無法訪問", + "models.check.model_status_passed": "{{count}} 個模型通過健康檢查", + "models.check.model_status_summary": "{{provider}}: {{summary}}", "models.check.no_api_keys": "未找到API密鑰,請先添加API密鑰。", "models.check.passed": "通過", "models.check.select_api_key": "選擇要使用的API密鑰:", @@ -1621,6 +1624,10 @@ "any.language": "任意語言", "button.translate": "翻譯", "close": "關閉", + "closed": "翻譯已關閉", + "copied": "翻譯內容已複製", + "empty": "翻譯內容為空", + "not.found": "未找到翻譯內容", "confirm": { "content": "翻譯後將覆蓋原文,是否繼續?", "title": "翻譯確認" diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index b5fafc827e..5e03167927 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -741,7 +741,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = useEffect(() => { textareaRef.current?.focus() - }, [assistant]) + }, [assistant, topic]) useEffect(() => { setTimeout(() => resizeTextArea(), 0) @@ -757,9 +757,14 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }, []) useEffect(() => { - window.addEventListener('focus', () => { + const onFocus = () => { + if (document.activeElement?.closest('.ant-modal')) { + return + } textareaRef.current?.focus() - }) + } + window.addEventListener('focus', onFocus) + return () => window.removeEventListener('focus', onFocus) }, []) useEffect(() => { @@ -898,10 +903,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = styles={{ textarea: TextareaStyle }} onFocus={(e: React.FocusEvent) => { setInputFocus(true) - const textArea = e.target - if (textArea) { - const length = textArea.value.length - textArea.setSelectionRange(length, length) + if (e.target.value.length === 0) { + e.target.setSelectionRange(0, 0) } }} onBlur={() => setInputFocus(false)} diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index fa3c2b9ff4..caf7d3f764 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -2,12 +2,35 @@ import { CheckOutlined } from '@ant-design/icons' import { useSettings } from '@renderer/hooks/useSettings' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { Collapse, message as antdMessage, Tooltip } from 'antd' +import { Lightbulb } from 'lucide-react' +import { motion } from 'motion/react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import BarLoader from 'react-spinners/BarLoader' import styled from 'styled-components' import Markdown from '../../Markdown/Markdown' + +// Define variants outside the component if they don't depend on component's props/state directly +// or inside if they do (though for this case, outside is fine). +const lightbulbVariants = { + thinking: { + opacity: [1, 0.2, 1], + transition: { + duration: 1.2, + ease: 'easeInOut', + times: [0, 0.5, 1], + repeat: Infinity + } + }, + idle: { + opacity: 1, + transition: { + duration: 0.3, // Smooth transition to idle state + ease: 'easeInOut' + } + } +} + interface Props { block: ThinkingMessageBlock } @@ -83,17 +106,25 @@ const ThinkingBlock: React.FC = ({ block }) => { size="small" onChange={() => setActiveKey((key) => (key ? '' : 'thought'))} className="message-thought-container" + expandIconPosition="end" items={[ { key: 'thought', label: ( + + + {t(isThinking ? 'chat.thinking' : 'chat.deeply_thought', { seconds: thinkingTimeSeconds })} - {isThinking && } + {/* {isThinking && } */} {!isThinking && ( = ({ block }) => { const CollapseContainer = styled(Collapse)` margin-bottom: 15px; + max-width: 960px; ` const MessageTitleLabel = styled.div` @@ -131,7 +163,7 @@ const MessageTitleLabel = styled.div` flex-direction: row; align-items: center; height: 22px; - gap: 15px; + gap: 4px; ` const ThinkingText = styled.span` diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index 12e0966c9e..c7233089bc 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -1,17 +1,8 @@ import type { RootState } from '@renderer/store' import { messageBlocksSelectors } from '@renderer/store/messageBlock' -import type { - ErrorMessageBlock, - FileMessageBlock, - ImageMessageBlock, - MainTextMessageBlock, - Message, - MessageBlock, - PlaceholderMessageBlock, - ThinkingMessageBlock, - TranslationMessageBlock -} from '@renderer/types/newMessage' +import type { ImageMessageBlock, MainTextMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' +import { AnimatePresence, motion } from 'motion/react' import React, { useMemo } from 'react' import { useSelector } from 'react-redux' import styled from 'styled-components' @@ -26,8 +17,41 @@ import ThinkingBlock from './ThinkingBlock' import ToolBlock from './ToolBlock' import TranslationBlock from './TranslationBlock' +interface AnimatedBlockWrapperProps { + children: React.ReactNode + enableAnimation: boolean +} + +const blockWrapperVariants = { + visible: { + opacity: 1, + x: 0, + transition: { duration: 0.3, type: 'spring', bounce: 0 } + }, + hidden: { + opacity: 0, + x: 10 + }, + static: { + opacity: 1, + x: 0, + transition: { duration: 0 } + } +} + +const AnimatedBlockWrapper: React.FC = ({ children, enableAnimation }) => { + return ( + + {children} + + ) +} + interface Props { - blocks: MessageBlock[] | string[] // 可以接收块ID数组或MessageBlock数组 + blocks: string[] // 可以接收块ID数组或MessageBlock数组 messageStatus?: Message['status'] message: Message } @@ -54,26 +78,30 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { // 根据blocks类型处理渲染数据 const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean) const groupedBlocks = useMemo(() => filterImageBlockGroups(renderedBlocks), [renderedBlocks]) - return ( - <> + {groupedBlocks.map((block) => { if (Array.isArray(block)) { + const groupKey = block.map((imageBlock) => imageBlock.id).join('-') return ( - imageBlock.id).join('-')}> - {block.map((imageBlock) => ( - - ))} - + + + {block.map((imageBlock) => ( + + ))} + + ) } + let blockComponent: React.ReactNode = null + switch (block.type) { case MessageBlockType.UNKNOWN: if (block.status === MessageBlockStatus.PROCESSING) { - return + blockComponent = } - return null + break case MessageBlockType.MAIN_TEXT: case MessageBlockType.CODE: { const mainTextBlock = block as MainTextMessageBlock @@ -82,7 +110,7 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { // No longer need to retrieve the full citation block here // const citationBlock = citationBlockId ? (blockEntities[citationBlockId] as CitationMessageBlock) : undefined - return ( + blockComponent = ( = ({ blocks, message }) => { role={message.role} /> ) + break } case MessageBlockType.IMAGE: - return + blockComponent = + break case MessageBlockType.FILE: - return + blockComponent = + break case MessageBlockType.TOOL: - return + blockComponent = + break case MessageBlockType.CITATION: - return + blockComponent = + break case MessageBlockType.ERROR: - return + blockComponent = + break case MessageBlockType.THINKING: - return - // case MessageBlockType.CODE: - // return + blockComponent = + break case MessageBlockType.TRANSLATION: - return + blockComponent = + break default: - // Cast block to any for console.warn to fix linter error console.warn('Unsupported block type in MessageBlockRenderer:', (block as any).type, block) - return null + break } + + return ( + + {blockComponent} + + ) })} - + ) } diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index fa685006a4..7a3ca793ff 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -8,6 +8,7 @@ import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { getMessageTitle } from '@renderer/services/MessagesService' import { translateText } from '@renderer/services/TranslateService' import { RootState } from '@renderer/store' +import { messageBlocksSelectors } from '@renderer/store/messageBlock' import type { Model } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types' import type { Message } from '@renderer/types/newMessage' @@ -22,7 +23,12 @@ import { } from '@renderer/utils/export' // import { withMessageThought } from '@renderer/utils/formats' import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown' -import { findImageBlocks, findMainTextBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find' +import { + findImageBlocks, + findMainTextBlocks, + findTranslationBlocks, + getMainTextContent +} from '@renderer/utils/messageUtils/find' import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' import { AtSign, Copy, Languages, Menu, RefreshCw, Save, Share, Split, ThumbsUp, Trash } from 'lucide-react' @@ -62,7 +68,8 @@ const MessageMenubar: FC = (props) => { resendUserMessageWithEdit, getTranslationUpdater, appendAssistantResponse, - editMessageBlocks + editMessageBlocks, + removeMessageBlock } = useMessageOperations(topic) const loading = useTopicLoading(topic) @@ -377,6 +384,12 @@ const MessageMenubar: FC = (props) => { [message, editMessage] ) + const blockEntities = useSelector(messageBlocksSelectors.selectEntities) + const hasTranslationBlocks = useMemo(() => { + const translationBlocks = findTranslationBlocks(message) + return translationBlocks.length > 0 + }, [message]) + return ( {message.role === 'user' && ( @@ -432,13 +445,52 @@ const MessageMenubar: FC = (props) => { label: item.emoji + ' ' + item.label, key: item.value, onClick: () => handleTranslate(item.value) - })) - // { - // TODO 删除翻译块可以放在翻译块内 - // label: '✖ ' + t('translate.close'), - // key: 'translate-close', - // onClick: () => editMessage(message.id, { translatedContent: undefined }) - // } + })), + ...(hasTranslationBlocks + ? [ + { type: 'divider' as const }, + { + label: '📋 ' + t('common.copy'), + key: 'translate-copy', + onClick: () => { + const translationBlocks = message.blocks + .map((blockId) => blockEntities[blockId]) + .filter((block) => block?.type === 'translation') + + if (translationBlocks.length > 0) { + const translationContent = translationBlocks + .map((block) => block?.content || '') + .join('\n\n') + .trim() + + if (translationContent) { + navigator.clipboard.writeText(translationContent) + window.message.success({ content: t('translate.copied'), key: 'translate-copy' }) + } else { + window.message.warning({ content: t('translate.empty'), key: 'translate-copy' }) + } + } + } + }, + { + label: '✖ ' + t('translate.close'), + key: 'translate-close', + onClick: () => { + const translationBlocks = message.blocks + .map((blockId) => blockEntities[blockId]) + .filter((block) => block?.type === 'translation') + .map((block) => block?.id) + + if (translationBlocks.length > 0) { + translationBlocks.forEach((blockId) => { + if (blockId) removeMessageBlock(message.id, blockId) + }) + window.message.success({ content: t('translate.closed'), key: 'translate-close' }) + } + } + } + ] + : []) ], onClick: (e) => e.domEvent.stopPropagation() }} diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx index d095ca7a27..5789571a9a 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList.tsx @@ -408,7 +408,6 @@ const StatusIndicator = styled.div<{ type: string }>` align-items: center; justify-content: center; font-size: 14px; - cursor: pointer; color: ${(props) => { switch (props.type) { case 'success': diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index dd0a32ac35..dd0844b2c7 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -8,7 +8,7 @@ import { useAllProviders, useProvider, useProviders } from '@renderer/hooks/useP import i18n from '@renderer/i18n' import { isOpenAIProvider } from '@renderer/providers/AiProvider/ProviderFactory' import { checkApi, formatApiKeys } from '@renderer/services/ApiService' -import { checkModelsHealth, ModelCheckStatus } from '@renderer/services/HealthCheckService' +import { checkModelsHealth, getModelCheckSummary } from '@renderer/services/HealthCheckService' import { isProviderSupportAuth } from '@renderer/services/ProviderService' import { Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' @@ -177,22 +177,11 @@ const ProviderSetting: FC = ({ provider: _provider }) => { } ) - // Show summary of results after checking - const failedModels = checkResults.filter((result) => result.status === ModelCheckStatus.FAILED) - const partialModels = checkResults.filter((result) => result.status === ModelCheckStatus.PARTIAL) - const successModels = checkResults.filter((result) => result.status === ModelCheckStatus.SUCCESS) - - // Display statistics of all model check results window.message.info({ key: 'health-check-summary', style: { marginTop: '3vh' }, - duration: 10, - content: t('settings.models.check.model_status_summary', { - provider: provider.name, - count_passed: successModels.length + partialModels.length, - count_partial: partialModels.length, - count_failed: failedModels.length - }) + duration: 5, + content: getModelCheckSummary(checkResults, provider.name) }) // Reset health check status @@ -235,8 +224,10 @@ const ProviderSetting: FC = ({ provider: _provider }) => { }) if (result?.validKeys) { - setApiKey(result.validKeys.join(',')) - updateProvider({ ...provider, apiKey: result.validKeys.join(',') }) + const newApiKey = result.validKeys.join(',') + setInputValue(newApiKey) + setApiKey(newApiKey) + updateProvider({ ...provider, apiKey: newApiKey }) } } else { setApiChecking(true) diff --git a/src/renderer/src/services/ApiService.ts b/src/renderer/src/services/ApiService.ts index 18d578dc5b..969b60463c 100644 --- a/src/renderer/src/services/ApiService.ts +++ b/src/renderer/src/services/ApiService.ts @@ -477,12 +477,13 @@ export async function checkApi(provider: Provider, model: Model) { } } - const AI = new AiProvider(provider) + const ai = new AiProvider(provider) - const { valid, error } = await AI.check(model) - - return { - valid, - error + // Try streaming check first + const result = await ai.check(model, true) + if (result.valid && !result.error) { + return result } + + return ai.check(model, false) } diff --git a/src/renderer/src/services/HealthCheckService.ts b/src/renderer/src/services/HealthCheckService.ts index bc6be27b4c..e631e1f40c 100644 --- a/src/renderer/src/services/HealthCheckService.ts +++ b/src/renderer/src/services/HealthCheckService.ts @@ -217,3 +217,33 @@ export async function checkModelsHealth( return results } + +export function getModelCheckSummary(results: ModelCheckResult[], providerName?: string): string { + const t = i18n.t + + // Show summary of results after checking + const failedModels = results.filter((result) => result.status === ModelCheckStatus.FAILED) + const partialModels = results.filter((result) => result.status === ModelCheckStatus.PARTIAL) + const successModels = results.filter((result) => result.status === ModelCheckStatus.SUCCESS) + + // Display statistics of all model check results + const summaryParts: string[] = [] + + if (failedModels.length > 0) { + summaryParts.push(t('settings.models.check.model_status_failed', { count: failedModels.length })) + } + if (successModels.length + partialModels.length > 0) { + summaryParts.push( + t('settings.models.check.model_status_passed', { count: successModels.length + partialModels.length }) + ) + } + if (partialModels.length > 0) { + summaryParts.push(t('settings.models.check.model_status_partial', { count: partialModels.length })) + } + + const summary = summaryParts.join(', ') + return t('settings.models.check.model_status_summary', { + provider: providerName ?? 'Unknown Provider', + summary + }) +} diff --git a/src/renderer/src/services/ModelService.ts b/src/renderer/src/services/ModelService.ts index 04867e1d8e..3ca5c485e5 100644 --- a/src/renderer/src/services/ModelService.ts +++ b/src/renderer/src/services/ModelService.ts @@ -83,12 +83,12 @@ export async function checkModel(provider: Provider, model: Model) { provider, model, async (ai, model) => { - const result = await ai.check(model, false) + // Try streaming check first + const result = await ai.check(model, true) if (result.valid && !result.error) { return result } - // Try streaming check - return ai.check(model, true) + return ai.check(model, false) }, ({ valid, error }) => ({ valid, error: error || null }) ) diff --git a/src/renderer/src/utils/messageUtils/find.ts b/src/renderer/src/utils/messageUtils/find.ts index fcd0a539e4..64e8beba05 100644 --- a/src/renderer/src/utils/messageUtils/find.ts +++ b/src/renderer/src/utils/messageUtils/find.ts @@ -6,7 +6,8 @@ import type { ImageMessageBlock, MainTextMessageBlock, Message, - ThinkingMessageBlock + ThinkingMessageBlock, + TranslationMessageBlock } from '@renderer/types/newMessage' import { MessageBlockType } from '@renderer/types/newMessage' @@ -30,6 +31,11 @@ export const findMainTextBlocks = (message: Message): MainTextMessageBlock[] => return textBlocks } +/** + * Finds all ThinkingMessageBlocks associated with a given message. + * @param message - The message object. + * @returns An array of ThinkingMessageBlocks (empty if none found). + */ export const findThinkingBlocks = (message: Message): ThinkingMessageBlock[] => { if (!message || !message.blocks || message.blocks.length === 0) { return [] @@ -95,6 +101,11 @@ export const getMainTextContent = (message: Message): string => { return textBlocks.map((block) => block.content).join('\n\n') } +/** + * Gets the concatenated content string from all ThinkingMessageBlocks of a message, in order. + * @param message + * @returns The concatenated content string or an empty string if no thinking blocks are found. + */ export const getThinkingContent = (message: Message): string => { const thinkingBlocks = findThinkingBlocks(message) return thinkingBlocks.map((block) => block.content).join('\n\n') @@ -131,6 +142,26 @@ export const findCitationBlocks = (message: Message): CitationMessageBlock[] => return citationBlocks } +/** + * Finds all TranslationMessageBlocks associated with a given message. + * @param message - The message object. + * @returns An array of TranslationMessageBlocks (empty if none found). + */ +export const findTranslationBlocks = (message: Message): TranslationMessageBlock[] => { + if (!message || !message.blocks || message.blocks.length === 0) { + return [] + } + const state = store.getState() + const translationBlocks: TranslationMessageBlock[] = [] + for (const blockId of message.blocks) { + const block = messageBlocksSelectors.selectById(state, blockId) + if (block && block.type === 'translation') { + translationBlocks.push(block as TranslationMessageBlock) + } + } + return translationBlocks +} + /** * Finds the WebSearchMessageBlock associated with a given message. * Assumes only one web search block per message. diff --git a/yarn.lock b/yarn.lock index 0ecf3b0fe4..3aceff4aa9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4488,6 +4488,7 @@ __metadata: lucide-react: "npm:^0.487.0" markdown-it: "npm:^14.1.0" mime: "npm:^4.0.4" + motion: "npm:^12.10.5" node-stream-zip: "npm:^1.15.0" npx-scope-finder: "npm:^1.2.0" officeparser: "npm:^4.1.1" @@ -8559,6 +8560,28 @@ __metadata: languageName: node linkType: hard +"framer-motion@npm:^12.10.5": + version: 12.10.5 + resolution: "framer-motion@npm:12.10.5" + dependencies: + motion-dom: "npm:^12.10.5" + motion-utils: "npm:^12.9.4" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/a24a44b7a1b21e347f93f9ec3c1218b9ebf2b2bc2883c26ab9951e19a62fdc2e03f80a57d0c78eaf408d098ed6f0fbcae48207313921c1f5462eb04296adf55b + languageName: node + linkType: hard + "fresh@npm:^2.0.0": version: 2.0.0 resolution: "fresh@npm:2.0.0" @@ -12414,6 +12437,43 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^12.10.5": + version: 12.10.5 + resolution: "motion-dom@npm:12.10.5" + dependencies: + motion-utils: "npm:^12.9.4" + checksum: 10c0/2c362eb94c941bbbc42288a6738b8c7a11933687b3b20aa6c9f2c3dedc69e5c7995c7348499b535f8abe5ed9ea81d88f9eb2f98b69f5012bcd80b8f7a64a1c2c + languageName: node + linkType: hard + +"motion-utils@npm:^12.9.4": + version: 12.9.4 + resolution: "motion-utils@npm:12.9.4" + checksum: 10c0/b6783babfd1282ad320585f7cdac9fe7a1f97b39e07d12a500d3709534441bd9d49b556fa1cd838d1bde188570d4ab6b4c5aa9d297f7f5aa9dc16d600c17afdc + languageName: node + linkType: hard + +"motion@npm:^12.10.5": + version: 12.10.5 + resolution: "motion@npm:12.10.5" + dependencies: + framer-motion: "npm:^12.10.5" + tslib: "npm:^2.4.0" + peerDependencies: + "@emotion/is-prop-valid": "*" + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@emotion/is-prop-valid": + optional: true + react: + optional: true + react-dom: + optional: true + checksum: 10c0/d8f1755a565332e6122e2079e164026b945eda34827170f2615999d74d3df2ad77984ca55304d7682b97a2ccf83c33508d234af619b043cd18056047884396d1 + languageName: node + linkType: hard + "mri@npm:1.1.4": version: 1.1.4 resolution: "mri@npm:1.1.4" @@ -12940,7 +13000,7 @@ __metadata: "openai@patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch": version: 4.96.0 - resolution: "openai@patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch::version=4.96.0&hash=645779" + resolution: "openai@patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch::version=4.96.0&hash=6bc976" dependencies: "@types/node": "npm:^18.11.18" "@types/node-fetch": "npm:^2.6.4" @@ -12959,7 +13019,7 @@ __metadata: optional: true bin: openai: bin/cli - checksum: 10c0/8c16fcf1812294220eddd4616e298c2af87398acb479287b7565548c8c1979c6d5c487fb7a9c25b0ac59f778de74c23d94ce1a34362c49260ae7a14acf22abc2 + checksum: 10c0/e50e4b9b60e94fadaca541cf2c36a12c55221555dd2ce977738e13978b7187504263f2e31b4641f2b6e70fce562b4e1fa2affd68caeca21248ddfa8847eeb003 languageName: node linkType: hard