Compare commits

..

6 Commits

Author SHA1 Message Date
MyPrototypeWhat
d7e79353fc feat: add fadeInWithBlur animation to Tailwind CSS and update Markdown component
- Introduced a new `fadeInWithBlur` keyframe animation in Tailwind CSS for enhanced visual effects.
- Removed inline fade animation styles from the `Markdown` component to streamline rendering.
- Updated the `SmoothFade` function to utilize the new animation, improving the user experience during content transitions.
2025-09-26 19:34:49 +08:00
MyPrototypeWhat
93e972a5da chore: remove @radix-ui/react-slot dependency and update utility functions
- Removed `@radix-ui/react-slot` dependency from package.json and corresponding entries in yarn.lock to streamline dependencies.
- Adjusted the `PlaceholderBlock` component's margin styling for improved layout.
- Refactored utility functions by exporting `cn` from `@heroui/react`, enhancing class name management.
2025-09-26 19:17:19 +08:00
MyPrototypeWhat
12d08e4748 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.
2025-09-26 17:46:51 +08:00
MyPrototypeWhat
52a980f751 fix(websearch): handle blocked domains conditionally in web search (#10374)
fix(websearch): handle blocked domains conditionally in web search configurations

- Updated the handling of blocked domains in both Google Vertex and Anthropic web search configurations to only include them if they are present, improving robustness and preventing unnecessary parameters from being passed.
2025-09-26 12:10:28 +08:00
kangfenmao
3b7ab2aec8 chore: remove cherryin provider references and update versioning
- Commented out all references to the 'cherryin' provider in configuration files.
- Updated the version in the persisted reducer from 157 to 158.
- Added migration logic to remove 'cherryin' from the state during version 158 migration.
2025-09-26 10:36:17 +08:00
Zhaokun
d41e239b89 Fix slash newline (#10305)
* Fix slash menu Shift+Enter newline

* fix: enable Shift+Enter newline in rich editor with slash commands

Fixed an issue where users couldn't create new lines using Shift+Enter when
slash command menu (/foo) was active. The problem was caused by globa
keyboard event handlers intercepting all Enter key variants.

Changes:
 - Allow Shift+Enter to pass through QuickPanel event handling
 - Add Shift+Enter detection in CommandListPopover to return false
 - Implement fallback Shift+Enter handling in command suggestion render
 - Remove unused import in AppUpdater.ts
 - Convert Chinese comments to English in QuickPanel
- Add test coverage for command suggestion functionality

---------

Co-authored-by: Zhaokun Zhang <zhaokunzhang@Zhaokuns-Air.lan>
2025-09-25 22:07:10 +01:00
17 changed files with 509 additions and 60 deletions

View File

@@ -134,9 +134,10 @@ export async function buildStreamTextParams(
if (aiSdkProviderId === 'google-vertex') {
tools.google_search = vertex.tools.googleSearch({}) as ProviderDefinedTool
} else if (aiSdkProviderId === 'google-vertex-anthropic') {
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
tools.web_search = vertexAnthropic.tools.webSearch_20250305({
maxUses: webSearchConfig.maxResults,
blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains)
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
}) as ProviderDefinedTool
}
}

View File

@@ -61,9 +61,10 @@ export function buildProviderBuiltinWebSearchConfig(
}
}
case 'anthropic': {
const blockedDomains = mapRegexToPatterns(webSearchConfig.excludeDomains)
const anthropicSearchOptions: AnthropicSearchConfig = {
maxUses: webSearchConfig.maxResults,
blockedDomains: mapRegexToPatterns(webSearchConfig.excludeDomains)
blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined
}
return {
anthropic: anthropicSearchOptions

View File

@@ -163,6 +163,13 @@
}
}
@layer components {
@keyframes fadeInWithBlur {
from { opacity: 0; filter: blur(2px); }
to { opacity: 1; filter: blur(0px); }
}
}
:root {
background-color: unset;
}

View File

@@ -457,7 +457,13 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
// 面板可见且未折叠时:拦截所有 Enter 变体;
// 纯 Enter 选择项,带修饰键仅拦截不处理
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
if (e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Don't prevent default or stop propagation - let it create a newline
setIsMouseOver(false)
break
}
if (e.ctrlKey || e.metaKey || e.altKey) {
e.preventDefault()
e.stopPropagation()
setIsMouseOver(false)

View File

@@ -87,6 +87,9 @@ const CommandListPopover = ({
return true
case 'Enter':
if (event.shiftKey) {
return false
}
event.preventDefault()
if (items[internalSelectedIndex]) {
selectItem(internalSelectedIndex)

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { commandSuggestion } from '../command'
describe('commandSuggestion render', () => {
it('has render function', () => {
expect(commandSuggestion.render).toBeDefined()
expect(typeof commandSuggestion.render).toBe('function')
})
it('render function returns object with onKeyDown', () => {
const renderResult = commandSuggestion.render?.()
expect(renderResult).toBeDefined()
expect(renderResult?.onKeyDown).toBeDefined()
expect(typeof renderResult?.onKeyDown).toBe('function')
})
})

View File

@@ -628,13 +628,34 @@ export const commandSuggestion: Omit<SuggestionOptions<Command, MentionNodeAttrs
},
onKeyDown: (props) => {
// Let CommandListPopover handle events first
const popoverHandled = component.ref?.onKeyDown?.(props.event)
if (popoverHandled) {
return true
}
// Handle Shift+Enter for newline when popover doesn't handle it
if (props.event.key === 'Enter' && props.event.shiftKey) {
props.event.preventDefault()
// Close the suggestion menu
if (cleanup) cleanup()
component.destroy()
// Use the view from SuggestionKeyDownProps to insert newline
const { view } = props
const { state, dispatch } = view
const { tr } = state
tr.insertText('\n')
dispatch(tr)
return true
}
if (props.event.key === 'Escape') {
if (cleanup) cleanup()
component.destroy()
return true
}
return component.ref?.onKeyDown(props.event)
return false
},
onExit: () => {

View File

@@ -25,7 +25,7 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
// Default quick assistant model
glm45FlashModel
],
cherryin: [],
// cherryin: [],
vertexai: [],
'302ai': [
{

View File

@@ -78,16 +78,16 @@ export const CHERRYAI_PROVIDER: SystemProvider = {
}
export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> = {
cherryin: {
id: 'cherryin',
name: 'CherryIN',
type: 'openai',
apiKey: '',
apiHost: 'https://open.cherryin.ai',
models: [],
isSystem: true,
enabled: true
},
// cherryin: {
// id: 'cherryin',
// name: 'CherryIN',
// type: 'openai',
// apiKey: '',
// apiHost: 'https://open.cherryin.ai',
// models: [],
// isSystem: true,
// enabled: true
// },
silicon: {
id: 'silicon',
name: 'Silicon',
@@ -708,17 +708,17 @@ type ProviderUrls = {
}
export const PROVIDER_URLS: Record<SystemProviderId, ProviderUrls> = {
cherryin: {
api: {
url: 'https://open.cherryin.ai'
},
websites: {
official: 'https://open.cherryin.ai',
apiKey: 'https://open.cherryin.ai/console/token',
docs: 'https://open.cherryin.ai',
models: 'https://open.cherryin.ai/pricing'
}
},
// cherryin: {
// api: {
// url: 'https://open.cherryin.ai'
// },
// websites: {
// official: 'https://open.cherryin.ai',
// apiKey: 'https://open.cherryin.ai/console/token',
// docs: 'https://open.cherryin.ai',
// models: 'https://open.cherryin.ai/pricing'
// }
// },
ph8: {
api: {
url: 'https://ph8.co'

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 157,
version: 158,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},

View File

@@ -2539,6 +2539,15 @@ const migrateConfig = {
logger.error('migrate 157 error', error as Error)
return state
}
},
'158': (state: RootState) => {
try {
state.llm.providers = state.llm.providers.filter((provider) => provider.id !== 'cherryin')
return state
} catch (error) {
logger.error('migrate 158 error', error as Error)
return state
}
}
}

View File

@@ -269,7 +269,7 @@ export type Provider = {
}
export const SystemProviderIds = {
cherryin: 'cherryin',
// cherryin: 'cherryin',
silicon: 'silicon',
aihubmix: 'aihubmix',
ocoolai: 'ocoolai',

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

View File

@@ -234,3 +234,4 @@ export * from './match'
export * from './naming'
export * from './sort'
export * from './style'
export { cn } from '@heroui/react'