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