dify
This commit is contained in:
21
dify/web/app/components/base/markdown-blocks/audio-block.tsx
Normal file
21
dify/web/app/components/base/markdown-blocks/audio-block.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview AudioBlock component for rendering audio elements in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the AudioGallery component to display audio players.
|
||||
*/
|
||||
import React, { memo } from 'react'
|
||||
import AudioGallery from '@/app/components/base/audio-gallery'
|
||||
|
||||
const AudioBlock: any = memo(({ node }: any) => {
|
||||
const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src)
|
||||
if (srcs.length === 0) {
|
||||
const src = node.properties?.src
|
||||
if (src)
|
||||
return <AudioGallery key={src} srcs={[src]} />
|
||||
return null
|
||||
}
|
||||
return <AudioGallery key={srcs.join()} srcs={srcs} />
|
||||
})
|
||||
AudioBlock.displayName = 'AudioBlock'
|
||||
|
||||
export default AudioBlock
|
||||
31
dify/web/app/components/base/markdown-blocks/button.tsx
Normal file
31
dify/web/app/components/base/markdown-blocks/button.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
import { isValidUrl } from './utils'
|
||||
const MarkdownButton = ({ node }: any) => {
|
||||
const { onSend } = useChatContext()
|
||||
const variant = node.properties.dataVariant
|
||||
const message = node.properties.dataMessage
|
||||
const link = node.properties.dataLink
|
||||
const size = node.properties.dataSize
|
||||
|
||||
return <Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('!h-auto min-h-8 select-none whitespace-normal !px-3')}
|
||||
onClick={() => {
|
||||
if (link && isValidUrl(link)) {
|
||||
window.open(link, '_blank')
|
||||
return
|
||||
}
|
||||
if(!message)
|
||||
return
|
||||
onSend?.(message)
|
||||
}}
|
||||
>
|
||||
<span className='text-[13px]'>{node.children[0]?.value || ''}</span>
|
||||
</Button>
|
||||
}
|
||||
MarkdownButton.displayName = 'MarkdownButton'
|
||||
|
||||
export default MarkdownButton
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import CodeBlock from './code-block'
|
||||
|
||||
const SAMPLE_CODE = `const greet = (name: string) => {
|
||||
return \`Hello, \${name}\`
|
||||
}
|
||||
|
||||
console.log(greet('Dify'))`
|
||||
|
||||
const CodeBlockDemo = ({
|
||||
language = 'typescript',
|
||||
}: {
|
||||
language?: string
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-text-tertiary">Code block</div>
|
||||
<CodeBlock
|
||||
className={`language-${language}`}
|
||||
>
|
||||
{SAMPLE_CODE}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/CodeBlock',
|
||||
component: CodeBlockDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Syntax highlighted code block with copy button and SVG toggle support.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
language: {
|
||||
control: 'radio',
|
||||
options: ['typescript', 'json', 'mermaid'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
language: 'typescript',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof CodeBlockDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const Mermaid: Story = {
|
||||
args: {
|
||||
language: 'mermaid',
|
||||
},
|
||||
render: ({ language }) => (
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<CodeBlock className={`language-${language}`}>
|
||||
{`graph TD
|
||||
Start --> Decision{User message?}
|
||||
Decision -->|Tool| ToolCall[Call web search]
|
||||
Decision -->|Respond| Answer[Compose draft]
|
||||
`}
|
||||
</CodeBlock>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
442
dify/web/app/components/base/markdown-blocks/code-block.tsx
Normal file
442
dify/web/app/components/base/markdown-blocks/code-block.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactEcharts from 'echarts-for-react'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||
import {
|
||||
atelierHeathDark,
|
||||
atelierHeathLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import CopyIcon from '@/app/components/base/copy-icon'
|
||||
import SVGBtn from '@/app/components/base/svg'
|
||||
import { Theme } from '@/types/app'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory
|
||||
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
|
||||
import ErrorBoundary from '@/app/components/base/markdown/error-boundary'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const Flowchart = dynamic(() => import('@/app/components/base/mermaid'), { ssr: false })
|
||||
|
||||
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
|
||||
const capitalizationLanguageNameMap: Record<string, string> = {
|
||||
sql: 'SQL',
|
||||
javascript: 'JavaScript',
|
||||
java: 'Java',
|
||||
typescript: 'TypeScript',
|
||||
vbscript: 'VBScript',
|
||||
css: 'CSS',
|
||||
html: 'HTML',
|
||||
xml: 'XML',
|
||||
php: 'PHP',
|
||||
python: 'Python',
|
||||
yaml: 'Yaml',
|
||||
mermaid: 'Mermaid',
|
||||
markdown: 'MarkDown',
|
||||
makefile: 'MakeFile',
|
||||
echarts: 'ECharts',
|
||||
shell: 'Shell',
|
||||
powershell: 'PowerShell',
|
||||
json: 'JSON',
|
||||
latex: 'Latex',
|
||||
svg: 'SVG',
|
||||
abc: 'ABC',
|
||||
}
|
||||
const getCorrectCapitalizationLanguageName = (language: string) => {
|
||||
if (!language)
|
||||
return 'Plain'
|
||||
|
||||
if (language in capitalizationLanguageNameMap)
|
||||
return capitalizationLanguageNameMap[language]
|
||||
|
||||
return language.charAt(0).toUpperCase() + language.substring(1)
|
||||
}
|
||||
|
||||
// **Add code block
|
||||
// Avoid error #185 (Maximum update depth exceeded.
|
||||
// This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
|
||||
// React limits the number of nested updates to prevent infinite loops.)
|
||||
// Reference A: https://reactjs.org/docs/error-decoder.html?invariant=185
|
||||
// Reference B1: https://react.dev/reference/react/memo
|
||||
// Reference B2: https://react.dev/reference/react/useMemo
|
||||
// ****
|
||||
// The original error that occurred in the streaming response during the conversation:
|
||||
// Error: Minified React error 185;
|
||||
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
|
||||
// or use the non-minified dev environment for full errors and additional helpful warnings.
|
||||
|
||||
// Define ECharts event parameter types
|
||||
type EChartsEventParams = {
|
||||
type: string;
|
||||
seriesIndex?: number;
|
||||
dataIndex?: number;
|
||||
name?: string;
|
||||
value?: any;
|
||||
currentIndex?: number; // Added for timeline events
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any) => {
|
||||
const { theme } = useTheme()
|
||||
const [isSVG, setIsSVG] = useState(true)
|
||||
const [chartState, setChartState] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [finalChartOption, setFinalChartOption] = useState<any>(null)
|
||||
const echartsRef = useRef<any>(null)
|
||||
const contentRef = useRef<string>('')
|
||||
const processedRef = useRef<boolean>(false) // Track if content was successfully processed
|
||||
const isInitialRenderRef = useRef<boolean>(true) // Track if this is initial render
|
||||
const chartInstanceRef = useRef<any>(null) // Direct reference to ECharts instance
|
||||
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null) // For debounce handling
|
||||
const finishedEventCountRef = useRef<number>(0) // Track finished event trigger count
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match?.[1]
|
||||
const languageShowName = getCorrectCapitalizationLanguageName(language || '')
|
||||
const isDarkMode = theme === Theme.dark
|
||||
|
||||
const echartsStyle = useMemo(() => ({
|
||||
height: '350px',
|
||||
width: '100%',
|
||||
}), [])
|
||||
|
||||
const echartsOpts = useMemo(() => ({
|
||||
renderer: 'canvas',
|
||||
width: 'auto',
|
||||
}) as any, [])
|
||||
|
||||
// Debounce resize operations
|
||||
const debouncedResize = useCallback(() => {
|
||||
if (resizeTimerRef.current)
|
||||
clearTimeout(resizeTimerRef.current)
|
||||
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
if (chartInstanceRef.current)
|
||||
chartInstanceRef.current.resize()
|
||||
resizeTimerRef.current = null
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
// Handle ECharts instance initialization
|
||||
const handleChartReady = useCallback((instance: any) => {
|
||||
chartInstanceRef.current = instance
|
||||
|
||||
// Force resize to ensure timeline displays correctly
|
||||
setTimeout(() => {
|
||||
if (chartInstanceRef.current)
|
||||
chartInstanceRef.current.resize()
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
// Store event handlers in useMemo to avoid recreating them
|
||||
const echartsEvents = useMemo(() => ({
|
||||
finished: (_params: EChartsEventParams) => {
|
||||
// Limit finished event frequency to avoid infinite loops
|
||||
finishedEventCountRef.current++
|
||||
if (finishedEventCountRef.current > 3) {
|
||||
// Stop processing after 3 times to avoid infinite loops
|
||||
return
|
||||
}
|
||||
|
||||
if (chartInstanceRef.current) {
|
||||
// Use debounced resize
|
||||
debouncedResize()
|
||||
}
|
||||
},
|
||||
}), [debouncedResize])
|
||||
|
||||
// Handle container resize for echarts
|
||||
useEffect(() => {
|
||||
if (language !== 'echarts' || !chartInstanceRef.current) return
|
||||
|
||||
const handleResize = () => {
|
||||
if (chartInstanceRef.current)
|
||||
// Use debounced resize
|
||||
debouncedResize()
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (resizeTimerRef.current)
|
||||
clearTimeout(resizeTimerRef.current)
|
||||
}
|
||||
}, [language, debouncedResize])
|
||||
// Process chart data when content changes
|
||||
useEffect(() => {
|
||||
// Only process echarts content
|
||||
if (language !== 'echarts') return
|
||||
|
||||
// Reset state when new content is detected
|
||||
if (!contentRef.current) {
|
||||
setChartState('loading')
|
||||
processedRef.current = false
|
||||
}
|
||||
|
||||
const newContent = String(children).replace(/\n$/, '')
|
||||
|
||||
// Skip if content hasn't changed
|
||||
if (contentRef.current === newContent) return
|
||||
contentRef.current = newContent
|
||||
|
||||
const trimmedContent = newContent.trim()
|
||||
if (!trimmedContent) return
|
||||
|
||||
// Detect if this is historical data (already complete)
|
||||
// Historical data typically comes as a complete code block with complete JSON
|
||||
const isCompleteJson
|
||||
= (trimmedContent.startsWith('{') && trimmedContent.endsWith('}')
|
||||
&& trimmedContent.split('{').length === trimmedContent.split('}').length)
|
||||
|| (trimmedContent.startsWith('[') && trimmedContent.endsWith(']')
|
||||
&& trimmedContent.split('[').length === trimmedContent.split(']').length)
|
||||
|
||||
// If the JSON structure looks complete, try to parse it right away
|
||||
if (isCompleteJson && !processedRef.current) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedContent)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
setFinalChartOption(parsed)
|
||||
setChartState('success')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func, sonarjs/code-eval
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
setChartState('success')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If we have a complete JSON structure but it doesn't parse,
|
||||
// it's likely an error rather than incomplete data
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, either the JSON isn't complete yet, or we failed to parse it
|
||||
// Check more conditions for streaming data
|
||||
const isIncomplete
|
||||
= trimmedContent.length < 5
|
||||
|| (trimmedContent.startsWith('{')
|
||||
&& (!trimmedContent.endsWith('}')
|
||||
|| trimmedContent.split('{').length !== trimmedContent.split('}').length))
|
||||
|| (trimmedContent.startsWith('[')
|
||||
&& (!trimmedContent.endsWith(']')
|
||||
|| trimmedContent.split('[').length !== trimmedContent.split('}').length))
|
||||
|| (trimmedContent.split('"').length % 2 !== 1)
|
||||
|| (trimmedContent.includes('{"') && !trimmedContent.includes('"}'))
|
||||
|
||||
// Only try to parse streaming data if it looks complete and hasn't been processed
|
||||
if (!isIncomplete && !processedRef.current) {
|
||||
let isValidOption = false
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedContent)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
setFinalChartOption(parsed)
|
||||
isValidOption = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func, sonarjs/code-eval
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
isValidOption = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Both parsing methods failed, but content looks complete
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidOption) {
|
||||
setChartState('success')
|
||||
processedRef.current = true
|
||||
}
|
||||
}
|
||||
}, [language, children])
|
||||
|
||||
// Cache rendered content to avoid unnecessary re-renders
|
||||
const renderCodeContent = useMemo(() => {
|
||||
const content = String(children).replace(/\n$/, '')
|
||||
switch (language) {
|
||||
case 'mermaid':
|
||||
return <Flowchart PrimitiveCode={content} theme={theme as 'light' | 'dark'} />
|
||||
case 'echarts': {
|
||||
// Loading state: show loading indicator
|
||||
if (chartState === 'loading') {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '350px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: isDarkMode ? 'var(--color-components-input-bg-normal)' : 'transparent',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
}}>
|
||||
{/* Rotating spinner that works in both light and dark modes */}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ animation: 'spin 1.5s linear infinite' }}>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<circle opacity="0.2" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family)',
|
||||
fontSize: '14px',
|
||||
}}>Chart loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state: show the chart
|
||||
if (chartState === 'success' && finalChartOption) {
|
||||
// Reset finished event counter
|
||||
finishedEventCountRef.current = 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minWidth: '300px',
|
||||
minHeight: '350px',
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
transition: 'background-color 0.3s ease',
|
||||
}}>
|
||||
<ErrorBoundary>
|
||||
<ReactEcharts
|
||||
ref={(e) => {
|
||||
if (e && isInitialRenderRef.current) {
|
||||
echartsRef.current = e
|
||||
isInitialRenderRef.current = false
|
||||
}
|
||||
}}
|
||||
option={finalChartOption}
|
||||
style={echartsStyle}
|
||||
theme={isDarkMode ? 'dark' : undefined}
|
||||
opts={echartsOpts}
|
||||
notMerge={false}
|
||||
lazyUpdate={false}
|
||||
onEvents={echartsEvents}
|
||||
onChartReady={handleChartReady}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state: show error message
|
||||
const errorOption = {
|
||||
title: {
|
||||
text: 'ECharts error - Wrong option.',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minWidth: '300px',
|
||||
minHeight: '350px',
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
transition: 'background-color 0.3s ease',
|
||||
}}>
|
||||
<ErrorBoundary>
|
||||
<ReactEcharts
|
||||
ref={echartsRef}
|
||||
option={errorOption}
|
||||
style={echartsStyle}
|
||||
theme={isDarkMode ? 'dark' : undefined}
|
||||
opts={echartsOpts}
|
||||
notMerge={true}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'svg':
|
||||
if (isSVG) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SVGRenderer content={content} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'abc':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<MarkdownMusic children={content} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
{...props}
|
||||
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
|
||||
customStyle={{
|
||||
paddingLeft: 12,
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: 'var(--color-components-input-bg-normal)',
|
||||
}}
|
||||
language={match?.[1]}
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}
|
||||
}, [children, language, isSVG, finalChartOption, props, theme, match, chartState, isDarkMode, echartsStyle, echartsOpts, handleChartReady, echartsEvents])
|
||||
|
||||
if (inline || !match)
|
||||
return <code {...props} className={className}>{children}</code>
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3'>
|
||||
<div className='system-xs-semibold-uppercase text-text-secondary'>{languageShowName}</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{language === 'svg' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
|
||||
<ActionButton>
|
||||
<CopyIcon content={String(children).replace(/\n$/, '')} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{renderCodeContent}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CodeBlock.displayName = 'CodeBlock'
|
||||
|
||||
export default CodeBlock
|
||||
267
dify/web/app/components/base/markdown-blocks/form.tsx
Normal file
267
dify/web/app/components/base/markdown-blocks/form.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
|
||||
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Select from '@/app/components/base/select'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import { formatDateForOutput } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
|
||||
enum DATA_FORMAT {
|
||||
TEXT = 'text',
|
||||
JSON = 'json',
|
||||
}
|
||||
enum SUPPORTED_TAGS {
|
||||
LABEL = 'label',
|
||||
INPUT = 'input',
|
||||
TEXTAREA = 'textarea',
|
||||
BUTTON = 'button',
|
||||
}
|
||||
enum SUPPORTED_TYPES {
|
||||
TEXT = 'text',
|
||||
PASSWORD = 'password',
|
||||
EMAIL = 'email',
|
||||
NUMBER = 'number',
|
||||
DATE = 'date',
|
||||
TIME = 'time',
|
||||
DATETIME = 'datetime',
|
||||
CHECKBOX = 'checkbox',
|
||||
SELECT = 'select',
|
||||
HIDDEN = 'hidden',
|
||||
}
|
||||
const MarkdownForm = ({ node }: any) => {
|
||||
const { onSend } = useChatContext()
|
||||
|
||||
const [formValues, setFormValues] = useState<{ [key: string]: any }>({})
|
||||
|
||||
useEffect(() => {
|
||||
const initialValues: { [key: string]: any } = {}
|
||||
node.children.forEach((child: any) => {
|
||||
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) {
|
||||
initialValues[child.properties.name]
|
||||
= (child.tagName === SUPPORTED_TAGS.INPUT && child.properties.type === SUPPORTED_TYPES.HIDDEN)
|
||||
? (child.properties.value || '')
|
||||
: child.properties.value
|
||||
}
|
||||
})
|
||||
setFormValues(initialValues)
|
||||
}, [node.children])
|
||||
|
||||
const getFormValues = (children: any) => {
|
||||
const values: { [key: string]: any } = {}
|
||||
children.forEach((child: any) => {
|
||||
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) {
|
||||
let value = formValues[child.properties.name]
|
||||
|
||||
if (child.tagName === SUPPORTED_TAGS.INPUT
|
||||
&& (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME)) {
|
||||
if (value && typeof value.format === 'function') {
|
||||
// Format date output consistently
|
||||
const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME
|
||||
value = formatDateForOutput(value, includeTime)
|
||||
}
|
||||
}
|
||||
|
||||
values[child.properties.name] = value
|
||||
}
|
||||
})
|
||||
return values
|
||||
}
|
||||
|
||||
const onSubmit = (e: any) => {
|
||||
e.preventDefault()
|
||||
const format = node.properties.dataFormat || DATA_FORMAT.TEXT
|
||||
const result = getFormValues(node.children)
|
||||
|
||||
if (format === DATA_FORMAT.JSON) {
|
||||
onSend?.(JSON.stringify(result))
|
||||
}
|
||||
else {
|
||||
const textResult = Object.entries(result)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')
|
||||
onSend?.(textResult)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form
|
||||
autoComplete="off"
|
||||
className='flex flex-col self-stretch'
|
||||
onSubmit={(e: any) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{node.children.filter((i: any) => i.type === 'element').map((child: any, index: number) => {
|
||||
if (child.tagName === SUPPORTED_TAGS.LABEL) {
|
||||
return (
|
||||
<label
|
||||
key={index}
|
||||
htmlFor={child.properties.for}
|
||||
className="system-md-semibold my-2 text-text-secondary"
|
||||
>
|
||||
{child.children[0]?.value || ''}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (child.tagName === SUPPORTED_TAGS.INPUT && Object.values(SUPPORTED_TYPES).includes(child.properties.type)) {
|
||||
if (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME) {
|
||||
return (
|
||||
<DatePicker
|
||||
key={index}
|
||||
value={formValues[child.properties.name]}
|
||||
needTimePicker={child.properties.type === SUPPORTED_TYPES.DATETIME}
|
||||
onChange={(date) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: date,
|
||||
}))
|
||||
}}
|
||||
onClear={() => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: undefined,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (child.properties.type === SUPPORTED_TYPES.TIME) {
|
||||
return (
|
||||
<TimePicker
|
||||
key={index}
|
||||
value={formValues[child.properties.name]}
|
||||
onChange={(time) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: time,
|
||||
}))
|
||||
}}
|
||||
onClear={() => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: undefined,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (child.properties.type === SUPPORTED_TYPES.CHECKBOX) {
|
||||
return (
|
||||
<div className='mt-2 flex h-6 items-center space-x-2' key={index}>
|
||||
<Checkbox
|
||||
key={index}
|
||||
checked={formValues[child.properties.name]}
|
||||
onCheck={() => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: !prevValues[child.properties.name],
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
<span>{child.properties.dataTip || child.properties['data-tip'] || ''}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (child.properties.type === SUPPORTED_TYPES.SELECT) {
|
||||
return (
|
||||
<Select
|
||||
key={index}
|
||||
allowSearch={false}
|
||||
className="w-full"
|
||||
items={(() => {
|
||||
let options = child.properties.dataOptions || child.properties['data-options'] || []
|
||||
if (typeof options === 'string') {
|
||||
try {
|
||||
options = JSON.parse(options)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to parse options:', e)
|
||||
options = []
|
||||
}
|
||||
}
|
||||
return options.map((option: string) => ({
|
||||
name: option,
|
||||
value: option,
|
||||
}))
|
||||
})()}
|
||||
defaultValue={formValues[child.properties.name]}
|
||||
onSelect={(item) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: item.value,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (child.properties.type === SUPPORTED_TYPES.HIDDEN) {
|
||||
return (
|
||||
<input
|
||||
key={index}
|
||||
type="hidden"
|
||||
name={child.properties.name}
|
||||
value={formValues[child.properties.name] || child.properties.value || ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
key={index}
|
||||
type={child.properties.type}
|
||||
name={child.properties.name}
|
||||
placeholder={child.properties.placeholder}
|
||||
value={formValues[child.properties.name]}
|
||||
onChange={(e) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: e.target.value,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (child.tagName === SUPPORTED_TAGS.TEXTAREA) {
|
||||
return (
|
||||
<Textarea
|
||||
key={index}
|
||||
name={child.properties.name}
|
||||
placeholder={child.properties.placeholder}
|
||||
value={formValues[child.properties.name]}
|
||||
onChange={(e) => {
|
||||
setFormValues(prevValues => ({
|
||||
...prevValues,
|
||||
[child.properties.name]: e.target.value,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (child.tagName === SUPPORTED_TAGS.BUTTON) {
|
||||
const variant = child.properties.dataVariant
|
||||
const size = child.properties.dataSize
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className='mt-4'
|
||||
key={index}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<span className='text-[13px]'>{child.children[0]?.value || ''}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return <p key={index}>Unsupported tag: {child.tagName}</p>
|
||||
})}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
MarkdownForm.displayName = 'MarkdownForm'
|
||||
export default MarkdownForm
|
||||
13
dify/web/app/components/base/markdown-blocks/img.tsx
Normal file
13
dify/web/app/components/base/markdown-blocks/img.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @fileoverview Img component for rendering <img> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the ImageGallery component to display images.
|
||||
*/
|
||||
import React from 'react'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
|
||||
const Img = ({ src }: any) => {
|
||||
return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div>
|
||||
}
|
||||
|
||||
export default Img
|
||||
20
dify/web/app/components/base/markdown-blocks/index.ts
Normal file
20
dify/web/app/components/base/markdown-blocks/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @fileoverview Barrel file for all markdown block components.
|
||||
* This allows for cleaner imports in other parts of the application.
|
||||
*/
|
||||
|
||||
export { default as AudioBlock } from './audio-block'
|
||||
export { default as CodeBlock } from './code-block'
|
||||
export * from './plugin-img'
|
||||
export * from './plugin-paragraph'
|
||||
export { default as Img } from './img'
|
||||
export { default as Paragraph } from './paragraph'
|
||||
export { default as Link } from './link'
|
||||
export { default as PreCode } from './pre-code'
|
||||
export { default as ScriptBlock } from './script-block'
|
||||
export { default as VideoBlock } from './video-block'
|
||||
|
||||
// Assuming these are also standalone components in this directory intended for Markdown rendering
|
||||
export { default as MarkdownButton } from './button'
|
||||
export { default as MarkdownForm } from './form'
|
||||
export { default as ThinkBlock } from './think-block'
|
||||
43
dify/web/app/components/base/markdown-blocks/link.tsx
Normal file
43
dify/web/app/components/base/markdown-blocks/link.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @fileoverview Link component for rendering <a> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Handles special rendering for "abbr:" type links for interactive chat actions.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import { isValidUrl } from './utils'
|
||||
|
||||
const Link = ({ node, children, ...props }: any) => {
|
||||
const { onSend } = useChatContext()
|
||||
const commonClassName = 'cursor-pointer underline !decoration-primary-700 decoration-dashed'
|
||||
if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) {
|
||||
const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1])
|
||||
|
||||
return <abbr className={commonClassName} onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value || ''}>{node.children[0]?.value || ''}</abbr>
|
||||
}
|
||||
else {
|
||||
const href = props.href || node.properties?.href
|
||||
if (href && /^#[a-zA-Z0-9_-]+$/.test(href.toString())) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault()
|
||||
// scroll to target element if exists within the answer container
|
||||
const answerContainer = e.currentTarget.closest('.chat-answer-container')
|
||||
|
||||
if (answerContainer) {
|
||||
const targetId = CSS.escape(href.toString().substring(1))
|
||||
const targetElement = answerContainer.querySelector(`[id="${targetId}"]`)
|
||||
if (targetElement)
|
||||
targetElement.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
return <a href={href} onClick={handleClick} className={commonClassName}>{children || 'ScrollView'}</a>
|
||||
}
|
||||
|
||||
if (!href || !isValidUrl(href))
|
||||
return <span>{children}</span>
|
||||
|
||||
return <a href={href} target="_blank" rel="noopener noreferrer" className={commonClassName}>{children || 'Download'}</a>
|
||||
}
|
||||
}
|
||||
|
||||
export default Link
|
||||
37
dify/web/app/components/base/markdown-blocks/music.tsx
Normal file
37
dify/web/app/components/base/markdown-blocks/music.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import abcjs from 'abcjs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import 'abcjs/abcjs-audio.css'
|
||||
|
||||
const MarkdownMusic = ({ children }: { children: React.ReactNode }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const controlsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && controlsRef.current) {
|
||||
if (typeof children === 'string') {
|
||||
const visualObjs = abcjs.renderAbc(containerRef.current, children, {
|
||||
add_classes: true, // Add classes to SVG elements for cursor tracking
|
||||
responsive: 'resize', // Make notation responsive
|
||||
})
|
||||
const synthControl = new abcjs.synth.SynthController()
|
||||
synthControl.load(controlsRef.current, {}, { displayPlay: true })
|
||||
const synth = new abcjs.synth.CreateSynth()
|
||||
const visualObj = visualObjs[0]
|
||||
synth.init({ visualObj }).then(() => {
|
||||
synthControl.setTune(visualObj, false)
|
||||
})
|
||||
containerRef.current.style.overflow = 'auto'
|
||||
}
|
||||
}
|
||||
}, [children])
|
||||
|
||||
return (
|
||||
<div style={{ minWidth: '100%', overflow: 'auto' }}>
|
||||
<div ref={containerRef} />
|
||||
<div ref={controlsRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
MarkdownMusic.displayName = 'MarkdownMusic'
|
||||
|
||||
export default MarkdownMusic
|
||||
27
dify/web/app/components/base/markdown-blocks/paragraph.tsx
Normal file
27
dify/web/app/components/base/markdown-blocks/paragraph.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @fileoverview Paragraph component for rendering <p> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Handles special rendering for paragraphs that directly contain an image.
|
||||
*/
|
||||
import React from 'react'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
|
||||
const Paragraph = (paragraph: any) => {
|
||||
const { node }: any = paragraph
|
||||
const children_node = node.children
|
||||
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[children_node[0].properties.src]} />
|
||||
{
|
||||
Array.isArray(paragraph.children) && paragraph.children.length > 1 && (
|
||||
<div className="mt-2">{paragraph.children.slice(1)}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p>{paragraph.children}</p>
|
||||
}
|
||||
|
||||
export default Paragraph
|
||||
48
dify/web/app/components/base/markdown-blocks/plugin-img.tsx
Normal file
48
dify/web/app/components/base/markdown-blocks/plugin-img.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @fileoverview Img component for rendering <img> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the ImageGallery component to display images.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { getMarkdownImageURL } from './utils'
|
||||
import { usePluginReadmeAsset } from '@/service/use-plugins'
|
||||
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
|
||||
|
||||
type ImgProps = {
|
||||
src: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
}
|
||||
|
||||
export const PluginImg: React.FC<ImgProps> = ({ src, pluginInfo }) => {
|
||||
const { pluginUniqueIdentifier, pluginId } = pluginInfo || {}
|
||||
const { data: assetData } = usePluginReadmeAsset({ plugin_unique_identifier: pluginUniqueIdentifier, file_name: src })
|
||||
const [blobUrl, setBlobUrl] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetData) {
|
||||
setBlobUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(assetData)
|
||||
setBlobUrl(objectUrl)
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [assetData])
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (blobUrl)
|
||||
return blobUrl
|
||||
|
||||
return getMarkdownImageURL(src, pluginId)
|
||||
}, [blobUrl, pluginId, src])
|
||||
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[imageUrl]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @fileoverview Paragraph component for rendering <p> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Handles special rendering for paragraphs that directly contain an image.
|
||||
*/
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { usePluginReadmeAsset } from '@/service/use-plugins'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import type { SimplePluginInfo } from '../markdown/react-markdown-wrapper'
|
||||
import { getMarkdownImageURL } from './utils'
|
||||
|
||||
type PluginParagraphProps = {
|
||||
pluginInfo?: SimplePluginInfo
|
||||
node?: any
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const PluginParagraph: React.FC<PluginParagraphProps> = ({ pluginInfo, node, children }) => {
|
||||
const { pluginUniqueIdentifier, pluginId } = pluginInfo || {}
|
||||
const childrenNode = node?.children as Array<any> | undefined
|
||||
const firstChild = childrenNode?.[0]
|
||||
const isImageParagraph = firstChild?.tagName === 'img'
|
||||
const imageSrc = isImageParagraph ? firstChild?.properties?.src : undefined
|
||||
|
||||
const { data: assetData } = usePluginReadmeAsset({
|
||||
plugin_unique_identifier: pluginUniqueIdentifier,
|
||||
file_name: isImageParagraph && imageSrc ? imageSrc : '',
|
||||
})
|
||||
|
||||
const [blobUrl, setBlobUrl] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetData) {
|
||||
setBlobUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(assetData)
|
||||
setBlobUrl(objectUrl)
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [assetData])
|
||||
|
||||
const imageUrl = useMemo(() => {
|
||||
if (blobUrl)
|
||||
return blobUrl
|
||||
|
||||
if (isImageParagraph && imageSrc)
|
||||
return getMarkdownImageURL(imageSrc, pluginId)
|
||||
|
||||
return ''
|
||||
}, [blobUrl, imageSrc, isImageParagraph, pluginId])
|
||||
|
||||
if (isImageParagraph) {
|
||||
const remainingChildren = Array.isArray(children) && children.length > 1 ? children.slice(1) : undefined
|
||||
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[imageUrl]} />
|
||||
{remainingChildren && (
|
||||
<div className="mt-2">{remainingChildren}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p>{children}</p>
|
||||
}
|
||||
21
dify/web/app/components/base/markdown-blocks/pre-code.tsx
Normal file
21
dify/web/app/components/base/markdown-blocks/pre-code.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview PreCode component for rendering <pre> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* This is a simple wrapper around the HTML <pre> element.
|
||||
*/
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
function PreCode(props: { children: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null)
|
||||
|
||||
return (
|
||||
<pre ref={ref}>
|
||||
<span
|
||||
className="copy-code-button"
|
||||
></span>
|
||||
{props.children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreCode
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @fileoverview ScriptBlock component for handling <script> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Note: Current implementation returns the script tag as a string, which might not execute as expected in React.
|
||||
* This behavior is preserved from the original implementation and may need review for security and functionality.
|
||||
*/
|
||||
import { memo } from 'react'
|
||||
|
||||
const ScriptBlock = memo(({ node }: any) => {
|
||||
const scriptContent = node.children[0]?.value || ''
|
||||
return `<script>${scriptContent}</script>`
|
||||
})
|
||||
ScriptBlock.displayName = 'ScriptBlock'
|
||||
|
||||
export default ScriptBlock
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import ThinkBlock from './think-block'
|
||||
import { ChatContextProvider } from '@/app/components/base/chat/chat/context'
|
||||
|
||||
const THOUGHT_TEXT = `
|
||||
Gather docs from knowledge base.
|
||||
Score snippets against query.
|
||||
[ENDTHINKFLAG]
|
||||
`
|
||||
|
||||
const ThinkBlockDemo = ({
|
||||
responding = false,
|
||||
}: {
|
||||
responding?: boolean
|
||||
}) => {
|
||||
const [isResponding, setIsResponding] = useState(responding)
|
||||
|
||||
return (
|
||||
<ChatContextProvider
|
||||
config={undefined}
|
||||
isResponding={isResponding}
|
||||
chatList={[]}
|
||||
showPromptLog={false}
|
||||
questionIcon={undefined}
|
||||
answerIcon={undefined}
|
||||
onSend={undefined}
|
||||
onRegenerate={undefined}
|
||||
onAnnotationEdited={undefined}
|
||||
onAnnotationAdded={undefined}
|
||||
onAnnotationRemoved={undefined}
|
||||
onFeedback={undefined}
|
||||
>
|
||||
<div className="flex w-full max-w-xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs uppercase tracking-[0.18em] text-text-tertiary">
|
||||
<span>Think block</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-divider-subtle bg-background-default px-3 py-1 text-xs font-medium text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => setIsResponding(prev => !prev)}
|
||||
>
|
||||
{isResponding ? 'Mark complete' : 'Simulate thinking'}
|
||||
</button>
|
||||
</div>
|
||||
<ThinkBlock data-think>
|
||||
<pre className="whitespace-pre-wrap text-sm text-text-secondary">
|
||||
{THOUGHT_TEXT}
|
||||
</pre>
|
||||
</ThinkBlock>
|
||||
</div>
|
||||
</ChatContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/ThinkBlock',
|
||||
component: ThinkBlockDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Expandable chain-of-thought block used in chat responses. Toggles between “thinking” and completed states.',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
responding: { control: 'boolean' },
|
||||
},
|
||||
args: {
|
||||
responding: false,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ThinkBlockDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
112
dify/web/app/components/base/markdown-blocks/think-block.tsx
Normal file
112
dify/web/app/components/base/markdown-blocks/think-block.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChatContext } from '../chat/chat/context'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const hasEndThink = (children: any): boolean => {
|
||||
if (typeof children === 'string')
|
||||
return children.includes('[ENDTHINKFLAG]')
|
||||
|
||||
if (Array.isArray(children))
|
||||
return children.some(child => hasEndThink(child))
|
||||
|
||||
if (children?.props?.children)
|
||||
return hasEndThink(children.props.children)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const removeEndThink = (children: any): any => {
|
||||
if (typeof children === 'string')
|
||||
return children.replace('[ENDTHINKFLAG]', '')
|
||||
|
||||
if (Array.isArray(children))
|
||||
return children.map(child => removeEndThink(child))
|
||||
|
||||
if (children?.props?.children) {
|
||||
return React.cloneElement(
|
||||
children,
|
||||
{
|
||||
...children.props,
|
||||
children: removeEndThink(children.props.children),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
const useThinkTimer = (children: any) => {
|
||||
const { isResponding } = useChatContext()
|
||||
const [startTime] = useState(() => Date.now())
|
||||
const [elapsedTime, setElapsedTime] = useState(0)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) return
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsedTime(Math.floor((Date.now() - startTime) / 100) / 10)
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
if (timerRef.current)
|
||||
clearInterval(timerRef.current)
|
||||
}
|
||||
}, [startTime, isComplete])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasEndThink(children) || !isResponding)
|
||||
setIsComplete(true)
|
||||
}, [children, isResponding])
|
||||
|
||||
return { elapsedTime, isComplete }
|
||||
}
|
||||
|
||||
type ThinkBlockProps = React.ComponentProps<'details'> & {
|
||||
'data-think'?: boolean
|
||||
}
|
||||
|
||||
const ThinkBlock = ({ children, ...props }: ThinkBlockProps) => {
|
||||
const { elapsedTime, isComplete } = useThinkTimer(children)
|
||||
const displayContent = removeEndThink(children)
|
||||
const { t } = useTranslation()
|
||||
const { 'data-think': isThink = false, className, open, ...rest } = props
|
||||
|
||||
if (!isThink)
|
||||
return (<details {...props}>{children}</details>)
|
||||
|
||||
return (
|
||||
<details
|
||||
{...rest}
|
||||
data-think={isThink}
|
||||
className={cn('group', className)}
|
||||
open={isComplete ? open : true}
|
||||
>
|
||||
<summary className="flex cursor-pointer select-none list-none items-center whitespace-nowrap pl-2 font-bold text-text-secondary">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<svg
|
||||
className="mr-2 h-3 w-3 transition-transform duration-500 group-open:rotate-90"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
{isComplete ? `${t('common.chat.thought')}(${elapsedTime.toFixed(1)}s)` : `${t('common.chat.thinking')}(${elapsedTime.toFixed(1)}s)`}
|
||||
</div>
|
||||
</summary>
|
||||
<div className="ml-2 border-l border-components-panel-border bg-components-panel-bg-alt p-3 text-text-secondary">
|
||||
{displayContent}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThinkBlock
|
||||
14
dify/web/app/components/base/markdown-blocks/utils.ts
Normal file
14
dify/web/app/components/base/markdown-blocks/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ALLOW_UNSAFE_DATA_SCHEME, MARKETPLACE_API_PREFIX } from '@/config'
|
||||
|
||||
export const isValidUrl = (url: string): boolean => {
|
||||
const validPrefixes = ['http:', 'https:', '//', 'mailto:']
|
||||
if (ALLOW_UNSAFE_DATA_SCHEME) validPrefixes.push('data:')
|
||||
return validPrefixes.some(prefix => url.startsWith(prefix))
|
||||
}
|
||||
|
||||
export const getMarkdownImageURL = (url: string, pathname?: string) => {
|
||||
const regex = /(^\.\/_assets|^_assets)/
|
||||
if (regex.test(url))
|
||||
return `${MARKETPLACE_API_PREFIX}${MARKETPLACE_API_PREFIX.endsWith('/') ? '' : '/'}plugins/${pathname ?? ''}${url.replace(regex, '/_assets')}`
|
||||
return url
|
||||
}
|
||||
21
dify/web/app/components/base/markdown-blocks/video-block.tsx
Normal file
21
dify/web/app/components/base/markdown-blocks/video-block.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview VideoBlock component for rendering video elements in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the VideoGallery component to display videos.
|
||||
*/
|
||||
import React, { memo } from 'react'
|
||||
import VideoGallery from '@/app/components/base/video-gallery'
|
||||
|
||||
const VideoBlock: any = memo(({ node }: any) => {
|
||||
const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src)
|
||||
if (srcs.length === 0) {
|
||||
const src = node.properties?.src
|
||||
if (src)
|
||||
return <VideoGallery key={src} srcs={[src]} />
|
||||
return null
|
||||
}
|
||||
return <VideoGallery key={srcs.join()} srcs={srcs} />
|
||||
})
|
||||
VideoBlock.displayName = 'VideoBlock'
|
||||
|
||||
export default VideoBlock
|
||||
Reference in New Issue
Block a user