Compare commits
3 Commits
v2
...
feat/messa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e79353fc | ||
|
|
93e972a5da | ||
|
|
12d08e4748 |
@@ -163,6 +163,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@keyframes fadeInWithBlur {
|
||||
from { opacity: 0; filter: blur(2px); }
|
||||
to { opacity: 1; filter: blur(0px); }
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ block, postProcess }) => {
|
||||
table: (props: any) => <Table {...props} blockId={block.id} />,
|
||||
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
|
||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
|
||||
p: (props) => {
|
||||
p: SmoothFade((props) => {
|
||||
const hasImage = props?.node?.children?.some((child: any) => child.tagName === 'img')
|
||||
if (hasImage) return <div {...props} />
|
||||
return <p {...props} />
|
||||
},
|
||||
svg: MarkdownSvgRenderer
|
||||
}, isStreaming),
|
||||
svg: MarkdownSvgRenderer,
|
||||
li: SmoothFade((props) => <li {...props} />, isStreaming),
|
||||
span: SmoothFade((props) => <span {...props} />, isStreaming)
|
||||
} as Partial<Components>
|
||||
}, [block.id])
|
||||
}, [block.id, isStreaming])
|
||||
|
||||
if (/<style\b[^>]*>/i.test(messageContent)) {
|
||||
components.style = MarkdownShadowDOMRenderer as any
|
||||
@@ -168,3 +172,23 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
|
||||
}
|
||||
|
||||
export default memo(Markdown)
|
||||
|
||||
const SmoothFade = (Comp: React.ElementType, isStreaming: boolean) => {
|
||||
const handleAnimationEnd = (e: React.AnimationEvent) => {
|
||||
// 动画结束后移除类名
|
||||
if (e.animationName === 'fadeInWithBlur') {
|
||||
e.currentTarget.classList.remove('animate-[fadeInWithBlur_500ms_ease-out_forwards]')
|
||||
e.currentTarget.classList.remove('opacity-0')
|
||||
}
|
||||
}
|
||||
return ({ children, ...rest }) => {
|
||||
return (
|
||||
<Comp
|
||||
{...rest}
|
||||
className={isStreaming ? 'animate-[fadeInWithBlur_500ms_ease-out_forwards] opacity-0' : ''}
|
||||
onAnimationEnd={handleAnimationEnd}>
|
||||
{children}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PlaceholderBlockProps> = ({ block }) => {
|
||||
if (block.status === MessageBlockStatus.PROCESSING && block.type === MessageBlockType.UNKNOWN) {
|
||||
const PlaceholderBlock: React.FC<PlaceholderBlockProps> = ({ status, type }) => {
|
||||
if (status === MessageBlockStatus.PROCESSING && type === MessageBlockType.UNKNOWN) {
|
||||
return (
|
||||
<MessageContentLoading>
|
||||
<Spinner color="current" variant="dots" />
|
||||
</MessageContentLoading>
|
||||
<div className="-mt-2">
|
||||
<Loader variant="terminal" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -215,15 +215,7 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
|
||||
})}
|
||||
{isProcessing && (
|
||||
<AnimatedBlockWrapper key="message-loading-placeholder" enableAnimation={true}>
|
||||
<PlaceholderBlock
|
||||
block={{
|
||||
id: `loading-${message.id}`,
|
||||
messageId: message.id,
|
||||
type: MessageBlockType.UNKNOWN,
|
||||
status: MessageBlockStatus.PROCESSING,
|
||||
createdAt: new Date().toISOString()
|
||||
}}
|
||||
/>
|
||||
<PlaceholderBlock type={MessageBlockType.UNKNOWN} status={MessageBlockStatus.PROCESSING} />
|
||||
</AnimatedBlockWrapper>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
374
src/renderer/src/ui/loader.tsx
Normal file
374
src/renderer/src/ui/loader.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { cn } from '@renderer/utils/index'
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-2 border-primary border-t-transparent',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('relative', sizeClasses[size], className)}>
|
||||
<div className="absolute h-full w-full">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full bg-primary"
|
||||
style={{
|
||||
top: '0',
|
||||
left: '50%',
|
||||
marginLeft: size === 'sm' ? '-0.75px' : size === 'lg' ? '-1.25px' : '-1px',
|
||||
transformOrigin: `${size === 'sm' ? '0.75px' : size === 'lg' ? '1.25px' : '1px'} ${size === 'sm' ? '10px' : size === 'lg' ? '14px' : '12px'}`,
|
||||
transform: `rotate(${i * 30}deg)`,
|
||||
opacity: 0,
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
height: barSizes[size].height,
|
||||
width: barSizes[size].width
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'size-4',
|
||||
md: 'size-5',
|
||||
lg: 'size-6'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', sizeClasses[size], className)}>
|
||||
<div className="absolute inset-0 animate-[thin-pulse_1.5s_ease-in-out_infinite] rounded-full border-2 border-primary" />
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseDotLoader({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'size-1',
|
||||
md: 'size-2',
|
||||
lg: 'size-3'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-[pulse-dot_1.2s_ease-in-out_infinite] rounded-full bg-primary',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('animate-[bounce-dots_1.4s_ease-in-out_infinite] rounded-full bg-primary', dotSizes[size])}
|
||||
style={{
|
||||
animationDelay: `${i * 160}ms`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('animate-[typing_1s_infinite] rounded-full bg-primary', dotSizes[size])}
|
||||
style={{
|
||||
animationDelay: `${i * 250}ms`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('flex items-center gap-0.5', containerSizes[size], className)}>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('animate-[wave_1s_ease-in-out_infinite] rounded-full bg-primary', barWidths[size])}
|
||||
style={{
|
||||
animationDelay: `${i * 100}ms`,
|
||||
height: heights[size][i]
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('flex', containerSizes[size], className)}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn('h-full animate-[wave-bars_1.2s_ease-in-out_infinite] bg-primary', barWidths[size])}
|
||||
style={{
|
||||
animationDelay: `${i * 0.2}s`
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('flex items-center space-x-1', containerSizes[size], className)}>
|
||||
<span className={cn('font-mono text-primary', textSizes[size])}>{'>'}</span>
|
||||
<div className={cn('animate-[blink_1s_step-end_infinite] bg-primary', cursorSizes[size])} />
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('animate-[text-blink_2s_ease-in-out_infinite] font-medium', textSizes[size], className)}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]',
|
||||
'bg-[200%_auto] bg-clip-text font-medium text-transparent',
|
||||
'animate-[shimmer_4s_infinite_linear]',
|
||||
textSizes[size],
|
||||
className
|
||||
)}>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('inline-flex items-center', className)}>
|
||||
<span className={cn('font-medium text-primary', textSizes[size])}>{text}</span>
|
||||
<span className="inline-flex">
|
||||
<span className="animate-[loading-dots_1.4s_infinite_0.2s] text-primary">.</span>
|
||||
<span className="animate-[loading-dots_1.4s_infinite_0.4s] text-primary">.</span>
|
||||
<span className="animate-[loading-dots_1.4s_infinite_0.6s] text-primary">.</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Loader({ variant = 'circular', size = 'md', text, className }: LoaderProps) {
|
||||
switch (variant) {
|
||||
case 'circular':
|
||||
return <CircularLoader size={size} className={className} />
|
||||
case 'classic':
|
||||
return <ClassicLoader size={size} className={className} />
|
||||
case 'pulse':
|
||||
return <PulseLoader size={size} className={className} />
|
||||
case 'pulse-dot':
|
||||
return <PulseDotLoader size={size} className={className} />
|
||||
case 'dots':
|
||||
return <DotsLoader size={size} className={className} />
|
||||
case 'typing':
|
||||
return <TypingLoader size={size} className={className} />
|
||||
case 'wave':
|
||||
return <WaveLoader size={size} className={className} />
|
||||
case 'bars':
|
||||
return <BarsLoader size={size} className={className} />
|
||||
case 'terminal':
|
||||
return <TerminalLoader size={size} className={className} />
|
||||
case 'text-blink':
|
||||
return <TextBlinkLoader text={text} size={size} className={className} />
|
||||
case 'text-shimmer':
|
||||
return <TextShimmerLoader text={text} size={size} className={className} />
|
||||
case 'loading-dots':
|
||||
return <TextDotsLoader text={text} size={size} className={className} />
|
||||
default:
|
||||
return <CircularLoader size={size} className={className} />
|
||||
}
|
||||
}
|
||||
|
||||
export { Loader }
|
||||
@@ -234,3 +234,4 @@ export * from './match'
|
||||
export * from './naming'
|
||||
export * from './sort'
|
||||
export * from './style'
|
||||
export { cn } from '@heroui/react'
|
||||
|
||||
Reference in New Issue
Block a user