mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-30 15:59:09 +08:00
Merge branch 'main' into fix/next-release-bugs
This commit is contained in:
commit
66939a5302
@ -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",
|
||||
|
||||
@ -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 (
|
||||
<Container>
|
||||
<Search size={24} />
|
||||
<StatusText>{t(text)}</StatusText>
|
||||
<BarLoader color="#1677ff" />
|
||||
</Container>
|
||||
<Searching
|
||||
variants={spinnerVariants}
|
||||
initial="defaultColor"
|
||||
animate={['defaultColor', 'dimmed']}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
repeat: Infinity,
|
||||
repeatType: 'reverse',
|
||||
ease: 'easeInOut'
|
||||
}}>
|
||||
<Search size={16} style={{ color: 'unset' }} />
|
||||
<span>{t(text)}</span>
|
||||
</Searching>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@ -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<Message> & Pick<Message, 'id'> = {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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": "翻訳確認"
|
||||
|
||||
@ -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": "Визуализация"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "可视化"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": "翻譯確認"
|
||||
|
||||
@ -741,7 +741,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [assistant])
|
||||
}, [assistant, topic])
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => resizeTextArea(), 0)
|
||||
@ -757,9 +757,14 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
styles={{ textarea: TextareaStyle }}
|
||||
onFocus={(e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
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)}
|
||||
|
||||
@ -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<Props> = ({ block }) => {
|
||||
size="small"
|
||||
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
|
||||
className="message-thought-container"
|
||||
expandIconPosition="end"
|
||||
items={[
|
||||
{
|
||||
key: 'thought',
|
||||
label: (
|
||||
<MessageTitleLabel>
|
||||
<motion.span
|
||||
style={{ height: '18px' }}
|
||||
variants={lightbulbVariants}
|
||||
animate={isThinking ? 'thinking' : 'idle'}
|
||||
initial="idle">
|
||||
<Lightbulb size={18} />
|
||||
</motion.span>
|
||||
<ThinkingText>
|
||||
{t(isThinking ? 'chat.thinking' : 'chat.deeply_thought', {
|
||||
seconds: thinkingTimeSeconds
|
||||
})}
|
||||
</ThinkingText>
|
||||
{isThinking && <BarLoader color="#9254de" />}
|
||||
{/* {isThinking && <BarLoader color="#9254de" />} */}
|
||||
{!isThinking && (
|
||||
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
|
||||
<ActionButton
|
||||
@ -124,6 +155,7 @@ const ThinkingBlock: React.FC<Props> = ({ 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`
|
||||
|
||||
@ -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<AnimatedBlockWrapperProps> = ({ children, enableAnimation }) => {
|
||||
return (
|
||||
<motion.div
|
||||
variants={blockWrapperVariants}
|
||||
initial={enableAnimation ? 'hidden' : 'static'}
|
||||
animate={enableAnimation ? 'visible' : 'static'}>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
blocks: MessageBlock[] | string[] // 可以接收块ID数组或MessageBlock数组
|
||||
blocks: string[] // 可以接收块ID数组或MessageBlock数组
|
||||
messageStatus?: Message['status']
|
||||
message: Message
|
||||
}
|
||||
@ -54,26 +78,30 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
// 根据blocks类型处理渲染数据
|
||||
const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean)
|
||||
const groupedBlocks = useMemo(() => filterImageBlockGroups(renderedBlocks), [renderedBlocks])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="sync">
|
||||
{groupedBlocks.map((block) => {
|
||||
if (Array.isArray(block)) {
|
||||
const groupKey = block.map((imageBlock) => imageBlock.id).join('-')
|
||||
return (
|
||||
<ImageBlockGroup key={block.map((imageBlock) => imageBlock.id).join('-')}>
|
||||
{block.map((imageBlock) => (
|
||||
<ImageBlock key={imageBlock.id} block={imageBlock as ImageMessageBlock} />
|
||||
))}
|
||||
</ImageBlockGroup>
|
||||
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
|
||||
<ImageBlockGroup>
|
||||
{block.map((imageBlock) => (
|
||||
<ImageBlock key={imageBlock.id} block={imageBlock as ImageMessageBlock} />
|
||||
))}
|
||||
</ImageBlockGroup>
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
let blockComponent: React.ReactNode = null
|
||||
|
||||
switch (block.type) {
|
||||
case MessageBlockType.UNKNOWN:
|
||||
if (block.status === MessageBlockStatus.PROCESSING) {
|
||||
return <PlaceholderBlock key={block.id} block={block as PlaceholderMessageBlock} />
|
||||
blockComponent = <PlaceholderBlock key={block.id} block={block} />
|
||||
}
|
||||
return null
|
||||
break
|
||||
case MessageBlockType.MAIN_TEXT:
|
||||
case MessageBlockType.CODE: {
|
||||
const mainTextBlock = block as MainTextMessageBlock
|
||||
@ -82,7 +110,7 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
// No longer need to retrieve the full citation block here
|
||||
// const citationBlock = citationBlockId ? (blockEntities[citationBlockId] as CitationMessageBlock) : undefined
|
||||
|
||||
return (
|
||||
blockComponent = (
|
||||
<MainTextBlock
|
||||
key={block.id}
|
||||
block={mainTextBlock}
|
||||
@ -91,30 +119,43 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
role={message.role}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
case MessageBlockType.IMAGE:
|
||||
return <ImageBlock key={block.id} block={block as ImageMessageBlock} />
|
||||
blockComponent = <ImageBlock key={block.id} block={block} />
|
||||
break
|
||||
case MessageBlockType.FILE:
|
||||
return <FileBlock key={block.id} block={block as FileMessageBlock} />
|
||||
blockComponent = <FileBlock key={block.id} block={block} />
|
||||
break
|
||||
case MessageBlockType.TOOL:
|
||||
return <ToolBlock key={block.id} block={block} />
|
||||
blockComponent = <ToolBlock key={block.id} block={block} />
|
||||
break
|
||||
case MessageBlockType.CITATION:
|
||||
return <CitationBlock key={block.id} block={block} />
|
||||
blockComponent = <CitationBlock key={block.id} block={block} />
|
||||
break
|
||||
case MessageBlockType.ERROR:
|
||||
return <ErrorBlock key={block.id} block={block as ErrorMessageBlock} />
|
||||
blockComponent = <ErrorBlock key={block.id} block={block} />
|
||||
break
|
||||
case MessageBlockType.THINKING:
|
||||
return <ThinkingBlock key={block.id} block={block as ThinkingMessageBlock} />
|
||||
// case MessageBlockType.CODE:
|
||||
// return <CodeBlock key={block.id} block={block as CodeMessageBlock} />
|
||||
blockComponent = <ThinkingBlock key={block.id} block={block} />
|
||||
break
|
||||
case MessageBlockType.TRANSLATION:
|
||||
return <TranslationBlock key={block.id} block={block as TranslationMessageBlock} />
|
||||
blockComponent = <TranslationBlock key={block.id} block={block} />
|
||||
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 (
|
||||
<AnimatedBlockWrapper
|
||||
key={block.type === MessageBlockType.UNKNOWN ? 'placeholder' : block.id}
|
||||
enableAnimation={message.status.includes('ing')}>
|
||||
{blockComponent}
|
||||
</AnimatedBlockWrapper>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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> = (props) => {
|
||||
resendUserMessageWithEdit,
|
||||
getTranslationUpdater,
|
||||
appendAssistantResponse,
|
||||
editMessageBlocks
|
||||
editMessageBlocks,
|
||||
removeMessageBlock
|
||||
} = useMessageOperations(topic)
|
||||
const loading = useTopicLoading(topic)
|
||||
|
||||
@ -377,6 +384,12 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
[message, editMessage]
|
||||
)
|
||||
|
||||
const blockEntities = useSelector(messageBlocksSelectors.selectEntities)
|
||||
const hasTranslationBlocks = useMemo(() => {
|
||||
const translationBlocks = findTranslationBlocks(message)
|
||||
return translationBlocks.length > 0
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
|
||||
{message.role === 'user' && (
|
||||
@ -432,13 +445,52 @@ const MessageMenubar: FC<Props> = (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()
|
||||
}}
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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<Props> = ({ 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<Props> = ({ 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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
)
|
||||
|
||||
@ -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.
|
||||
|
||||
64
yarn.lock
64
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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user