From 12d08e47483f5bc2f26bfaa7d6b644cd44a61744 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Fri, 26 Sep 2025 17:46:51 +0800 Subject: [PATCH] feat: add Radix UI slot component and enhance Markdown rendering - Added `@radix-ui/react-slot` dependency to package.json for improved component composition. - Introduced a new `Loader` component with various loading styles to enhance user experience during asynchronous operations. - Updated `PlaceholderBlock` to utilize the new `Loader` component, improving loading state representation. - Enhanced `Markdown` component to support smooth fade animations based on streaming status, improving visual feedback during content updates. - Refactored utility functions to include a new `cn` function for class name merging, streamlining component styling. --- package.json | 1 + .../src/pages/home/Markdown/Markdown.tsx | 44 +- .../home/Messages/Blocks/PlaceholderBlock.tsx | 27 +- .../src/pages/home/Messages/Blocks/index.tsx | 10 +- src/renderer/src/ui/loader.tsx | 375 ++++++++++++++++++ src/renderer/src/utils/index.ts | 6 + yarn.lock | 29 ++ 7 files changed, 460 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/ui/loader.tsx diff --git a/package.json b/package.json index 6e5ab73a8c..4f33bd2b59 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@playwright/test": "^1.52.0", + "@radix-ui/react-slot": "^1.2.3", "@reduxjs/toolkit": "^2.2.5", "@shikijs/markdown-it": "^3.12.0", "@swc/plugin-styled-components": "^8.0.4", diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index f2d65e9a7c..2ccf3e51f7 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -12,6 +12,7 @@ import { removeSvgEmptyLines } from '@renderer/utils/formats' import { processLatexBrackets } from '@renderer/utils/markdown' import { isEmpty } from 'lodash' import { type FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown' import rehypeKatex from 'rehype-katex' @@ -64,6 +65,8 @@ const Markdown: FC = ({ block, postProcess }) => { initialText: block.content }) + const isStreaming = block.status === 'streaming' + useEffect(() => { const newContent = block.content || '' const oldContent = prevContentRef.current || '' @@ -85,9 +88,8 @@ const Markdown: FC = ({ block, postProcess }) => { prevBlockIdRef.current = block.id // 更新 stream 状态 - const isStreaming = block.status === 'streaming' setIsStreamDone(!isStreaming) - }, [block.content, block.id, block.status, addChunk, reset]) + }, [block.content, block.id, block.status, addChunk, reset, isStreaming]) const remarkPlugins = useMemo(() => { const plugins = [ @@ -130,14 +132,16 @@ const Markdown: FC = ({ block, postProcess }) => { table: (props: any) => , img: (props: any) => , pre: (props: any) =>
,
-      p: (props) => {
+      p: SmoothFade((props) => {
         const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
         if (hasImage) return 
return

- }, - svg: MarkdownSvgRenderer + }, isStreaming), + svg: MarkdownSvgRenderer, + li: SmoothFade((props) =>

  • , isStreaming), + span: SmoothFade((props) => , isStreaming) } as Partial - }, [block.id]) + }, [block.id, isStreaming]) if (/]*>/i.test(messageContent)) { components.style = MarkdownShadowDOMRenderer as any @@ -150,6 +154,7 @@ const Markdown: FC = ({ block, postProcess }) => { return (
    + = ({ block, postProcess }) => { } export default memo(Markdown) + +const fadeStyle = ` + @keyframes fadeIn { + from { opacity: 0; filter: blur(2px); } + to { opacity: 1; filter: blur(0px); } + } +` + +const SmoothFade = (Comp: React.ElementType, isStreaming: boolean) => { + const handleAnimationEnd = (e: React.AnimationEvent) => { + // 动画结束后移除类名 + if (e.animationName === 'fadeIn') { + e.currentTarget.classList.remove('animate-[fadeIn_500ms_ease-out_forwards]') + e.currentTarget.classList.remove('opacity-0') + } + } + return ({ children, ...rest }) => { + return ( + + {children} + + ) + } +} diff --git a/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx index 7682ae2343..6e4ee3d9d5 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/PlaceholderBlock.tsx @@ -1,27 +1,20 @@ -import { Spinner } from '@heroui/react' -import { MessageBlockStatus, MessageBlockType, type PlaceholderMessageBlock } from '@renderer/types/newMessage' +import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage' +import { Loader } from '@renderer/ui/loader' import React from 'react' -import styled from 'styled-components' interface PlaceholderBlockProps { - block: PlaceholderMessageBlock + status: MessageBlockStatus + type: MessageBlockType } -const PlaceholderBlock: React.FC = ({ block }) => { - if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) { +const PlaceholderBlock: React.FC = ({ status, type }) => { + if (status === MessageBlockStatus.PROCESSING && type === MessageBlockType.UNKNOWN) { return ( - - - +
    + +
    ) } return null } -const MessageContentLoading = styled.div` - display: flex; - flex-direction: row; - align-items: center; - height: 32px; - margin-top: -5px; - margin-bottom: 5px; -` + export default React.memo(PlaceholderBlock) diff --git a/src/renderer/src/pages/home/Messages/Blocks/index.tsx b/src/renderer/src/pages/home/Messages/Blocks/index.tsx index 0e2d318e1e..ff4c36c5e9 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/index.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/index.tsx @@ -215,15 +215,7 @@ const MessageBlockRenderer: React.FC = ({ blocks, message }) => { })} {isProcessing && ( - + )} diff --git a/src/renderer/src/ui/loader.tsx b/src/renderer/src/ui/loader.tsx new file mode 100644 index 0000000000..0aac78891d --- /dev/null +++ b/src/renderer/src/ui/loader.tsx @@ -0,0 +1,375 @@ +import { cn } from '@renderer/utils/index' +import React from 'react' + +export interface LoaderProps { + variant?: + | 'circular' + | 'classic' + | 'pulse' + | 'pulse-dot' + | 'dots' + | 'typing' + | 'wave' + | 'bars' + | 'terminal' + | 'text-blink' + | 'text-shimmer' + | 'loading-dots' + size?: 'sm' | 'md' | 'lg' + text?: string + className?: string +} + +export function CircularLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const sizeClasses = { + sm: 'size-4', + md: 'size-5', + lg: 'size-6' + } + + return ( +
    + Loading +
    + ) +} + +export function ClassicLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const sizeClasses = { + sm: 'size-4', + md: 'size-5', + lg: 'size-6' + } + + const barSizes = { + sm: { height: '6px', width: '1.5px' }, + md: { height: '8px', width: '2px' }, + lg: { height: '10px', width: '2.5px' } + } + + return ( +
    +
    + {[...Array(12)].map((_, i) => ( +
    + ))} +
    + Loading +
    + ) +} + +export function PulseLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const sizeClasses = { + sm: 'size-4', + md: 'size-5', + lg: 'size-6' + } + + return ( +
    +
    + Loading +
    + ) +} + +export function PulseDotLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const sizeClasses = { + sm: 'size-1', + md: 'size-2', + lg: 'size-3' + } + + return ( +
    + Loading +
    + ) +} + +export function DotsLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const dotSizes = { + sm: 'h-1.5 w-1.5', + md: 'h-2 w-2', + lg: 'h-2.5 w-2.5' + } + + const containerSizes = { + sm: 'h-4', + md: 'h-5', + lg: 'h-6' + } + + return ( +
    + {[...Array(3)].map((_, i) => ( +
    + ))} + Loading +
    + ) +} + +export function TypingLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const dotSizes = { + sm: 'h-1 w-1', + md: 'h-1.5 w-1.5', + lg: 'h-2 w-2' + } + + const containerSizes = { + sm: 'h-4', + md: 'h-5', + lg: 'h-6' + } + + return ( +
    + {[...Array(3)].map((_, i) => ( +
    + ))} + Loading +
    + ) +} + +export function WaveLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const barWidths = { + sm: 'w-0.5', + md: 'w-0.5', + lg: 'w-1' + } + + const containerSizes = { + sm: 'h-4', + md: 'h-5', + lg: 'h-6' + } + + const heights = { + sm: ['6px', '9px', '12px', '9px', '6px'], + md: ['8px', '12px', '16px', '12px', '8px'], + lg: ['10px', '15px', '20px', '15px', '10px'] + } + + return ( +
    + {[...Array(5)].map((_, i) => ( +
    + ))} + Loading +
    + ) +} + +export function BarsLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const barWidths = { + sm: 'w-1', + md: 'w-1.5', + lg: 'w-2' + } + + const containerSizes = { + sm: 'h-4 gap-1', + md: 'h-5 gap-1.5', + lg: 'h-6 gap-2' + } + + return ( +
    + {[...Array(3)].map((_, i) => ( +
    + ))} + Loading +
    + ) +} + +export function TerminalLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) { + const cursorSizes = { + sm: 'h-3 w-1.5', + md: 'h-4 w-2', + lg: 'h-5 w-2.5' + } + + const textSizes = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base' + } + + const containerSizes = { + sm: 'h-4', + md: 'h-5', + lg: 'h-6' + } + + return ( +
    + {'>'} +
    + Loading +
    + ) +} + +export function TextBlinkLoader({ + text = 'Thinking', + className, + size = 'md' +}: { + text?: string + className?: string + size?: 'sm' | 'md' | 'lg' +}) { + const textSizes = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base' + } + + return ( +
    + {text} +
    + ) +} + +export function TextShimmerLoader({ + text = 'Thinking', + className, + size = 'md' +}: { + text?: string + className?: string + size?: 'sm' | 'md' | 'lg' +}) { + const textSizes = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base' + } + + return ( +
    + {text} +
    + ) +} + +export function TextDotsLoader({ + className, + text = 'Thinking', + size = 'md' +}: { + className?: string + text?: string + size?: 'sm' | 'md' | 'lg' +}) { + const textSizes = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base' + } + + return ( +
    + {text} + + . + . + . + +
    + ) +} + +function Loader({ variant = 'circular', size = 'md', text, className }: LoaderProps) { + switch (variant) { + case 'circular': + return + case 'classic': + return + case 'pulse': + return + case 'pulse-dot': + return + case 'dots': + return + case 'typing': + return + case 'wave': + return + case 'bars': + return + case 'terminal': + return + case 'text-blink': + return + case 'text-shimmer': + return + case 'loading-dots': + return + default: + return + } +} + +export { Loader } diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index 64f943946a..f46de2de1d 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -1,7 +1,9 @@ import { loggerService } from '@logger' import { Model, ModelType, Provider } from '@renderer/types' import { ModalFuncProps } from 'antd' +import { type ClassValue, clsx } from 'clsx' import { isEqual } from 'lodash' +import { twMerge } from 'tailwind-merge' import { v4 as uuidv4 } from 'uuid' const logger = loggerService.withContext('Utils') @@ -224,6 +226,10 @@ export function uniqueObjectArray(array: T[]): T[] { return array.filter((obj, index, self) => index === self.findIndex((t) => isEqual(t, obj))) } +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + export * from './api' export * from './collection' export * from './dataLimit' diff --git a/yarn.lock b/yarn.lock index 192c8e2076..0c6d03d7bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7396,6 +7396,34 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-compose-refs@npm:1.1.2": + version: 1.1.2 + resolution: "@radix-ui/react-compose-refs@npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/d36a9c589eb75d634b9b139c80f916aadaf8a68a7c1c4b8c6c6b88755af1a92f2e343457042089f04cc3f23073619d08bb65419ced1402e9d4e299576d970771 + languageName: node + linkType: hard + +"@radix-ui/react-slot@npm:^1.2.3": + version: 1.2.3 + resolution: "@radix-ui/react-slot@npm:1.2.3" + dependencies: + "@radix-ui/react-compose-refs": "npm:1.1.2" + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10c0/5913aa0d760f505905779515e4b1f0f71a422350f077cc8d26d1aafe53c97f177fec0e6d7fbbb50d8b5e498aa9df9f707ca75ae3801540c283b26b0136138eef + languageName: node + linkType: hard + "@rc-component/async-validator@npm:^5.0.3": version: 5.0.4 resolution: "@rc-component/async-validator@npm:5.0.4" @@ -13253,6 +13281,7 @@ __metadata: "@opentelemetry/sdk-trace-node": "npm:^2.0.0" "@opentelemetry/sdk-trace-web": "npm:^2.0.0" "@playwright/test": "npm:^1.52.0" + "@radix-ui/react-slot": "npm:^1.2.3" "@reduxjs/toolkit": "npm:^2.2.5" "@shikijs/markdown-it": "npm:^3.12.0" "@strongtz/win32-arm64-msvc": "npm:^0.4.7"