Merge branch 'main' into fix/next-release-bugs

This commit is contained in:
suyao 2025-05-11 16:45:29 +08:00
commit 66939a5302
No known key found for this signature in database
19 changed files with 448 additions and 114 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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
}
}

View File

@ -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"

View File

@ -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": "翻訳確認"

View File

@ -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": "Визуализация"
}
}
}
}

View File

@ -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": "可视化"
}
}
}
}

View File

@ -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": "翻譯確認"

View File

@ -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)}

View File

@ -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`

View File

@ -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>
)
}

View File

@ -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()
}}

View File

@ -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':

View File

@ -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)

View File

@ -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)
}

View File

@ -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
})
}

View File

@ -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 })
)

View File

@ -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.

View File

@ -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