dify
This commit is contained in:
45
dify/web/app/components/base/action-button/index.css
Normal file
45
dify/web/app/components/base/action-button/index.css
Normal file
@@ -0,0 +1,45 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.action-btn {
|
||||
@apply inline-flex justify-center items-center cursor-pointer text-text-tertiary hover:text-text-secondary hover:bg-state-base-hover
|
||||
}
|
||||
|
||||
.action-btn-hover {
|
||||
@apply bg-state-base-hover
|
||||
}
|
||||
|
||||
.action-btn-disabled {
|
||||
@apply cursor-not-allowed
|
||||
}
|
||||
|
||||
.action-btn-xl {
|
||||
@apply p-2 w-9 h-9 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-l {
|
||||
@apply p-1.5 w-8 h-8 rounded-lg
|
||||
}
|
||||
|
||||
/* m is for the regular button */
|
||||
.action-btn-m {
|
||||
@apply p-0.5 w-6 h-6 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-xs {
|
||||
@apply p-0 w-4 h-4 rounded
|
||||
}
|
||||
|
||||
.action-btn.action-btn-active {
|
||||
@apply text-text-accent bg-state-accent-active hover:bg-state-accent-active-alt
|
||||
}
|
||||
|
||||
.action-btn.action-btn-disabled {
|
||||
@apply text-text-disabled
|
||||
}
|
||||
|
||||
.action-btn.action-btn-destructive {
|
||||
@apply text-text-destructive bg-state-destructive-hover
|
||||
}
|
||||
|
||||
}
|
||||
76
dify/web/app/components/base/action-button/index.spec.tsx
Normal file
76
dify/web/app/components/base/action-button/index.spec.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ActionButton, ActionButtonState } from './index'
|
||||
|
||||
describe('ActionButton', () => {
|
||||
test('renders button with default props', () => {
|
||||
render(<ActionButton>Click me</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Click me' })
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.classList.contains('action-btn')).toBe(true)
|
||||
expect(button.classList.contains('action-btn-m')).toBe(true)
|
||||
})
|
||||
|
||||
test('renders button with xs size', () => {
|
||||
render(<ActionButton size='xs'>Small Button</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Small Button' })
|
||||
expect(button.classList.contains('action-btn-xs')).toBe(true)
|
||||
})
|
||||
|
||||
test('renders button with l size', () => {
|
||||
render(<ActionButton size='l'>Large Button</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Large Button' })
|
||||
expect(button.classList.contains('action-btn-l')).toBe(true)
|
||||
})
|
||||
|
||||
test('renders button with xl size', () => {
|
||||
render(<ActionButton size='xl'>Extra Large Button</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Extra Large Button' })
|
||||
expect(button.classList.contains('action-btn-xl')).toBe(true)
|
||||
})
|
||||
|
||||
test('applies correct state classes', () => {
|
||||
const { rerender } = render(
|
||||
<ActionButton state={ActionButtonState.Destructive}>Destructive</ActionButton>,
|
||||
)
|
||||
let button = screen.getByRole('button', { name: 'Destructive' })
|
||||
expect(button.classList.contains('action-btn-destructive')).toBe(true)
|
||||
|
||||
rerender(<ActionButton state={ActionButtonState.Active}>Active</ActionButton>)
|
||||
button = screen.getByRole('button', { name: 'Active' })
|
||||
expect(button.classList.contains('action-btn-active')).toBe(true)
|
||||
|
||||
rerender(<ActionButton state={ActionButtonState.Disabled}>Disabled</ActionButton>)
|
||||
button = screen.getByRole('button', { name: 'Disabled' })
|
||||
expect(button.classList.contains('action-btn-disabled')).toBe(true)
|
||||
|
||||
rerender(<ActionButton state={ActionButtonState.Hover}>Hover</ActionButton>)
|
||||
button = screen.getByRole('button', { name: 'Hover' })
|
||||
expect(button.classList.contains('action-btn-hover')).toBe(true)
|
||||
})
|
||||
|
||||
test('applies custom className', () => {
|
||||
render(<ActionButton className='custom-class'>Custom Class</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Custom Class' })
|
||||
expect(button.classList.contains('custom-class')).toBe(true)
|
||||
})
|
||||
|
||||
test('applies custom style', () => {
|
||||
render(
|
||||
<ActionButton styleCss={{ color: 'red', backgroundColor: 'blue' }}>
|
||||
Custom Style
|
||||
</ActionButton>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Custom Style' })
|
||||
expect(button).toHaveStyle({
|
||||
color: 'red',
|
||||
backgroundColor: 'blue',
|
||||
})
|
||||
})
|
||||
|
||||
test('forwards additional button props', () => {
|
||||
render(<ActionButton disabled data-testid='test-button'>Disabled Button</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Disabled Button' })
|
||||
expect(button).toBeDisabled()
|
||||
expect(button).toHaveAttribute('data-testid', 'test-button')
|
||||
})
|
||||
})
|
||||
262
dify/web/app/components/base/action-button/index.stories.tsx
Normal file
262
dify/web/app/components/base/action-button/index.stories.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShareLine } from '@remixicon/react'
|
||||
import ActionButton, { ActionButtonState } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/ActionButton',
|
||||
component: ActionButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Action button component with multiple sizes and states. Commonly used for toolbar actions and inline operations.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'm', 'l', 'xl'],
|
||||
description: 'Button size',
|
||||
},
|
||||
state: {
|
||||
control: 'select',
|
||||
options: [
|
||||
ActionButtonState.Default,
|
||||
ActionButtonState.Active,
|
||||
ActionButtonState.Disabled,
|
||||
ActionButtonState.Destructive,
|
||||
ActionButtonState.Hover,
|
||||
],
|
||||
description: 'Button state',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
description: 'Button content',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Native disabled state',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ActionButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
// With text
|
||||
export const WithText: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: 'Edit',
|
||||
},
|
||||
}
|
||||
|
||||
// Icon with text
|
||||
export const IconWithText: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: (
|
||||
<>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
Add Item
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
// Size variations
|
||||
export const ExtraSmall: Story = {
|
||||
args: {
|
||||
size: 'xs',
|
||||
children: <RiEditLine className="h-3 w-3" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'xs',
|
||||
children: <RiEditLine className="h-3.5 w-3.5" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Medium: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
size: 'l',
|
||||
children: <RiEditLine className="h-5 w-5" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const ExtraLarge: Story = {
|
||||
args: {
|
||||
size: 'xl',
|
||||
children: <RiEditLine className="h-6 w-6" />,
|
||||
},
|
||||
}
|
||||
|
||||
// State variations
|
||||
export const ActiveState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Active,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const DisabledState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Disabled,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const DestructiveState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Destructive,
|
||||
children: <RiDeleteBinLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
export const HoverState: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Hover,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world examples
|
||||
export const ToolbarActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-background-section-burn p-2">
|
||||
<ActionButton size="m">
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<ActionButton size="m">
|
||||
<RiShareLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<ActionButton size="m">
|
||||
<RiSaveLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<div className="mx-1 h-4 w-px bg-divider-regular" />
|
||||
<ActionButton size="m" state={ActionButtonState.Destructive}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const InlineActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-secondary">Item name</span>
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
<ActionButton size="xs">
|
||||
<RiMore2Fill className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3 w-3" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">XS</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xs">
|
||||
<RiEditLine className="h-3.5 w-3.5" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">S</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m">
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">M</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="l">
|
||||
<RiEditLine className="h-5 w-5" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">L</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="xl">
|
||||
<RiEditLine className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">XL</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const StateComparison: Story = {
|
||||
render: () => (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Default}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Default</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Active}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Active</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Hover}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Hover</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Disabled}>
|
||||
<RiEditLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Disabled</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ActionButton size="m" state={ActionButtonState.Destructive}>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
<span className="text-xs text-text-tertiary">Destructive</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
args: {
|
||||
size: 'm',
|
||||
state: ActionButtonState.Default,
|
||||
children: <RiEditLine className="h-4 w-4" />,
|
||||
},
|
||||
}
|
||||
72
dify/web/app/components/base/action-button/index.tsx
Normal file
72
dify/web/app/components/base/action-button/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
enum ActionButtonState {
|
||||
Destructive = 'destructive',
|
||||
Active = 'active',
|
||||
Disabled = 'disabled',
|
||||
Default = '',
|
||||
Hover = 'hover',
|
||||
}
|
||||
|
||||
const actionButtonVariants = cva(
|
||||
'action-btn',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'action-btn-xs',
|
||||
m: 'action-btn-m',
|
||||
l: 'action-btn-l',
|
||||
xl: 'action-btn-xl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'm',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ActionButtonProps = {
|
||||
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
|
||||
state?: ActionButtonState
|
||||
styleCss?: CSSProperties
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof actionButtonVariants>
|
||||
|
||||
function getActionButtonState(state: ActionButtonState) {
|
||||
switch (state) {
|
||||
case ActionButtonState.Destructive:
|
||||
return 'action-btn-destructive'
|
||||
case ActionButtonState.Active:
|
||||
return 'action-btn-active'
|
||||
case ActionButtonState.Disabled:
|
||||
return 'action-btn-disabled'
|
||||
case ActionButtonState.Hover:
|
||||
return 'action-btn-hover'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, ...props }: ActionButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
actionButtonVariants({ className, size }),
|
||||
getActionButtonState(state),
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
ActionButton.displayName = 'ActionButton'
|
||||
|
||||
export default ActionButton
|
||||
export { ActionButton, ActionButtonState, actionButtonVariants }
|
||||
132
dify/web/app/components/base/agent-log-modal/detail.tsx
Normal file
132
dify/web/app/components/base/agent-log-modal/detail.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { flatten, uniq } from 'lodash-es'
|
||||
import ResultPanel from './result'
|
||||
import TracingPanel from './tracing'
|
||||
import cn from '@/utils/classnames'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { fetchAgentLogDetail } from '@/service/log'
|
||||
import type { AgentIteration, AgentLogDetailResponse } from '@/models/log'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
|
||||
export type AgentLogDetailProps = {
|
||||
activeTab?: 'DETAIL' | 'TRACING'
|
||||
conversationID: string
|
||||
log: IChatItem
|
||||
messageID: string
|
||||
}
|
||||
|
||||
const AgentLogDetail: FC<AgentLogDetailProps> = ({
|
||||
activeTab = 'DETAIL',
|
||||
conversationID,
|
||||
messageID,
|
||||
log,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [currentTab, setCurrentTab] = useState<string>(activeTab)
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [runDetail, setRunDetail] = useState<AgentLogDetailResponse>()
|
||||
const [list, setList] = useState<AgentIteration[]>([])
|
||||
|
||||
const tools = useMemo(() => {
|
||||
const res = uniq(flatten(runDetail?.iterations.map((iteration) => {
|
||||
return iteration.tool_calls.map((tool: any) => tool.tool_name).filter(Boolean)
|
||||
})).filter(Boolean))
|
||||
return res
|
||||
}, [runDetail])
|
||||
|
||||
const getLogDetail = useCallback(async (appID: string, conversationID: string, messageID: string) => {
|
||||
try {
|
||||
const res = await fetchAgentLogDetail({
|
||||
appID,
|
||||
params: {
|
||||
conversation_id: conversationID,
|
||||
message_id: messageID,
|
||||
},
|
||||
})
|
||||
setRunDetail(res)
|
||||
setList(res.iterations)
|
||||
}
|
||||
catch (err) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `${err}`,
|
||||
})
|
||||
}
|
||||
}, [notify])
|
||||
|
||||
const getData = async (appID: string, conversationID: string, messageID: string) => {
|
||||
setLoading(true)
|
||||
await getLogDetail(appID, conversationID, messageID)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const switchTab = async (tab: string) => {
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// fetch data
|
||||
if (appDetail)
|
||||
getData(appDetail.id, conversationID, messageID)
|
||||
}, [appDetail, conversationID, messageID])
|
||||
|
||||
return (
|
||||
<div className='relative flex grow flex-col'>
|
||||
{/* tab */}
|
||||
<div className='flex shrink-0 items-center border-b-[0.5px] border-divider-regular px-4'>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary',
|
||||
currentTab === 'DETAIL' && '!border-[rgb(21,94,239)] text-text-secondary',
|
||||
)}
|
||||
onClick={() => switchTab('DETAIL')}
|
||||
>{t('runLog.detail')}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-[13px] font-semibold leading-[18px] text-text-tertiary',
|
||||
currentTab === 'TRACING' && '!border-[rgb(21,94,239)] text-text-secondary',
|
||||
)}
|
||||
onClick={() => switchTab('TRACING')}
|
||||
>{t('runLog.tracing')}</div>
|
||||
</div>
|
||||
{/* panel detail */}
|
||||
<div className={cn('h-0 grow overflow-y-auto rounded-b-2xl bg-components-panel-bg', currentTab !== 'DETAIL' && '!bg-background-section')}>
|
||||
{loading && (
|
||||
<div className='flex h-full items-center justify-center bg-components-panel-bg'>
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{!loading && currentTab === 'DETAIL' && runDetail && (
|
||||
<ResultPanel
|
||||
inputs={log.input}
|
||||
outputs={log.content}
|
||||
status={runDetail.meta.status}
|
||||
error={runDetail.meta.error}
|
||||
elapsed_time={runDetail.meta.elapsed_time}
|
||||
total_tokens={runDetail.meta.total_tokens}
|
||||
created_at={runDetail.meta.start_time}
|
||||
created_by={runDetail.meta.executor}
|
||||
agentMode={runDetail.meta.agent_mode}
|
||||
tools={tools}
|
||||
iterations={runDetail.iterations.length}
|
||||
/>
|
||||
)}
|
||||
{!loading && currentTab === 'TRACING' && (
|
||||
<TracingPanel
|
||||
list={list}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentLogDetail
|
||||
146
dify/web/app/components/base/agent-log-modal/index.stories.tsx
Normal file
146
dify/web/app/components/base/agent-log-modal/index.stories.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import AgentLogModal from '.'
|
||||
import { ToastProvider } from '@/app/components/base/toast'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { AgentLogDetailResponse } from '@/models/log'
|
||||
|
||||
const MOCK_RESPONSE: AgentLogDetailResponse = {
|
||||
meta: {
|
||||
status: 'finished',
|
||||
executor: 'Agent Runner',
|
||||
start_time: '2024-03-12T10:00:00Z',
|
||||
elapsed_time: 12.45,
|
||||
total_tokens: 2589,
|
||||
agent_mode: 'ReACT',
|
||||
iterations: 2,
|
||||
error: undefined,
|
||||
},
|
||||
iterations: [
|
||||
{
|
||||
created_at: '2024-03-12T10:00:05Z',
|
||||
files: [],
|
||||
thought: JSON.stringify({ reasoning: 'Summarise conversation' }, null, 2),
|
||||
tokens: 934,
|
||||
tool_calls: [
|
||||
{
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
tool_input: { query: 'Latest revenue numbers' },
|
||||
tool_output: { answer: 'Revenue up 12% QoQ' },
|
||||
tool_name: 'search',
|
||||
tool_label: {
|
||||
'en-US': 'Revenue Search',
|
||||
},
|
||||
time_cost: 1.8,
|
||||
},
|
||||
],
|
||||
tool_raw: {
|
||||
inputs: JSON.stringify({ context: 'Summaries' }, null, 2),
|
||||
outputs: JSON.stringify({ observation: 'Revenue up 12% QoQ' }, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
created_at: '2024-03-12T10:00:09Z',
|
||||
files: [],
|
||||
thought: JSON.stringify({ final: 'Revenue increased 12% quarter-over-quarter.' }, null, 2),
|
||||
tokens: 642,
|
||||
tool_calls: [],
|
||||
tool_raw: {
|
||||
inputs: JSON.stringify({ context: 'Compose summary' }, null, 2),
|
||||
outputs: JSON.stringify({ observation: 'Final answer ready' }, null, 2),
|
||||
},
|
||||
},
|
||||
],
|
||||
files: [],
|
||||
}
|
||||
|
||||
const MOCK_CHAT_ITEM: IChatItem = {
|
||||
id: 'message-1',
|
||||
content: JSON.stringify({ answer: 'Revenue grew 12% QoQ.' }, null, 2),
|
||||
input: JSON.stringify({ question: 'Summarise revenue trends.' }, null, 2),
|
||||
isAnswer: true,
|
||||
conversationId: 'conv-123',
|
||||
}
|
||||
|
||||
const AgentLogModalDemo = ({
|
||||
width = 960,
|
||||
}: {
|
||||
width?: number
|
||||
}) => {
|
||||
const originalFetchRef = useRef<typeof globalThis.fetch>(null)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
|
||||
useEffect(() => {
|
||||
setAppDetail({
|
||||
id: 'app-1',
|
||||
name: 'Analytics Agent',
|
||||
mode: 'agent-chat',
|
||||
} as any)
|
||||
|
||||
originalFetchRef.current = globalThis.fetch?.bind(globalThis)
|
||||
|
||||
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const url = request.url
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
|
||||
if (parsed.pathname.endsWith('/apps/app-1/agent/logs')) {
|
||||
return new Response(JSON.stringify(MOCK_RESPONSE), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
if (originalFetchRef.current)
|
||||
return originalFetchRef.current(request)
|
||||
|
||||
throw new Error(`Unhandled request: ${url}`)
|
||||
}
|
||||
|
||||
globalThis.fetch = handler as typeof globalThis.fetch
|
||||
|
||||
return () => {
|
||||
if (originalFetchRef.current)
|
||||
globalThis.fetch = originalFetchRef.current
|
||||
setAppDetail(undefined)
|
||||
}
|
||||
}, [setAppDetail])
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="relative min-h-[540px] w-full bg-background-default-subtle p-6">
|
||||
<AgentLogModal
|
||||
currentLogItem={MOCK_CHAT_ITEM}
|
||||
width={width}
|
||||
onCancel={() => {
|
||||
console.log('Agent log modal closed')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Other/AgentLogModal',
|
||||
component: AgentLogModalDemo,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Agent execution viewer showing iterations, tool calls, and metadata. Fetch responses are mocked for Storybook.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
width: 960,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AgentLogModalDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
61
dify/web/app/components/base/agent-log-modal/index.tsx
Normal file
61
dify/web/app/components/base/agent-log-modal/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import AgentLogDetail from './detail'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
|
||||
type AgentLogModalProps = {
|
||||
currentLogItem?: IChatItem
|
||||
width: number
|
||||
onCancel: () => void
|
||||
}
|
||||
const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
currentLogItem,
|
||||
width,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useClickAway(() => {
|
||||
if (mounted)
|
||||
onCancel()
|
||||
}, ref)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!currentLogItem || !currentLogItem.conversationId)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl')}
|
||||
style={{
|
||||
width: 480,
|
||||
position: 'fixed',
|
||||
top: 56 + 8,
|
||||
left: 8 + (width - 480),
|
||||
bottom: 16,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<h1 className='text-md shrink-0 px-4 py-1 font-semibold text-text-primary'>{t('appLog.runDetail.workflowTitle')}</h1>
|
||||
<span className='absolute right-3 top-4 z-20 cursor-pointer p-1' onClick={onCancel}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</span>
|
||||
<AgentLogDetail
|
||||
conversationID={currentLogItem.conversationId}
|
||||
messageID={currentLogItem.id}
|
||||
log={currentLogItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AgentLogModal
|
||||
51
dify/web/app/components/base/agent-log-modal/iteration.tsx
Normal file
51
dify/web/app/components/base/agent-log-modal/iteration.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { FC } from 'react'
|
||||
import ToolCall from './tool-call'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import type { AgentIteration } from '@/models/log'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
isFinal: boolean
|
||||
index: number
|
||||
iterationInfo: AgentIteration
|
||||
}
|
||||
|
||||
const Iteration: FC<Props> = ({ iterationInfo, isFinal, index }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={cn('px-4 py-2')}>
|
||||
<div className='flex items-center'>
|
||||
{isFinal && (
|
||||
<div className='mr-3 shrink-0 text-xs font-semibold leading-[18px] text-text-tertiary'>{t('appLog.agentLogDetail.finalProcessing')}</div>
|
||||
)}
|
||||
{!isFinal && (
|
||||
<div className='mr-3 shrink-0 text-xs font-semibold leading-[18px] text-text-tertiary'>{`${t('appLog.agentLogDetail.iteration').toUpperCase()} ${index}`}</div>
|
||||
)}
|
||||
<Divider bgStyle='gradient' className='mx-0 h-px grow'/>
|
||||
</div>
|
||||
<ToolCall
|
||||
isLLM
|
||||
isFinal={isFinal}
|
||||
tokens={iterationInfo.tokens}
|
||||
observation={iterationInfo.tool_raw.outputs}
|
||||
finalAnswer={iterationInfo.thought}
|
||||
toolCall={{
|
||||
status: 'success',
|
||||
tool_icon: null,
|
||||
}}
|
||||
/>
|
||||
{iterationInfo.tool_calls.map((toolCall, index) => (
|
||||
<ToolCall
|
||||
isLLM={false}
|
||||
key={index}
|
||||
toolCall={toolCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Iteration
|
||||
126
dify/web/app/components/base/agent-log-modal/result.tsx
Normal file
126
dify/web/app/components/base/agent-log-modal/result.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import StatusPanel from '@/app/components/workflow/run/status'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
|
||||
type ResultPanelProps = {
|
||||
status: string
|
||||
elapsed_time?: number
|
||||
total_tokens?: number
|
||||
error?: string
|
||||
inputs?: any
|
||||
outputs?: any
|
||||
created_by?: string
|
||||
created_at: string
|
||||
agentMode?: string
|
||||
tools?: string[]
|
||||
iterations?: number
|
||||
}
|
||||
|
||||
const ResultPanel: FC<ResultPanelProps> = ({
|
||||
elapsed_time,
|
||||
total_tokens,
|
||||
error,
|
||||
inputs,
|
||||
outputs,
|
||||
created_by,
|
||||
created_at,
|
||||
agentMode,
|
||||
tools,
|
||||
iterations,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
|
||||
return (
|
||||
<div className='bg-components-panel-bg py-2'>
|
||||
<div className='px-4 py-2'>
|
||||
<StatusPanel
|
||||
status='succeeded'
|
||||
time={elapsed_time}
|
||||
tokens={total_tokens}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 px-4 py-2'>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>INPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={inputs}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>OUTPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={outputs}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
<div className='px-4 py-2'>
|
||||
<div className='h-[0.5px] bg-divider-regular opacity-5' />
|
||||
</div>
|
||||
<div className='px-4 py-2'>
|
||||
<div className='relative'>
|
||||
<div className='h-6 text-xs font-medium leading-6 text-text-tertiary'>{t('runLog.meta.title')}</div>
|
||||
<div className='py-1'>
|
||||
<div className='flex'>
|
||||
<div className='w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary'>{t('runLog.meta.status')}</div>
|
||||
<div className='grow px-2 py-[5px] text-xs leading-[18px] text-text-primary'>
|
||||
<span>SUCCESS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary'>{t('runLog.meta.executor')}</div>
|
||||
<div className='grow px-2 py-[5px] text-xs leading-[18px] text-text-primary'>
|
||||
<span>{created_by || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary'>{t('runLog.meta.startTime')}</div>
|
||||
<div className='grow px-2 py-[5px] text-xs leading-[18px] text-text-primary'>
|
||||
<span>{formatTime(Date.parse(created_at) / 1000, t('appLog.dateTimeFormat') as string)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary'>{t('runLog.meta.time')}</div>
|
||||
<div className='grow px-2 py-[5px] text-xs leading-[18px] text-text-primary'>
|
||||
<span>{`${elapsed_time?.toFixed(3)}s`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary'>{t('runLog.meta.tokens')}</div>
|
||||
<div className='grow px-2 py-[5px] text-xs leading-[18px] text-text-primary'>
|
||||
<span>{`${total_tokens || 0} Tokens`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary'>{t('appLog.agentLogDetail.agentMode')}</div>
|
||||
<div className='grow px-2 py-[5px] text-xs leading-[18px] text-text-primary'>
|
||||
<span>{agentMode === 'function_call' ? t('appDebug.agent.agentModeType.functionCall') : t('appDebug.agent.agentModeType.ReACT')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary'>{t('appLog.agentLogDetail.toolUsed')}</div>
|
||||
<div className='grow px-2 py-[5px] text-xs leading-[18px] text-text-primary'>
|
||||
<span>{tools?.length ? tools?.join(', ') : 'Null'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<div className='w-[104px] shrink-0 truncate px-2 py-[5px] text-xs leading-[18px] text-text-tertiary'>{t('appLog.agentLogDetail.iterations')}</div>
|
||||
<div className='grow px-2 py-[5px] text-xs leading-[18px] text-text-primary'>
|
||||
<span>{iterations}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultPanel
|
||||
142
dify/web/app/components/base/agent-log-modal/tool-call.tsx
Normal file
142
dify/web/app/components/base/agent-log-modal/tool-call.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
RiCheckboxCircleLine,
|
||||
RiErrorWarningLine,
|
||||
} from '@remixicon/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import cn from '@/utils/classnames'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import type { ToolCall } from '@/models/log'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import I18n from '@/context/i18n'
|
||||
|
||||
type Props = {
|
||||
toolCall: ToolCall
|
||||
isLLM: boolean
|
||||
isFinal?: boolean
|
||||
tokens?: number
|
||||
observation?: any
|
||||
finalAnswer?: any
|
||||
}
|
||||
|
||||
const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, observation, finalAnswer }) => {
|
||||
const [collapseState, setCollapseState] = useState<boolean>(true)
|
||||
const { locale } = useContext(I18n)
|
||||
const toolName = isLLM ? 'LLM' : (toolCall.tool_label[locale] || toolCall.tool_label[locale.replaceAll('-', '_')])
|
||||
|
||||
const getTime = (time: number) => {
|
||||
if (time < 1)
|
||||
return `${(time * 1000).toFixed(3)} ms`
|
||||
if (time > 60)
|
||||
return `${Math.floor(time / 60)} m ${(time % 60).toFixed(3)} s`
|
||||
return `${time.toFixed(3)} s`
|
||||
}
|
||||
|
||||
const getTokenCount = (tokens: number) => {
|
||||
if (tokens < 1000)
|
||||
return tokens
|
||||
if (tokens >= 1000 && tokens < 1000000)
|
||||
return `${Number.parseFloat((tokens / 1000).toFixed(3))}K`
|
||||
if (tokens >= 1000000)
|
||||
return `${Number.parseFloat((tokens / 1000000).toFixed(3))}M`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('py-1')}>
|
||||
<div className={cn('group rounded-2xl border border-components-panel-border bg-background-default shadow-xs transition-all hover:shadow-md')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center py-3 pl-[6px] pr-3',
|
||||
!collapseState && '!pb-2',
|
||||
)}
|
||||
onClick={() => setCollapseState(!collapseState)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'mr-1 h-3 w-3 shrink-0 text-text-quaternary transition-all group-hover:text-text-tertiary',
|
||||
!collapseState && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<BlockIcon className={cn('mr-2 shrink-0')} type={isLLM ? BlockEnum.LLM : BlockEnum.Tool} toolIcon={toolCall.tool_icon} />
|
||||
<div className={cn(
|
||||
'grow truncate text-[13px] font-semibold leading-[16px] text-text-secondary',
|
||||
)} title={toolName}>{toolName}</div>
|
||||
<div className='shrink-0 text-xs leading-[18px] text-text-tertiary'>
|
||||
{toolCall.time_cost && (
|
||||
<span>{getTime(toolCall.time_cost || 0)}</span>
|
||||
)}
|
||||
{isLLM && (
|
||||
<span>{`${getTokenCount(tokens || 0)} tokens`}</span>
|
||||
)}
|
||||
</div>
|
||||
{toolCall.status === 'success' && (
|
||||
<RiCheckboxCircleLine className='ml-2 h-3.5 w-3.5 shrink-0 text-[#12B76A]' />
|
||||
)}
|
||||
{toolCall.status === 'error' && (
|
||||
<RiErrorWarningLine className='ml-2 h-3.5 w-3.5 shrink-0 text-[#F04438]' />
|
||||
)}
|
||||
</div>
|
||||
{!collapseState && (
|
||||
<div className='pb-2'>
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
{toolCall.status === 'error' && (
|
||||
<div className='rounded-lg border-[0.5px] border-[rbga(0,0,0,0.05)] bg-[#fef3f2] px-3 py-[10px] text-xs leading-[18px] text-[#d92d20] shadow-xs'>{toolCall.error}</div>
|
||||
)}
|
||||
</div>
|
||||
{toolCall.tool_input && (
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>INPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={toolCall.tool_input}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{toolCall.tool_output && (
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>OUTPUT</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={toolCall.tool_output}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLLM && (
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>OBSERVATION</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={observation}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLLM && (
|
||||
<div className={cn('px-[10px] py-1')}>
|
||||
<CodeEditor
|
||||
readOnly
|
||||
title={<div>{isFinal ? 'FINAL ANSWER' : 'THOUGHT'}</div>}
|
||||
language={CodeLanguage.json}
|
||||
value={finalAnswer}
|
||||
isJSONStringifyBeauty
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolCallItem
|
||||
25
dify/web/app/components/base/agent-log-modal/tracing.tsx
Normal file
25
dify/web/app/components/base/agent-log-modal/tracing.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import Iteration from './iteration'
|
||||
import type { AgentIteration } from '@/models/log'
|
||||
|
||||
type TracingPanelProps = {
|
||||
list: AgentIteration[]
|
||||
}
|
||||
|
||||
const TracingPanel: FC<TracingPanelProps> = ({ list }) => {
|
||||
return (
|
||||
<div className='bg-background-section'>
|
||||
{list.map((iteration, index) => (
|
||||
<Iteration
|
||||
key={index}
|
||||
index={index + 1}
|
||||
isFinal={index + 1 === list.length}
|
||||
iterationInfo={iteration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TracingPanel
|
||||
107
dify/web/app/components/base/answer-icon/index.stories.tsx
Normal file
107
dify/web/app/components/base/answer-icon/index.stories.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import type { ReactNode } from 'react'
|
||||
import AnswerIcon from '.'
|
||||
|
||||
const SAMPLE_IMAGE = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" rx="40" ry="40" fill="%23EEF2FF"/><text x="50%" y="55%" dominant-baseline="middle" text-anchor="middle" font-size="34" font-family="Arial" fill="%233256D4">AI</text></svg>'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/AnswerIcon',
|
||||
component: AnswerIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Circular avatar used for assistant answers. Supports emoji, solid background colour, or uploaded imagery.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
icon: '🤖',
|
||||
background: '#D5F5F6',
|
||||
},
|
||||
} satisfies Meta<typeof AnswerIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const StoryWrapper = (children: ReactNode) => (
|
||||
<div className="flex items-center gap-6">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => StoryWrapper(
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon {...args} />
|
||||
</div>,
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon icon="🤖" background="#D5F5F6" />
|
||||
</div>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomEmoji: Story = {
|
||||
render: args => StoryWrapper(
|
||||
<>
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon {...args} icon="🧠" background="#FEE4E2" />
|
||||
</div>
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon {...args} icon="🛠️" background="#EEF2FF" />
|
||||
</div>
|
||||
</>,
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<div className="flex gap-4">
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon icon="🧠" background="#FEE4E2" />
|
||||
</div>
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon icon="🛠️" background="#EEF2FF" />
|
||||
</div>
|
||||
</div>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ImageIcon: Story = {
|
||||
render: args => StoryWrapper(
|
||||
<div className="h-16 w-16">
|
||||
<AnswerIcon
|
||||
{...args}
|
||||
iconType="image"
|
||||
imageUrl={SAMPLE_IMAGE}
|
||||
background={undefined}
|
||||
/>
|
||||
</div>,
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<AnswerIcon
|
||||
iconType="image"
|
||||
imageUrl="data:image/svg+xml;utf8,<svg ...>"
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
47
dify/web/app/components/base/answer-icon/index.tsx
Normal file
47
dify/web/app/components/base/answer-icon/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { init } from 'emoji-mart'
|
||||
import data from '@emoji-mart/data'
|
||||
import classNames from '@/utils/classnames'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
|
||||
init({ data })
|
||||
|
||||
export type AnswerIconProps = {
|
||||
iconType?: AppIconType | null
|
||||
icon?: string | null
|
||||
background?: string | null
|
||||
imageUrl?: string | null
|
||||
}
|
||||
|
||||
const AnswerIcon: FC<AnswerIconProps> = ({
|
||||
iconType,
|
||||
icon,
|
||||
background,
|
||||
imageUrl,
|
||||
}) => {
|
||||
const wrapperClassName = classNames(
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'w-full',
|
||||
'h-full',
|
||||
'rounded-full',
|
||||
'border-[0.5px]',
|
||||
'border-black/5',
|
||||
'text-xl',
|
||||
)
|
||||
const isValidImageIcon = iconType === 'image' && imageUrl
|
||||
return <div
|
||||
className={wrapperClassName}
|
||||
style={{ background: background || '#D5F5F6' }}
|
||||
>
|
||||
{isValidImageIcon
|
||||
? <img src={imageUrl} className="h-full w-full rounded-full" alt="answer icon" />
|
||||
: (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default AnswerIcon
|
||||
126
dify/web/app/components/base/app-icon-picker/ImageInput.tsx
Normal file
126
dify/web/app/components/base/app-icon-picker/ImageInput.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import { createRef, useEffect, useState } from 'react'
|
||||
import Cropper, { type Area, type CropperProps } from 'react-easy-crop'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ImagePlus } from '../icons/src/vender/line/images'
|
||||
import { useDraggableUploader } from './hooks'
|
||||
import { checkIsAnimatedImage } from './utils'
|
||||
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
|
||||
|
||||
export type OnImageInput = {
|
||||
(isCropped: true, tempUrl: string, croppedAreaPixels: Area, fileName: string): void
|
||||
(isCropped: false, file: File): void
|
||||
}
|
||||
|
||||
type UploaderProps = {
|
||||
className?: string
|
||||
cropShape?: CropperProps['cropShape']
|
||||
onImageInput?: OnImageInput
|
||||
}
|
||||
|
||||
const ImageInput: FC<UploaderProps> = ({
|
||||
className,
|
||||
cropShape,
|
||||
onImageInput,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [inputImage, setInputImage] = useState<{ file: File; url: string }>()
|
||||
const [isAnimatedImage, setIsAnimatedImage] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (inputImage)
|
||||
URL.revokeObjectURL(inputImage.url)
|
||||
}
|
||||
}, [inputImage])
|
||||
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
|
||||
const onCropComplete = async (_: Area, croppedAreaPixels: Area) => {
|
||||
if (!inputImage)
|
||||
return
|
||||
onImageInput?.(true, inputImage.url, croppedAreaPixels, inputImage.file.name)
|
||||
}
|
||||
|
||||
const handleLocalFileInput = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setInputImage({ file, url: URL.createObjectURL(file) })
|
||||
checkIsAnimatedImage(file).then((isAnimatedImage) => {
|
||||
setIsAnimatedImage(!!isAnimatedImage)
|
||||
if (isAnimatedImage)
|
||||
onImageInput?.(false, file)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
isDragActive,
|
||||
handleDragEnter,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
} = useDraggableUploader((file: File) => setInputImage({ file, url: URL.createObjectURL(file) }))
|
||||
|
||||
const inputRef = createRef<HTMLInputElement>()
|
||||
|
||||
const handleShowImage = () => {
|
||||
if (isAnimatedImage) {
|
||||
return (
|
||||
<img src={inputImage?.url} alt='' />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Cropper
|
||||
image={inputImage?.url}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape={cropShape}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropComplete}
|
||||
onZoomChange={setZoom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(className, 'w-full px-3 py-1.5')}>
|
||||
<div
|
||||
className={classNames(
|
||||
isDragActive && 'border-primary-600',
|
||||
'relative flex aspect-square flex-col items-center justify-center rounded-lg border-[1.5px] border-dashed text-gray-500')}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{
|
||||
!inputImage
|
||||
? <>
|
||||
<ImagePlus className="pointer-events-none mb-3 h-[30px] w-[30px]" />
|
||||
<div className="mb-[2px] text-sm font-medium">
|
||||
<span className="pointer-events-none">{t('common.imageInput.dropImageHere')} </span>
|
||||
<button type="button" className="text-components-button-primary-bg" onClick={() => inputRef.current?.click()}>{t('common.imageInput.browse')}</button>
|
||||
<input
|
||||
ref={inputRef} type="file" className="hidden"
|
||||
onClick={e => ((e.target as HTMLInputElement).value = '')}
|
||||
accept={ALLOW_FILE_EXTENSIONS.map(ext => `.${ext}`).join(',')}
|
||||
onChange={handleLocalFileInput}
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none">{t('common.imageInput.supportedFormats')}</div>
|
||||
</>
|
||||
: handleShowImage()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageInput
|
||||
43
dify/web/app/components/base/app-icon-picker/hooks.tsx
Normal file
43
dify/web/app/components/base/app-icon-picker/hooks.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
export const useDraggableUploader = <T extends HTMLElement>(setImageFn: (file: File) => void) => {
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(true)
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<T>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
|
||||
const file = e.dataTransfer.files[0]
|
||||
|
||||
if (!file)
|
||||
return
|
||||
|
||||
setImageFn(file)
|
||||
}, [setImageFn])
|
||||
|
||||
return {
|
||||
handleDragEnter,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
isDragActive,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import AppIconPicker, { type AppIconSelection } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/AppIconPicker',
|
||||
component: AppIconPicker,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Modal workflow for choosing an application avatar. Users can switch between emoji selections and image uploads (when enabled).',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/apps/demo-app/icon-picker',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AppIconPicker>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const AppIconPickerDemo = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selection, setSelection] = useState<AppIconSelection | null>(null)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-start gap-4 px-6 py-8 md:px-12">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Choose icon…
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg p-4 text-sm text-text-secondary shadow-sm">
|
||||
<div className="font-medium text-text-primary">Selection preview</div>
|
||||
<pre className="mt-2 max-h-44 overflow-auto rounded-md bg-background-default-subtle p-3 font-mono text-xs leading-tight text-text-primary">
|
||||
{selection ? JSON.stringify(selection, null, 2) : 'No icon selected yet.'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<AppIconPicker
|
||||
onSelect={(result) => {
|
||||
setSelection(result)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <AppIconPickerDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selection, setSelection] = useState<AppIconSelection | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)}>Choose icon…</button>
|
||||
{open && (
|
||||
<AppIconPicker
|
||||
onSelect={(result) => {
|
||||
setSelection(result)
|
||||
setOpen(false)
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
150
dify/web/app/components/base/app-icon-picker/index.tsx
Normal file
150
dify/web/app/components/base/app-icon-picker/index.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Area } from 'react-easy-crop'
|
||||
import Modal from '../modal'
|
||||
import Divider from '../divider'
|
||||
import Button from '../button'
|
||||
import { useLocalFileUploader } from '../image-uploader/hooks'
|
||||
import EmojiPickerInner from '../emoji-picker/Inner'
|
||||
import type { OnImageInput } from './ImageInput'
|
||||
import ImageInput from './ImageInput'
|
||||
import s from './style.module.css'
|
||||
import getCroppedImg from './utils'
|
||||
import type { AppIconType, ImageFile } from '@/types/app'
|
||||
import cn from '@/utils/classnames'
|
||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||
import { noop } from 'lodash-es'
|
||||
import { RiImageCircleAiLine } from '@remixicon/react'
|
||||
|
||||
export type AppIconEmojiSelection = {
|
||||
type: 'emoji'
|
||||
icon: string
|
||||
background: string
|
||||
}
|
||||
|
||||
export type AppIconImageSelection = {
|
||||
type: 'image'
|
||||
fileId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection
|
||||
|
||||
type AppIconPickerProps = {
|
||||
onSelect?: (payload: AppIconSelection) => void
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const tabs = [
|
||||
{ key: 'emoji', label: t('app.iconPicker.emoji'), icon: <span className="text-lg">🤖</span> },
|
||||
{ key: 'image', label: t('app.iconPicker.image'), icon: <RiImageCircleAiLine className='size-4' /> },
|
||||
]
|
||||
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
|
||||
|
||||
const [emoji, setEmoji] = useState<{ emoji: string; background: string }>()
|
||||
const handleSelectEmoji = useCallback((emoji: string, background: string) => {
|
||||
setEmoji({ emoji, background })
|
||||
}, [setEmoji])
|
||||
|
||||
const [uploading, setUploading] = useState<boolean>()
|
||||
|
||||
const { handleLocalFileUpload } = useLocalFileUploader({
|
||||
limit: 3,
|
||||
disabled: false,
|
||||
onUpload: (imageFile: ImageFile) => {
|
||||
if (imageFile.fileId) {
|
||||
setUploading(false)
|
||||
onSelect?.({
|
||||
type: 'image',
|
||||
fileId: imageFile.fileId,
|
||||
url: imageFile.url,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string }
|
||||
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
|
||||
|
||||
const handleImageInput: OnImageInput = async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => {
|
||||
setInputImageInfo(
|
||||
isCropped
|
||||
? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! }
|
||||
: { file: fileOrTempUrl as File },
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelect = async () => {
|
||||
if (activeTab === 'emoji') {
|
||||
if (emoji) {
|
||||
onSelect?.({
|
||||
type: 'emoji',
|
||||
icon: emoji.emoji,
|
||||
background: emoji.background,
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!inputImageInfo)
|
||||
return
|
||||
setUploading(true)
|
||||
if ('file' in inputImageInfo) {
|
||||
handleLocalFileUpload(inputImageInfo.file)
|
||||
return
|
||||
}
|
||||
const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName)
|
||||
const file = new File([blob], inputImageInfo.fileName, { type: blob.type })
|
||||
handleLocalFileUpload(file)
|
||||
}
|
||||
}
|
||||
|
||||
return <Modal
|
||||
onClose={noop}
|
||||
isShow
|
||||
closable={false}
|
||||
wrapperClassName={className}
|
||||
className={cn(s.container, '!h-[462px] !w-[362px] !p-0')}
|
||||
>
|
||||
{!DISABLE_UPLOAD_IMAGE_AS_ICON && <div className="w-full p-2 pb-0">
|
||||
<div className='flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary'>
|
||||
{tabs.map(tab => (
|
||||
<button type="button"
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
'system-sm-medium flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 text-text-tertiary',
|
||||
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key as AppIconType)}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{activeTab === 'emoji' && <EmojiPickerInner className={cn('flex-1 overflow-hidden pt-2')} onSelect={handleSelectEmoji} />}
|
||||
{activeTab === 'image' && <ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />}
|
||||
|
||||
<Divider className='m-0' />
|
||||
<div className='flex w-full items-center justify-center gap-2 p-3'>
|
||||
<Button className='w-full' onClick={() => onClose?.()}>
|
||||
{t('app.iconPicker.cancel')}
|
||||
</Button>
|
||||
|
||||
<Button variant="primary" className='w-full' disabled={uploading} loading={uploading} onClick={handleSelect}>
|
||||
{t('app.iconPicker.ok')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
export default AppIconPicker
|
||||
@@ -0,0 +1,9 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 362px;
|
||||
max-height: 552px;
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
166
dify/web/app/components/base/app-icon-picker/utils.ts
Normal file
166
dify/web/app/components/base/app-icon-picker/utils.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
export const createImage = (url: string) =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener('load', () => resolve(image))
|
||||
image.addEventListener('error', error => reject(error))
|
||||
image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox
|
||||
image.src = url
|
||||
})
|
||||
|
||||
export function getRadianAngle(degreeValue: number) {
|
||||
return (degreeValue * Math.PI) / 180
|
||||
}
|
||||
|
||||
export function getMimeType(fileName: string): string {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase()
|
||||
switch (extension) {
|
||||
case 'png':
|
||||
return 'image/png'
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg'
|
||||
case 'gif':
|
||||
return 'image/gif'
|
||||
case 'webp':
|
||||
return 'image/webp'
|
||||
default:
|
||||
return 'image/jpeg'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the new bounding area of a rotated rectangle.
|
||||
*/
|
||||
export function rotateSize(width: number, height: number, rotation: number) {
|
||||
const rotRad = getRadianAngle(rotation)
|
||||
|
||||
return {
|
||||
width:
|
||||
Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height),
|
||||
height:
|
||||
Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop
|
||||
*/
|
||||
export default async function getCroppedImg(
|
||||
imageSrc: string,
|
||||
pixelCrop: { x: number; y: number; width: number; height: number },
|
||||
fileName: string,
|
||||
rotation = 0,
|
||||
flip = { horizontal: false, vertical: false },
|
||||
): Promise<Blob> {
|
||||
const image = await createImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const mimeType = getMimeType(fileName)
|
||||
|
||||
if (!ctx)
|
||||
throw new Error('Could not create a canvas context')
|
||||
|
||||
const rotRad = getRadianAngle(rotation)
|
||||
|
||||
// calculate bounding box of the rotated image
|
||||
const { width: bBoxWidth, height: bBoxHeight } = rotateSize(
|
||||
image.width,
|
||||
image.height,
|
||||
rotation,
|
||||
)
|
||||
|
||||
// set canvas size to match the bounding box
|
||||
canvas.width = bBoxWidth
|
||||
canvas.height = bBoxHeight
|
||||
|
||||
// translate canvas context to a central location to allow rotating and flipping around the center
|
||||
ctx.translate(bBoxWidth / 2, bBoxHeight / 2)
|
||||
ctx.rotate(rotRad)
|
||||
ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1)
|
||||
ctx.translate(-image.width / 2, -image.height / 2)
|
||||
|
||||
// draw rotated image
|
||||
ctx.drawImage(image, 0, 0)
|
||||
|
||||
const croppedCanvas = document.createElement('canvas')
|
||||
|
||||
const croppedCtx = croppedCanvas.getContext('2d')
|
||||
|
||||
if (!croppedCtx)
|
||||
throw new Error('Could not create a canvas context')
|
||||
|
||||
// Set the size of the cropped canvas
|
||||
croppedCanvas.width = pixelCrop.width
|
||||
croppedCanvas.height = pixelCrop.height
|
||||
|
||||
// Draw the cropped image onto the new canvas
|
||||
croppedCtx.drawImage(
|
||||
canvas,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
croppedCanvas.toBlob((file) => {
|
||||
if (file)
|
||||
resolve(file)
|
||||
else
|
||||
reject(new Error('Could not create a blob'))
|
||||
}, mimeType)
|
||||
})
|
||||
}
|
||||
|
||||
export function checkIsAnimatedImage(file: File): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader()
|
||||
|
||||
fileReader.onload = function (e) {
|
||||
const arr = new Uint8Array(e.target?.result as ArrayBuffer)
|
||||
|
||||
// Check file extension
|
||||
const fileName = file.name.toLowerCase()
|
||||
if (fileName.endsWith('.gif')) {
|
||||
// If file is a GIF, assume it's animated
|
||||
resolve(true)
|
||||
}
|
||||
// Check for WebP signature (RIFF and WEBP)
|
||||
else if (isWebP(arr)) {
|
||||
resolve(checkWebPAnimation(arr)) // Check if it's animated
|
||||
}
|
||||
else {
|
||||
resolve(false) // Not a GIF or WebP
|
||||
}
|
||||
}
|
||||
|
||||
fileReader.onerror = function (err) {
|
||||
reject(err) // Reject the promise on error
|
||||
}
|
||||
|
||||
// Read the file as an array buffer
|
||||
fileReader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
// Function to check for WebP signature
|
||||
function isWebP(arr: Uint8Array) {
|
||||
return (
|
||||
arr[0] === 0x52 && arr[1] === 0x49 && arr[2] === 0x46 && arr[3] === 0x46
|
||||
&& arr[8] === 0x57 && arr[9] === 0x45 && arr[10] === 0x42 && arr[11] === 0x50
|
||||
) // "WEBP"
|
||||
}
|
||||
|
||||
// Function to check if the WebP is animated (contains ANIM chunk)
|
||||
function checkWebPAnimation(arr: Uint8Array) {
|
||||
// Search for the ANIM chunk in WebP to determine if it's animated
|
||||
for (let i = 12; i < arr.length - 4; i++) {
|
||||
if (arr[i] === 0x41 && arr[i + 1] === 0x4E && arr[i + 2] === 0x49 && arr[i + 3] === 0x4D)
|
||||
return true // Found animation
|
||||
}
|
||||
return false // No animation chunk found
|
||||
}
|
||||
159
dify/web/app/components/base/app-icon/index.spec.tsx
Normal file
159
dify/web/app/components/base/app-icon/index.spec.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import AppIcon from './index'
|
||||
|
||||
// Mock emoji-mart initialization
|
||||
jest.mock('emoji-mart', () => ({
|
||||
init: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock emoji data
|
||||
jest.mock('@emoji-mart/data', () => ({}))
|
||||
|
||||
// Mock the ahooks useHover hook
|
||||
jest.mock('ahooks', () => ({
|
||||
useHover: jest.fn(() => false),
|
||||
}))
|
||||
|
||||
describe('AppIcon', () => {
|
||||
beforeEach(() => {
|
||||
// Mock custom element
|
||||
if (!customElements.get('em-emoji')) {
|
||||
customElements.define('em-emoji', class extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
// Mock basic functionality
|
||||
connectedCallback() {
|
||||
this.innerHTML = '🤖'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
require('ahooks').useHover.mockReset().mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('renders default emoji when no icon or image is provided', () => {
|
||||
render(<AppIcon />)
|
||||
const emojiElement = document.querySelector('em-emoji')
|
||||
expect(emojiElement).toBeInTheDocument()
|
||||
expect(emojiElement?.getAttribute('id')).toBe('🤖')
|
||||
})
|
||||
|
||||
it('renders with custom emoji when icon is provided', () => {
|
||||
render(<AppIcon icon='smile' />)
|
||||
const emojiElement = document.querySelector('em-emoji')
|
||||
expect(emojiElement).toBeInTheDocument()
|
||||
expect(emojiElement?.getAttribute('id')).toBe('smile')
|
||||
})
|
||||
|
||||
it('renders image when iconType is image and imageUrl is provided', () => {
|
||||
render(<AppIcon iconType='image' imageUrl='test-image.jpg' />)
|
||||
const imgElement = screen.getByAltText('app icon')
|
||||
expect(imgElement).toBeInTheDocument()
|
||||
expect(imgElement).toHaveAttribute('src', 'test-image.jpg')
|
||||
})
|
||||
|
||||
it('renders innerIcon when provided', () => {
|
||||
render(<AppIcon innerIcon={<div data-testid='inner-icon'>Custom Icon</div>} />)
|
||||
const innerIcon = screen.getByTestId('inner-icon')
|
||||
expect(innerIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies size classes correctly', () => {
|
||||
const { container: xsContainer } = render(<AppIcon size='xs' />)
|
||||
expect(xsContainer.firstChild).toHaveClass('w-4 h-4 rounded-[4px]')
|
||||
|
||||
const { container: tinyContainer } = render(<AppIcon size='tiny' />)
|
||||
expect(tinyContainer.firstChild).toHaveClass('w-6 h-6 rounded-md')
|
||||
|
||||
const { container: smallContainer } = render(<AppIcon size='small' />)
|
||||
expect(smallContainer.firstChild).toHaveClass('w-8 h-8 rounded-lg')
|
||||
|
||||
const { container: mediumContainer } = render(<AppIcon size='medium' />)
|
||||
expect(mediumContainer.firstChild).toHaveClass('w-9 h-9 rounded-[10px]')
|
||||
|
||||
const { container: largeContainer } = render(<AppIcon size='large' />)
|
||||
expect(largeContainer.firstChild).toHaveClass('w-10 h-10 rounded-[10px]')
|
||||
|
||||
const { container: xlContainer } = render(<AppIcon size='xl' />)
|
||||
expect(xlContainer.firstChild).toHaveClass('w-12 h-12 rounded-xl')
|
||||
|
||||
const { container: xxlContainer } = render(<AppIcon size='xxl' />)
|
||||
expect(xxlContainer.firstChild).toHaveClass('w-14 h-14 rounded-2xl')
|
||||
})
|
||||
|
||||
it('applies rounded class when rounded=true', () => {
|
||||
const { container } = render(<AppIcon rounded />)
|
||||
expect(container.firstChild).toHaveClass('rounded-full')
|
||||
})
|
||||
|
||||
it('applies custom background color', () => {
|
||||
const { container } = render(<AppIcon background='#FF5500' />)
|
||||
expect(container.firstChild).toHaveStyle('background: #FF5500')
|
||||
})
|
||||
|
||||
it('uses default background color when no background is provided for non-image icons', () => {
|
||||
const { container } = render(<AppIcon />)
|
||||
expect(container.firstChild).toHaveStyle('background: #FFEAD5')
|
||||
})
|
||||
|
||||
it('does not apply background style for image icons', () => {
|
||||
const { container } = render(<AppIcon iconType='image' imageUrl='test.jpg' background='#FF5500' />)
|
||||
// Should not have the background style from the prop
|
||||
expect(container.firstChild).not.toHaveStyle('background: #FF5500')
|
||||
})
|
||||
|
||||
it('calls onClick handler when clicked', () => {
|
||||
const handleClick = jest.fn()
|
||||
const { container } = render(<AppIcon onClick={handleClick} />)
|
||||
fireEvent.click(container.firstChild!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<AppIcon className='custom-class' />)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('does not display edit icon when showEditIcon=false', () => {
|
||||
render(<AppIcon />)
|
||||
const editIcon = screen.queryByRole('svg')
|
||||
expect(editIcon).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays edit icon when showEditIcon=true and hovering', () => {
|
||||
// Mock the useHover hook to return true for this test
|
||||
require('ahooks').useHover.mockReturnValue(true)
|
||||
|
||||
render(<AppIcon showEditIcon />)
|
||||
const editIcon = document.querySelector('svg')
|
||||
expect(editIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not display edit icon when showEditIcon=true but not hovering', () => {
|
||||
// useHover returns false by default from our mock setup
|
||||
render(<AppIcon showEditIcon />)
|
||||
const editIcon = document.querySelector('svg')
|
||||
expect(editIcon).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles conditional isValidImageIcon check correctly', () => {
|
||||
// Case 1: Valid image icon
|
||||
const { rerender } = render(
|
||||
<AppIcon iconType='image' imageUrl='test.jpg' />,
|
||||
)
|
||||
expect(screen.getByAltText('app icon')).toBeInTheDocument()
|
||||
|
||||
// Case 2: Invalid - missing image URL
|
||||
rerender(<AppIcon iconType='image' imageUrl={null} />)
|
||||
expect(screen.queryByAltText('app icon')).not.toBeInTheDocument()
|
||||
|
||||
// Case 3: Invalid - wrong icon type
|
||||
rerender(<AppIcon iconType='emoji' imageUrl='test.jpg' />)
|
||||
expect(screen.queryByAltText('app icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
108
dify/web/app/components/base/app-icon/index.stories.tsx
Normal file
108
dify/web/app/components/base/app-icon/index.stories.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import type { ComponentProps } from 'react'
|
||||
import AppIcon from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/AppIcon',
|
||||
component: AppIcon,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Reusable avatar for applications and workflows. Supports emoji or uploaded imagery, rounded mode, edit overlays, and multiple sizes.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
icon: '🧭',
|
||||
background: '#FFEAD5',
|
||||
size: 'medium',
|
||||
rounded: false,
|
||||
},
|
||||
} satisfies Meta<typeof AppIcon>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-4">
|
||||
<AppIcon {...args} />
|
||||
<AppIcon {...args} rounded icon="🧠" background="#E0F2FE" />
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<AppIcon icon="🧭" background="#FFEAD5" />
|
||||
<AppIcon icon="🧠" background="#E0F2FE" rounded />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: (args) => {
|
||||
const sizes: Array<ComponentProps<typeof AppIcon>['size']> = ['xs', 'tiny', 'small', 'medium', 'large', 'xl', 'xxl']
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
{sizes.map(size => (
|
||||
<div key={size} className="flex flex-col items-center gap-2">
|
||||
<AppIcon {...args} size={size} icon="🚀" background="#E5DEFF" />
|
||||
<span className="text-xs uppercase text-text-tertiary">{size}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
{(['xs','tiny','small','medium','large','xl','xxl'] as const).map(size => (
|
||||
<AppIcon key={size} size={size} icon="🚀" background="#E5DEFF" />
|
||||
))}
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const WithEditOverlay: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-4">
|
||||
<AppIcon
|
||||
{...args}
|
||||
icon="🛠️"
|
||||
background="#E7F5FF"
|
||||
showEditIcon
|
||||
/>
|
||||
<AppIcon
|
||||
{...args}
|
||||
iconType="image"
|
||||
background={undefined}
|
||||
imageUrl="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><rect width='80' height='80' rx='16' fill='%23CBD5F5'/><text x='50%' y='54%' dominant-baseline='middle' text-anchor='middle' font-size='30' font-family='Arial' fill='%231f2937'>AI</text></svg>"
|
||||
showEditIcon
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<AppIcon icon="🛠️" background="#E7F5FF" showEditIcon />
|
||||
<AppIcon
|
||||
iconType="image"
|
||||
imageUrl="data:image/svg+xml;utf8,<svg ...>"
|
||||
showEditIcon
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
131
dify/web/app/components/base/app-icon/index.tsx
Normal file
131
dify/web/app/components/base/app-icon/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { type FC, useRef } from 'react'
|
||||
import { init } from 'emoji-mart'
|
||||
import data from '@emoji-mart/data'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { useHover } from 'ahooks'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
|
||||
init({ data })
|
||||
|
||||
export type AppIconProps = {
|
||||
size?: 'xs' | 'tiny' | 'small' | 'medium' | 'large' | 'xl' | 'xxl'
|
||||
rounded?: boolean
|
||||
iconType?: AppIconType | null
|
||||
icon?: string
|
||||
background?: string | null
|
||||
imageUrl?: string | null
|
||||
className?: string
|
||||
innerIcon?: React.ReactNode
|
||||
coverElement?: React.ReactNode
|
||||
showEditIcon?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
const appIconVariants = cva(
|
||||
'flex items-center justify-center relative grow-0 shrink-0 overflow-hidden leading-none border-[0.5px] border-divider-regular',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'w-4 h-4 text-xs rounded-[4px]',
|
||||
tiny: 'w-6 h-6 text-base rounded-md',
|
||||
small: 'w-8 h-8 text-xl rounded-lg',
|
||||
medium: 'w-9 h-9 text-[22px] rounded-[10px]',
|
||||
large: 'w-10 h-10 text-[24px] rounded-[10px]',
|
||||
xl: 'w-12 h-12 text-[28px] rounded-xl',
|
||||
xxl: 'w-14 h-14 text-[32px] rounded-2xl',
|
||||
},
|
||||
rounded: {
|
||||
true: 'rounded-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: false,
|
||||
},
|
||||
})
|
||||
const EditIconWrapperVariants = cva(
|
||||
'absolute left-0 top-0 z-10 flex items-center justify-center bg-background-overlay-alt',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'w-4 h-4 rounded-[4px]',
|
||||
tiny: 'w-6 h-6 rounded-md',
|
||||
small: 'w-8 h-8 rounded-lg',
|
||||
medium: 'w-9 h-9 rounded-[10px]',
|
||||
large: 'w-10 h-10 rounded-[10px]',
|
||||
xl: 'w-12 h-12 rounded-xl',
|
||||
xxl: 'w-14 h-14 rounded-2xl',
|
||||
},
|
||||
rounded: {
|
||||
true: 'rounded-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: false,
|
||||
},
|
||||
})
|
||||
const EditIconVariants = cva(
|
||||
'text-text-primary-on-surface',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'size-3',
|
||||
tiny: 'size-3.5',
|
||||
small: 'size-5',
|
||||
medium: 'size-[22px]',
|
||||
large: 'size-6',
|
||||
xl: 'size-7',
|
||||
xxl: 'size-8',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
})
|
||||
const AppIcon: FC<AppIconProps> = ({
|
||||
size = 'medium',
|
||||
rounded = false,
|
||||
iconType,
|
||||
icon,
|
||||
background,
|
||||
imageUrl,
|
||||
className,
|
||||
innerIcon,
|
||||
coverElement,
|
||||
onClick,
|
||||
showEditIcon = false,
|
||||
}) => {
|
||||
const isValidImageIcon = iconType === 'image' && imageUrl
|
||||
const Icon = (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />
|
||||
const wrapperRef = useRef<HTMLSpanElement>(null)
|
||||
const isHovering = useHover(wrapperRef)
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={wrapperRef}
|
||||
className={classNames(appIconVariants({ size, rounded }), className)}
|
||||
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{
|
||||
isValidImageIcon
|
||||
? <img src={imageUrl} className='h-full w-full' alt='app icon' />
|
||||
: (innerIcon || Icon)
|
||||
}
|
||||
{
|
||||
showEditIcon && isHovering && (
|
||||
<div className={EditIconWrapperVariants({ size, rounded })}>
|
||||
<RiEditLine className={EditIconVariants({ size })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{coverElement}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppIcon)
|
||||
32
dify/web/app/components/base/app-unavailable.tsx
Normal file
32
dify/web/app/components/base/app-unavailable.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
import classNames from '@/utils/classnames'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type IAppUnavailableProps = {
|
||||
code?: number | string
|
||||
isUnknownReason?: boolean
|
||||
unknownReason?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AppUnavailable: FC<IAppUnavailableProps> = ({
|
||||
code = 404,
|
||||
isUnknownReason,
|
||||
unknownReason,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className={classNames('flex h-screen w-screen items-center justify-center', className)}>
|
||||
<h1 className='mr-5 h-[50px] shrink-0 pr-5 text-[24px] font-medium leading-[50px]'
|
||||
style={{
|
||||
borderRight: '1px solid rgba(0,0,0,.3)',
|
||||
}}>{code}</h1>
|
||||
<div className='text-sm'>{unknownReason || (isUnknownReason ? t('share.common.appUnknownError') : t('share.common.appUnavailable'))}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AppUnavailable)
|
||||
@@ -0,0 +1,50 @@
|
||||
import AudioPlayer from '@/app/components/base/audio-btn/audio'
|
||||
declare global {
|
||||
// eslint-disable-next-line ts/consistent-type-definitions
|
||||
interface AudioPlayerManager {
|
||||
instance: AudioPlayerManager
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export class AudioPlayerManager {
|
||||
private static instance: AudioPlayerManager
|
||||
private audioPlayers: AudioPlayer | null = null
|
||||
private msgId: string | undefined
|
||||
|
||||
public static getInstance(): AudioPlayerManager {
|
||||
if (!AudioPlayerManager.instance) {
|
||||
AudioPlayerManager.instance = new AudioPlayerManager()
|
||||
this.instance = AudioPlayerManager.instance
|
||||
}
|
||||
|
||||
return AudioPlayerManager.instance
|
||||
}
|
||||
|
||||
public getAudioPlayer(url: string, isPublic: boolean, id: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => void) | null): AudioPlayer {
|
||||
if (this.msgId && this.msgId === id && this.audioPlayers) {
|
||||
this.audioPlayers.setCallback(callback)
|
||||
return this.audioPlayers
|
||||
}
|
||||
else {
|
||||
if (this.audioPlayers) {
|
||||
try {
|
||||
this.audioPlayers.pauseAudio()
|
||||
this.audioPlayers.cacheBuffers = []
|
||||
this.audioPlayers.sourceBuffer?.abort()
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
this.msgId = id
|
||||
this.audioPlayers = new AudioPlayer(url, isPublic, id, msgContent, voice, callback)
|
||||
return this.audioPlayers
|
||||
}
|
||||
}
|
||||
|
||||
public resetMsgId(msgId: string) {
|
||||
this.msgId = msgId
|
||||
this.audioPlayers?.resetMsgId(msgId)
|
||||
}
|
||||
}
|
||||
246
dify/web/app/components/base/audio-btn/audio.ts
Normal file
246
dify/web/app/components/base/audio-btn/audio.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { textToAudioStream } from '@/service/share'
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line ts/consistent-type-definitions
|
||||
interface Window {
|
||||
ManagedMediaSource: any
|
||||
}
|
||||
}
|
||||
|
||||
export default class AudioPlayer {
|
||||
mediaSource: MediaSource | null
|
||||
audio: HTMLAudioElement
|
||||
audioContext: AudioContext
|
||||
sourceBuffer?: any
|
||||
cacheBuffers: ArrayBuffer[] = []
|
||||
pauseTimer: number | null = null
|
||||
msgId: string | undefined
|
||||
msgContent: string | null | undefined = null
|
||||
voice: string | undefined = undefined
|
||||
isLoadData = false
|
||||
url: string
|
||||
isPublic: boolean
|
||||
callback: ((event: string) => void) | null
|
||||
|
||||
constructor(streamUrl: string, isPublic: boolean, msgId: string | undefined, msgContent: string | null | undefined, voice: string | undefined, callback: ((event: string) => void) | null) {
|
||||
this.audioContext = new AudioContext()
|
||||
this.msgId = msgId
|
||||
this.msgContent = msgContent
|
||||
this.url = streamUrl
|
||||
this.isPublic = isPublic
|
||||
this.voice = voice
|
||||
this.callback = callback
|
||||
|
||||
// Compatible with iphone ios17 ManagedMediaSource
|
||||
const MediaSource = window.ManagedMediaSource || window.MediaSource
|
||||
if (!MediaSource) {
|
||||
Toast.notify({
|
||||
message: 'Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
this.mediaSource = MediaSource ? new MediaSource() : null
|
||||
this.audio = new Audio()
|
||||
this.setCallback(callback)
|
||||
if (!window.MediaSource) { // if use ManagedMediaSource
|
||||
this.audio.disableRemotePlayback = true
|
||||
this.audio.controls = true
|
||||
}
|
||||
this.audio.src = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
|
||||
this.audio.autoplay = true
|
||||
|
||||
const source = this.audioContext.createMediaElementSource(this.audio)
|
||||
source.connect(this.audioContext.destination)
|
||||
this.listenMediaSource('audio/mpeg')
|
||||
}
|
||||
|
||||
public resetMsgId(msgId: string) {
|
||||
this.msgId = msgId
|
||||
}
|
||||
|
||||
private listenMediaSource(contentType: string) {
|
||||
this.mediaSource?.addEventListener('sourceopen', () => {
|
||||
if (this.sourceBuffer)
|
||||
return
|
||||
|
||||
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
|
||||
})
|
||||
}
|
||||
|
||||
public setCallback(callback: ((event: string) => void) | null) {
|
||||
this.callback = callback
|
||||
if (callback) {
|
||||
this.audio.addEventListener('ended', () => {
|
||||
callback('ended')
|
||||
}, false)
|
||||
this.audio.addEventListener('paused', () => {
|
||||
callback('paused')
|
||||
}, true)
|
||||
this.audio.addEventListener('loaded', () => {
|
||||
callback('loaded')
|
||||
}, true)
|
||||
this.audio.addEventListener('play', () => {
|
||||
callback('play')
|
||||
}, true)
|
||||
this.audio.addEventListener('timeupdate', () => {
|
||||
callback('timeupdate')
|
||||
}, true)
|
||||
this.audio.addEventListener('loadeddate', () => {
|
||||
callback('loadeddate')
|
||||
}, true)
|
||||
this.audio.addEventListener('canplay', () => {
|
||||
callback('canplay')
|
||||
}, true)
|
||||
this.audio.addEventListener('error', () => {
|
||||
callback('error')
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
private async loadAudio() {
|
||||
try {
|
||||
const audioResponse: any = await textToAudioStream(this.url, this.isPublic, { content_type: 'audio/mpeg' }, {
|
||||
message_id: this.msgId,
|
||||
streaming: true,
|
||||
voice: this.voice,
|
||||
text: this.msgContent,
|
||||
})
|
||||
|
||||
if (audioResponse.status !== 200) {
|
||||
this.isLoadData = false
|
||||
if (this.callback)
|
||||
this.callback('error')
|
||||
}
|
||||
|
||||
const reader = audioResponse.body.getReader()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
|
||||
if (done) {
|
||||
this.receiveAudioData(value)
|
||||
break
|
||||
}
|
||||
|
||||
this.receiveAudioData(value)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
this.isLoadData = false
|
||||
this.callback?.('error')
|
||||
}
|
||||
}
|
||||
|
||||
// play audio
|
||||
public playAudio() {
|
||||
if (this.isLoadData) {
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
}
|
||||
else if (this.audio.ended) {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
}
|
||||
this.callback?.('play')
|
||||
}
|
||||
else {
|
||||
this.isLoadData = true
|
||||
this.loadAudio()
|
||||
}
|
||||
}
|
||||
|
||||
private theEndOfStream() {
|
||||
const endTimer = setInterval(() => {
|
||||
if (!this.sourceBuffer?.updating) {
|
||||
this.mediaSource?.endOfStream()
|
||||
clearInterval(endTimer)
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
private finishStream() {
|
||||
const timer = setInterval(() => {
|
||||
if (!this.cacheBuffers.length) {
|
||||
this.theEndOfStream()
|
||||
clearInterval(timer)
|
||||
}
|
||||
|
||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
||||
const arrayBuffer = this.cacheBuffers.shift()!
|
||||
this.sourceBuffer?.appendBuffer(arrayBuffer)
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
public async playAudioWithAudio(audio: string, play = true) {
|
||||
if (!audio || !audio.length) {
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
|
||||
const audioContent = Buffer.from(audio, 'base64')
|
||||
this.receiveAudioData(new Uint8Array(audioContent))
|
||||
if (play) {
|
||||
this.isLoadData = true
|
||||
if (this.audio.paused) {
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
}
|
||||
else if (this.audio.ended) {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
}
|
||||
else if (this.audio.played) { /* empty */ }
|
||||
|
||||
else {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public pauseAudio() {
|
||||
this.callback?.('paused')
|
||||
this.audio.pause()
|
||||
this.audioContext.suspend()
|
||||
}
|
||||
|
||||
private receiveAudioData(unit8Array: Uint8Array) {
|
||||
if (!unit8Array) {
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
const audioData = this.byteArrayToArrayBuffer(unit8Array)
|
||||
if (!audioData.byteLength) {
|
||||
if (this.mediaSource?.readyState === 'open')
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.sourceBuffer?.updating) {
|
||||
this.cacheBuffers.push(audioData)
|
||||
}
|
||||
else {
|
||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
||||
this.cacheBuffers.push(audioData)
|
||||
const cacheBuffer = this.cacheBuffers.shift()!
|
||||
this.sourceBuffer?.appendBuffer(cacheBuffer)
|
||||
}
|
||||
else {
|
||||
this.sourceBuffer?.appendBuffer(audioData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byteArrayToArrayBuffer(byteArray: Uint8Array): ArrayBuffer {
|
||||
const arrayBuffer = new ArrayBuffer(byteArray.length)
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
uint8Array.set(byteArray)
|
||||
return arrayBuffer
|
||||
}
|
||||
}
|
||||
75
dify/web/app/components/base/audio-btn/index.stories.tsx
Normal file
75
dify/web/app/components/base/audio-btn/index.stories.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useEffect } from 'react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import AudioBtn from '.'
|
||||
import { ensureMockAudioManager } from '../../../../.storybook/utils/audio-player-manager.mock'
|
||||
|
||||
ensureMockAudioManager()
|
||||
|
||||
const StoryWrapper = (props: ComponentProps<typeof AudioBtn>) => {
|
||||
useEffect(() => {
|
||||
ensureMockAudioManager()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-3">
|
||||
<AudioBtn {...props} />
|
||||
<span className="text-xs text-gray-500">Click to toggle playback</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/AudioBtn',
|
||||
component: AudioBtn,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Audio playback toggle that streams assistant responses. The story uses a mocked audio player so you can inspect loading and playback states without calling the real API.',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
navigation: {
|
||||
pathname: '/apps/demo-app/text-to-audio',
|
||||
params: { appId: 'demo-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
id: {
|
||||
control: 'text',
|
||||
description: 'Message identifier used to scope the audio stream.',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Text content that would be converted to speech.',
|
||||
},
|
||||
voice: {
|
||||
control: 'text',
|
||||
description: 'Voice profile used for playback.',
|
||||
},
|
||||
isAudition: {
|
||||
control: 'boolean',
|
||||
description: 'Switches to the audition style with minimal padding.',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Optional custom class for the wrapper.',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AudioBtn>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => <StoryWrapper {...args} />,
|
||||
args: {
|
||||
id: 'message-1',
|
||||
value: 'This is an audio preview for the current assistant response.',
|
||||
voice: 'alloy',
|
||||
},
|
||||
}
|
||||
110
dify/web/app/components/base/audio-btn/index.tsx
Normal file
110
dify/web/app/components/base/audio-btn/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import s from './style.module.css'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
|
||||
type AudioBtnProps = {
|
||||
id?: string
|
||||
voice?: string
|
||||
value?: string
|
||||
className?: string
|
||||
isAudition?: boolean
|
||||
noCache?: boolean
|
||||
}
|
||||
|
||||
type AudioState = 'initial' | 'loading' | 'playing' | 'paused' | 'ended'
|
||||
|
||||
const AudioBtn = ({
|
||||
id,
|
||||
voice,
|
||||
value,
|
||||
className,
|
||||
isAudition,
|
||||
}: AudioBtnProps) => {
|
||||
const [audioState, setAudioState] = useState<AudioState>('initial')
|
||||
|
||||
const params = useParams()
|
||||
const pathname = usePathname()
|
||||
const audio_finished_call = (event: string): void => {
|
||||
switch (event) {
|
||||
case 'ended':
|
||||
setAudioState('ended')
|
||||
break
|
||||
case 'paused':
|
||||
setAudioState('ended')
|
||||
break
|
||||
case 'loaded':
|
||||
setAudioState('loading')
|
||||
break
|
||||
case 'play':
|
||||
setAudioState('playing')
|
||||
break
|
||||
case 'error':
|
||||
setAudioState('ended')
|
||||
break
|
||||
}
|
||||
}
|
||||
let url = ''
|
||||
let isPublic = false
|
||||
|
||||
if (params.token) {
|
||||
url = '/text-to-audio'
|
||||
isPublic = true
|
||||
}
|
||||
else if (params.appId) {
|
||||
if (pathname.search('explore/installed') > -1)
|
||||
url = `/installed-apps/${params.appId}/text-to-audio`
|
||||
else
|
||||
url = `/apps/${params.appId}/text-to-audio`
|
||||
}
|
||||
const handleToggle = async () => {
|
||||
if (audioState === 'playing' || audioState === 'loading') {
|
||||
setTimeout(() => setAudioState('paused'), 1)
|
||||
AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).pauseAudio()
|
||||
}
|
||||
else {
|
||||
setTimeout(() => setAudioState('loading'), 1)
|
||||
AudioPlayerManager.getInstance().getAudioPlayer(url, isPublic, id, value, voice, audio_finished_call).playAudio()
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipContent = {
|
||||
initial: t('appApi.play'),
|
||||
ended: t('appApi.play'),
|
||||
paused: t('appApi.pause'),
|
||||
playing: t('appApi.playing'),
|
||||
loading: t('appApi.loading'),
|
||||
}[audioState]
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center justify-center ${(audioState === 'loading' || audioState === 'playing') ? 'mr-1' : className}`}>
|
||||
<Tooltip
|
||||
popupContent={tooltipContent}
|
||||
>
|
||||
<button type="button"
|
||||
disabled={audioState === 'loading'}
|
||||
className={`box-border flex h-6 w-6 cursor-pointer items-center justify-center ${isAudition ? 'p-0.5' : 'rounded-md bg-white p-0'}`}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{audioState === 'loading'
|
||||
? (
|
||||
<div className='flex h-full w-full items-center justify-center rounded-md'>
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className={'flex h-full w-full items-center justify-center rounded-md hover:bg-gray-50'}>
|
||||
<div className={`h-4 w-4 ${(audioState === 'playing') ? s.pauseIcon : s.playIcon}`}></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioBtn
|
||||
10
dify/web/app/components/base/audio-btn/style.module.css
Normal file
10
dify/web/app/components/base/audio-btn/style.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.playIcon {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/play.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.pauseIcon {
|
||||
background-image: url(~@/app/components/develop/secret-key/assets/pause.svg);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
329
dify/web/app/components/base/audio-gallery/AudioPlayer.tsx
Normal file
329
dify/web/app/components/base/audio-gallery/AudioPlayer.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import {
|
||||
RiPauseCircleFill,
|
||||
RiPlayLargeFill,
|
||||
} from '@remixicon/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type AudioPlayerProps = {
|
||||
src?: string // Keep backward compatibility
|
||||
srcs?: string[] // Support multiple sources
|
||||
}
|
||||
|
||||
const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [waveformData, setWaveformData] = useState<number[]>([])
|
||||
const [bufferedTime, setBufferedTime] = useState(0)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [hasStartedPlaying, setHasStartedPlaying] = useState(false)
|
||||
const [hoverTime, setHoverTime] = useState(0)
|
||||
const [isAudioAvailable, setIsAudioAvailable] = useState(true)
|
||||
const { theme } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current
|
||||
if (!audio)
|
||||
return
|
||||
|
||||
const handleError = () => {
|
||||
setIsAudioAvailable(false)
|
||||
}
|
||||
|
||||
const setAudioData = () => {
|
||||
setDuration(audio.duration)
|
||||
}
|
||||
|
||||
const setAudioTime = () => {
|
||||
setCurrentTime(audio.currentTime)
|
||||
}
|
||||
|
||||
const handleProgress = () => {
|
||||
if (audio.buffered.length > 0)
|
||||
setBufferedTime(audio.buffered.end(audio.buffered.length - 1))
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
|
||||
audio.addEventListener('loadedmetadata', setAudioData)
|
||||
audio.addEventListener('timeupdate', setAudioTime)
|
||||
audio.addEventListener('progress', handleProgress)
|
||||
audio.addEventListener('ended', handleEnded)
|
||||
audio.addEventListener('error', handleError)
|
||||
|
||||
// Preload audio metadata
|
||||
audio.load()
|
||||
|
||||
// Use the first source or src to generate waveform
|
||||
const primarySrc = srcs?.[0] || src
|
||||
if (primarySrc) {
|
||||
// Delayed generation of waveform data
|
||||
// eslint-disable-next-line ts/no-use-before-define
|
||||
const timer = setTimeout(() => generateWaveformData(primarySrc), 1000)
|
||||
return () => {
|
||||
audio.removeEventListener('loadedmetadata', setAudioData)
|
||||
audio.removeEventListener('timeupdate', setAudioTime)
|
||||
audio.removeEventListener('progress', handleProgress)
|
||||
audio.removeEventListener('ended', handleEnded)
|
||||
audio.removeEventListener('error', handleError)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [src, srcs])
|
||||
|
||||
const generateWaveformData = async (audioSrc: string) => {
|
||||
if (!window.AudioContext && !(window as any).webkitAudioContext) {
|
||||
setIsAudioAvailable(false)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Web Audio API is not supported in this browser',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const primarySrc = srcs?.[0] || src
|
||||
const url = primarySrc ? new URL(primarySrc) : null
|
||||
const isHttp = url ? (url.protocol === 'http:' || url.protocol === 'https:') : false
|
||||
if (!isHttp) {
|
||||
setIsAudioAvailable(false)
|
||||
return null
|
||||
}
|
||||
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const samples = 70
|
||||
|
||||
try {
|
||||
const response = await fetch(audioSrc, { mode: 'cors' })
|
||||
if (!response || !response.ok) {
|
||||
setIsAudioAvailable(false)
|
||||
return null
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
||||
const channelData = audioBuffer.getChannelData(0)
|
||||
const blockSize = Math.floor(channelData.length / samples)
|
||||
const waveformData: number[] = []
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
let sum = 0
|
||||
for (let j = 0; j < blockSize; j++)
|
||||
sum += Math.abs(channelData[i * blockSize + j])
|
||||
|
||||
// Apply nonlinear scaling to enhance small amplitudes
|
||||
waveformData.push((sum / blockSize) * 5)
|
||||
}
|
||||
|
||||
// Normalized waveform data
|
||||
const maxAmplitude = Math.max(...waveformData)
|
||||
const normalizedWaveform = waveformData.map(amp => amp / maxAmplitude)
|
||||
|
||||
setWaveformData(normalizedWaveform)
|
||||
setIsAudioAvailable(true)
|
||||
}
|
||||
catch {
|
||||
const waveform: number[] = []
|
||||
let prevValue = Math.random()
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const targetValue = Math.random()
|
||||
const interpolatedValue = prevValue + (targetValue - prevValue) * 0.3
|
||||
waveform.push(interpolatedValue)
|
||||
prevValue = interpolatedValue
|
||||
}
|
||||
|
||||
const maxAmplitude = Math.max(...waveform)
|
||||
const randomWaveform = waveform.map(amp => amp / maxAmplitude)
|
||||
|
||||
setWaveformData(randomWaveform)
|
||||
setIsAudioAvailable(true)
|
||||
}
|
||||
finally {
|
||||
await audioContext.close()
|
||||
}
|
||||
}
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current
|
||||
if (audio && isAudioAvailable) {
|
||||
if (isPlaying) {
|
||||
setHasStartedPlaying(false)
|
||||
audio.pause()
|
||||
}
|
||||
else {
|
||||
setHasStartedPlaying(true)
|
||||
audio.play().catch(error => console.error('Error playing audio:', error))
|
||||
}
|
||||
|
||||
setIsPlaying(!isPlaying)
|
||||
}
|
||||
else {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Audio element not found',
|
||||
})
|
||||
setIsAudioAvailable(false)
|
||||
}
|
||||
}, [isAudioAvailable, isPlaying])
|
||||
|
||||
const handleCanvasInteraction = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const getClientX = (event: React.MouseEvent | React.TouchEvent): number => {
|
||||
if ('touches' in event)
|
||||
return event.touches[0].clientX
|
||||
return event.clientX
|
||||
}
|
||||
|
||||
const updateProgress = (clientX: number) => {
|
||||
const canvas = canvasRef.current
|
||||
const audio = audioRef.current
|
||||
if (!canvas || !audio)
|
||||
return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const percent = Math.min(Math.max(0, clientX - rect.left), rect.width) / rect.width
|
||||
const newTime = percent * duration
|
||||
|
||||
// Removes the buffer check, allowing drag to any location
|
||||
audio.currentTime = newTime
|
||||
setCurrentTime(newTime)
|
||||
|
||||
if (!isPlaying) {
|
||||
setIsPlaying(true)
|
||||
audio.play().catch((error) => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: `Error playing audio: ${error}`,
|
||||
})
|
||||
setIsPlaying(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
updateProgress(getClientX(e))
|
||||
}, [duration, isPlaying])
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = Math.floor(time % 60)
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const drawWaveform = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas)
|
||||
return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx)
|
||||
return
|
||||
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const data = waveformData
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const barWidth = width / data.length
|
||||
const playedWidth = (currentTime / duration) * width
|
||||
const cornerRadius = 2
|
||||
|
||||
// Draw waveform bars
|
||||
data.forEach((value, index) => {
|
||||
let color
|
||||
|
||||
if (index * barWidth <= playedWidth)
|
||||
color = theme === Theme.light ? '#296DFF' : '#84ABFF'
|
||||
else if ((index * barWidth / width) * duration <= hoverTime)
|
||||
color = theme === Theme.light ? 'rgba(21,90,239,.40)' : 'rgba(200, 206, 218, 0.28)'
|
||||
else
|
||||
color = theme === Theme.light ? 'rgba(21,90,239,.20)' : 'rgba(200, 206, 218, 0.14)'
|
||||
|
||||
const barHeight = value * height
|
||||
const rectX = index * barWidth
|
||||
const rectY = (height - barHeight) / 2
|
||||
const rectWidth = barWidth * 0.5
|
||||
const rectHeight = barHeight
|
||||
|
||||
ctx.lineWidth = 1
|
||||
ctx.fillStyle = color
|
||||
if (ctx.roundRect) {
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius)
|
||||
ctx.fill()
|
||||
}
|
||||
else {
|
||||
ctx.fillRect(rectX, rectY, rectWidth, rectHeight)
|
||||
}
|
||||
})
|
||||
}, [currentTime, duration, hoverTime, theme, waveformData])
|
||||
|
||||
useEffect(() => {
|
||||
drawWaveform()
|
||||
}, [drawWaveform, bufferedTime, hasStartedPlaying])
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
const canvas = canvasRef.current
|
||||
const audio = audioRef.current
|
||||
if (!canvas || !audio)
|
||||
return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const percent = Math.min(Math.max(0, e.clientX - rect.left), rect.width) / rect.width
|
||||
const time = percent * duration
|
||||
|
||||
// Check if the hovered position is within a buffered range before updating hoverTime
|
||||
for (let i = 0; i < audio.buffered.length; i++) {
|
||||
if (time >= audio.buffered.start(i) && time <= audio.buffered.end(i)) {
|
||||
setHoverTime(time)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [duration])
|
||||
|
||||
return (
|
||||
<div className='flex h-9 min-w-[240px] max-w-[420px] items-center gap-2 rounded-[10px] border border-components-panel-border-subtle bg-components-chat-input-audio-bg-alt p-2 shadow-xs backdrop-blur-sm'>
|
||||
<audio ref={audioRef} src={src} preload="auto">
|
||||
{/* If srcs array is provided, render multiple source elements */}
|
||||
{srcs && srcs.map((srcUrl, index) => (
|
||||
<source key={index} src={srcUrl} />
|
||||
))}
|
||||
</audio>
|
||||
<button type="button" className='inline-flex shrink-0 cursor-pointer items-center justify-center border-none text-text-accent transition-all hover:text-text-accent-secondary disabled:text-components-button-primary-bg-disabled' onClick={togglePlay} disabled={!isAudioAvailable}>
|
||||
{isPlaying
|
||||
? (
|
||||
<RiPauseCircleFill className='h-5 w-5' />
|
||||
)
|
||||
: (
|
||||
<RiPlayLargeFill className='h-5 w-5' />
|
||||
)}
|
||||
</button>
|
||||
<div className={cn(isAudioAvailable && 'grow')} hidden={!isAudioAvailable}>
|
||||
<div className='flex h-8 items-center justify-center'>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className='relative flex h-6 w-full grow cursor-pointer items-center justify-center'
|
||||
onClick={handleCanvasInteraction}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleCanvasInteraction}
|
||||
/>
|
||||
<div className='system-xs-medium inline-flex min-w-[50px] items-center justify-center text-text-accent-secondary'>
|
||||
<span className='rounded-[10px] px-0.5 py-1'>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute left-0 top-0 flex h-full w-full items-center justify-center text-text-quaternary' hidden={isAudioAvailable}>{t('common.operation.audioSourceUnavailable')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioPlayer
|
||||
37
dify/web/app/components/base/audio-gallery/index.stories.tsx
Normal file
37
dify/web/app/components/base/audio-gallery/index.stories.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import AudioGallery from '.'
|
||||
|
||||
const AUDIO_SOURCES = [
|
||||
'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
|
||||
]
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/AudioGallery',
|
||||
component: AudioGallery,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'List of audio players that render waveform previews and playback controls for each source.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<AudioGallery
|
||||
srcs={[
|
||||
'https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3',
|
||||
]}
|
||||
/>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
srcs: AUDIO_SOURCES,
|
||||
},
|
||||
} satisfies Meta<typeof AudioGallery>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
19
dify/web/app/components/base/audio-gallery/index.tsx
Normal file
19
dify/web/app/components/base/audio-gallery/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import AudioPlayer from './AudioPlayer'
|
||||
|
||||
type Props = {
|
||||
srcs: string[]
|
||||
}
|
||||
|
||||
const AudioGallery: React.FC<Props> = ({ srcs }) => {
|
||||
const validSrcs = srcs.filter(src => src)
|
||||
if (validSrcs.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="my-3">
|
||||
<AudioPlayer srcs={validSrcs} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AudioGallery)
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import AutoHeightTextarea from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/AutoHeightTextarea',
|
||||
component: AutoHeightTextarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Auto-resizing textarea component that expands and contracts based on content, with configurable min/max height constraints.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Textarea value',
|
||||
},
|
||||
onChange: {
|
||||
action: 'changed',
|
||||
description: 'Change handler',
|
||||
},
|
||||
minHeight: {
|
||||
control: 'number',
|
||||
description: 'Minimum height in pixels',
|
||||
},
|
||||
maxHeight: {
|
||||
control: 'number',
|
||||
description: 'Maximum height in pixels',
|
||||
},
|
||||
autoFocus: {
|
||||
control: 'boolean',
|
||||
description: 'Auto focus on mount',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional CSS classes',
|
||||
},
|
||||
wrapperClassName: {
|
||||
control: 'text',
|
||||
description: 'Wrapper CSS classes',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onChange: (e) => {
|
||||
console.log('Text changed:', e.target.value)
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AutoHeightTextarea>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const AutoHeightTextareaDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<AutoHeightTextarea
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
console.log('Text changed:', e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'This is a pre-filled textarea with some initial content.',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With multiline content
|
||||
export const MultilineContent: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'Line 1\nLine 2\nLine 3\nLine 4\nThis textarea automatically expands to fit the content.',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Custom min height
|
||||
export const CustomMinHeight: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Taller minimum height...',
|
||||
value: '',
|
||||
minHeight: 100,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Small max height (scrollable)
|
||||
export const SmallMaxHeight: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type multiple lines...',
|
||||
value: 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nThis will become scrollable when it exceeds max height.',
|
||||
minHeight: 36,
|
||||
maxHeight: 80,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Auto focus enabled
|
||||
export const AutoFocus: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'This textarea auto-focuses on mount',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
autoFocus: true,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// With custom styling
|
||||
export const CustomStyling: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Custom styled textarea...',
|
||||
value: '',
|
||||
minHeight: 50,
|
||||
maxHeight: 150,
|
||||
className: 'w-full p-3 bg-gray-50 border-2 border-blue-400 rounded-xl text-lg focus:outline-none focus:bg-white focus:border-blue-600',
|
||||
wrapperClassName: 'shadow-lg',
|
||||
},
|
||||
}
|
||||
|
||||
// Long content example
|
||||
export const LongContent: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n\nUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\n\nExcepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
|
||||
minHeight: 36,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Chat input
|
||||
export const ChatInput: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type your message...',
|
||||
value: '',
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
className: 'w-full px-4 py-2 bg-gray-100 border border-gray-300 rounded-2xl text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-blue-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Comment box
|
||||
export const CommentBox: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Write a comment...',
|
||||
value: '',
|
||||
minHeight: 60,
|
||||
maxHeight: 200,
|
||||
className: 'w-full p-3 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500',
|
||||
},
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <AutoHeightTextareaDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
value: '',
|
||||
minHeight: 36,
|
||||
maxHeight: 96,
|
||||
autoFocus: false,
|
||||
className: 'w-full p-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||
wrapperClassName: '',
|
||||
},
|
||||
}
|
||||
96
dify/web/app/components/base/auto-height-textarea/index.tsx
Normal file
96
dify/web/app/components/base/auto-height-textarea/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { sleep } from '@/utils'
|
||||
|
||||
type IProps = {
|
||||
placeholder?: string
|
||||
value: string
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
className?: string
|
||||
wrapperClassName?: string
|
||||
minHeight?: number
|
||||
maxHeight?: number
|
||||
autoFocus?: boolean
|
||||
controlFocus?: number
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
const AutoHeightTextarea = (
|
||||
{
|
||||
ref: outerRef,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
wrapperClassName,
|
||||
minHeight = 36,
|
||||
maxHeight = 96,
|
||||
autoFocus,
|
||||
controlFocus,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
}: IProps & {
|
||||
ref?: React.RefObject<HTMLTextAreaElement>;
|
||||
},
|
||||
) => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const ref = outerRef || useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const doFocus = () => {
|
||||
if (ref.current) {
|
||||
ref.current.setSelectionRange(value.length, value.length)
|
||||
ref.current.focus()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const focus = async () => {
|
||||
if (!doFocus()) {
|
||||
let hasFocus = false
|
||||
await sleep(100)
|
||||
hasFocus = doFocus()
|
||||
if (!hasFocus)
|
||||
focus()
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus)
|
||||
focus()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (controlFocus)
|
||||
focus()
|
||||
}, [controlFocus])
|
||||
|
||||
return (
|
||||
(<div className={`relative ${wrapperClassName}`}>
|
||||
<div className={cn(className, 'invisible overflow-y-auto whitespace-pre-wrap break-all')} style={{
|
||||
minHeight,
|
||||
maxHeight,
|
||||
paddingRight: (value && value.trim().length > 10000) ? 140 : 130,
|
||||
}}>
|
||||
{!value ? placeholder : value.replace(/\n$/, '\n ')}
|
||||
</div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
autoFocus={autoFocus}
|
||||
className={cn(className, 'absolute inset-0 resize-none overflow-auto')}
|
||||
style={{
|
||||
paddingRight: (value && value.trim().length > 10000) ? 140 : 130,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
value={value}
|
||||
/>
|
||||
</div>)
|
||||
)
|
||||
}
|
||||
|
||||
AutoHeightTextarea.displayName = 'AutoHeightTextarea'
|
||||
|
||||
export default AutoHeightTextarea
|
||||
73
dify/web/app/components/base/avatar/index.stories.tsx
Normal file
73
dify/web/app/components/base/avatar/index.stories.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Avatar from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/Avatar',
|
||||
component: Avatar,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Initials or image-based avatar used across contacts and member lists. Falls back to the first letter when the image fails to load.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Avatar name="Alex Doe" avatar="https://cloud.dify.ai/logo/logo.svg" size={40} />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
name: 'Alex Doe',
|
||||
avatar: 'https://cloud.dify.ai/logo/logo.svg',
|
||||
size: 40,
|
||||
},
|
||||
} satisfies Meta<typeof Avatar>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithFallback: Story = {
|
||||
args: {
|
||||
avatar: null,
|
||||
name: 'Fallback',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Avatar name="Fallback" avatar={null} size={40} />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomSizes: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-end gap-4">
|
||||
{[24, 32, 48, 64].map(size => (
|
||||
<div key={size} className="flex flex-col items-center gap-2">
|
||||
<Avatar {...args} size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
|
||||
<span className="text-xs text-text-tertiary">{size}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
{[24, 32, 48, 64].map(size => (
|
||||
<Avatar key={size} name="Size Test" size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
|
||||
))}
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
64
dify/web/app/components/base/avatar/index.tsx
Normal file
64
dify/web/app/components/base/avatar/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type AvatarProps = {
|
||||
name: string
|
||||
avatar: string | null
|
||||
size?: number
|
||||
className?: string
|
||||
textClassName?: string
|
||||
onError?: (x: boolean) => void
|
||||
}
|
||||
const Avatar = ({
|
||||
name,
|
||||
avatar,
|
||||
size = 30,
|
||||
className,
|
||||
textClassName,
|
||||
onError,
|
||||
}: AvatarProps) => {
|
||||
const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600'
|
||||
const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
|
||||
const [imgError, setImgError] = useState(false)
|
||||
|
||||
const handleError = () => {
|
||||
setImgError(true)
|
||||
onError?.(true)
|
||||
}
|
||||
|
||||
// after uploaded, api would first return error imgs url: '.../files//file-preview/...'. Then return the right url, Which caused not show the avatar
|
||||
useEffect(() => {
|
||||
if(avatar && imgError)
|
||||
setImgError(false)
|
||||
}, [avatar])
|
||||
|
||||
if (avatar && !imgError) {
|
||||
return (
|
||||
<img
|
||||
className={cn(avatarClassName, className)}
|
||||
style={style}
|
||||
alt={name}
|
||||
src={avatar}
|
||||
onError={handleError}
|
||||
onLoad={() => onError?.(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(avatarClassName, className)}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={cn(textClassName, 'scale-[0.4] text-center text-white')}
|
||||
style={style}
|
||||
>
|
||||
{name && name[0].toLocaleUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
||||
37
dify/web/app/components/base/badge.tsx
Normal file
37
dify/web/app/components/base/badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { memo } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type BadgeProps = {
|
||||
className?: string
|
||||
text?: ReactNode
|
||||
children?: ReactNode
|
||||
uppercase?: boolean
|
||||
hasRedCornerMark?: boolean
|
||||
}
|
||||
|
||||
const Badge = ({
|
||||
className,
|
||||
text,
|
||||
children,
|
||||
uppercase = true,
|
||||
hasRedCornerMark,
|
||||
}: BadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative inline-flex h-5 items-center whitespace-nowrap rounded-[5px] border border-divider-deep px-[5px] leading-3 text-text-tertiary',
|
||||
uppercase ? 'system-2xs-medium-uppercase' : 'system-xs-medium',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasRedCornerMark && (
|
||||
<div className='absolute right-[-2px] top-[-2px] h-1.5 w-1.5 rounded-[2px] border border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-sm'>
|
||||
</div>
|
||||
)}
|
||||
{children || text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Badge)
|
||||
28
dify/web/app/components/base/badge/index.css
Normal file
28
dify/web/app/components/base/badge/index.css
Normal file
@@ -0,0 +1,28 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.badge {
|
||||
@apply inline-flex justify-center items-center text-text-tertiary border border-divider-deep
|
||||
}
|
||||
|
||||
.badge-l {
|
||||
@apply rounded-md gap-1 min-w-6
|
||||
}
|
||||
|
||||
/* m is for the regular button */
|
||||
.badge-m {
|
||||
@apply rounded-md gap-[3px] min-w-5
|
||||
}
|
||||
|
||||
.badge-s {
|
||||
@apply rounded-[5px] gap-0.5 min-w-[18px]
|
||||
}
|
||||
|
||||
.badge.badge-warning {
|
||||
@apply text-text-warning border border-text-warning
|
||||
}
|
||||
|
||||
.badge.badge-accent {
|
||||
@apply text-text-accent-secondary border border-text-accent-secondary
|
||||
}
|
||||
}
|
||||
73
dify/web/app/components/base/badge/index.stories.tsx
Normal file
73
dify/web/app/components/base/badge/index.stories.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import Badge from '../badge'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Display/Badge',
|
||||
component: Badge,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compact label used for statuses and counts. Supports uppercase styling and optional red corner marks.',
|
||||
},
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Badge text="beta" />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
text: 'beta',
|
||||
uppercase: true,
|
||||
},
|
||||
} satisfies Meta<typeof Badge>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {}
|
||||
|
||||
export const WithCornerMark: Story = {
|
||||
args: {
|
||||
text: 'new',
|
||||
hasRedCornerMark: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Badge text="new" hasRedCornerMark />
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomContent: Story = {
|
||||
render: args => (
|
||||
<Badge {...args} uppercase={false}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Production
|
||||
</span>
|
||||
</Badge>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
source: {
|
||||
language: 'tsx',
|
||||
code: `
|
||||
<Badge uppercase={false}>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-400" />
|
||||
Production
|
||||
</span>
|
||||
</Badge>
|
||||
`.trim(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
81
dify/web/app/components/base/badge/index.tsx
Normal file
81
dify/web/app/components/base/badge/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import classNames from '@/utils/classnames'
|
||||
import './index.css'
|
||||
|
||||
enum BadgeState {
|
||||
Warning = 'warning',
|
||||
Accent = 'accent',
|
||||
Default = '',
|
||||
}
|
||||
|
||||
const BadgeVariants = cva(
|
||||
'badge',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
s: 'badge-s',
|
||||
m: 'badge-m',
|
||||
l: 'badge-l',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'm',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type BadgeProps = {
|
||||
size?: 's' | 'm' | 'l'
|
||||
iconOnly?: boolean
|
||||
uppercase?: boolean
|
||||
state?: BadgeState
|
||||
styleCss?: CSSProperties
|
||||
children?: ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof BadgeVariants>
|
||||
|
||||
function getBadgeState(state: BadgeState) {
|
||||
switch (state) {
|
||||
case BadgeState.Warning:
|
||||
return 'badge-warning'
|
||||
case BadgeState.Accent:
|
||||
return 'badge-accent'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({
|
||||
className,
|
||||
size,
|
||||
state = BadgeState.Default,
|
||||
iconOnly = false,
|
||||
uppercase = false,
|
||||
styleCss,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
BadgeVariants({ size, className }),
|
||||
getBadgeState(state),
|
||||
size === 's'
|
||||
? (iconOnly ? 'p-[3px]' : 'px-[5px] py-[3px]')
|
||||
: size === 'l'
|
||||
? (iconOnly ? 'p-1.5' : 'px-2 py-1')
|
||||
: (iconOnly ? 'p-1' : 'px-[5px] py-[2px]'),
|
||||
uppercase ? 'system-2xs-medium-uppercase' : 'system-2xs-medium',
|
||||
)}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Badge.displayName = 'Badge'
|
||||
|
||||
export default Badge
|
||||
export { Badge, BadgeState, BadgeVariants }
|
||||
191
dify/web/app/components/base/block-input/index.stories.tsx
Normal file
191
dify/web/app/components/base/block-input/index.stories.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import BlockInput from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/BlockInput',
|
||||
component: BlockInput,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Input value (supports {{variable}} syntax)',
|
||||
},
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Wrapper CSS classes',
|
||||
},
|
||||
highLightClassName: {
|
||||
control: 'text',
|
||||
description: 'CSS class for highlighted variables (default: text-blue-500)',
|
||||
},
|
||||
readonly: {
|
||||
control: 'boolean',
|
||||
description: 'Read-only mode',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof BlockInput>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const BlockInputDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
const [keys, setKeys] = useState<string[]>([])
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }}>
|
||||
<BlockInput
|
||||
{...args}
|
||||
value={value}
|
||||
onConfirm={(newValue, extractedKeys) => {
|
||||
setValue(newValue)
|
||||
setKeys(extractedKeys)
|
||||
console.log('Value confirmed:', newValue)
|
||||
console.log('Extracted keys:', extractedKeys)
|
||||
}}
|
||||
/>
|
||||
{keys.length > 0 && (
|
||||
<div className="mt-4 rounded-lg bg-blue-50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-gray-700">Detected Variables:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{keys.map(key => (
|
||||
<span key={key} className="rounded bg-blue-500 px-2 py-1 text-xs text-white">
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With single variable
|
||||
export const SingleVariable: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hello {{name}}, welcome to the application!',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With multiple variables
|
||||
export const MultipleVariables: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Complex template
|
||||
export const ComplexTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Read-only mode
|
||||
export const ReadOnlyMode: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.',
|
||||
readonly: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Empty state
|
||||
export const EmptyState: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Long content
|
||||
export const LongContent: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Variables with underscores
|
||||
export const VariablesWithUnderscores: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Adjacent variables
|
||||
export const AdjacentVariables: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Email template
|
||||
export const EmailTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Notification template
|
||||
export const NotificationTemplate: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}',
|
||||
readonly: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Custom styling
|
||||
export const CustomStyling: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'This template uses {{custom_variable}} with custom styling.',
|
||||
readonly: false,
|
||||
className: 'bg-gray-50 border-2 border-blue-200',
|
||||
},
|
||||
}
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <BlockInputDemo {...args} />,
|
||||
args: {
|
||||
value: 'Try editing this text and adding variables like {{example}}',
|
||||
readonly: false,
|
||||
className: '',
|
||||
highLightClassName: '',
|
||||
},
|
||||
}
|
||||
159
dify/web/app/components/base/block-input/index.tsx
Normal file
159
dify/web/app/components/base/block-input/index.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import VarHighlight from '../../app/configuration/base/var-highlight'
|
||||
import Toast from '../toast'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
|
||||
// regex to match the {{}} and replace it with a span
|
||||
const regex = /\{\{([^}]+)\}\}/g
|
||||
|
||||
export const getInputKeys = (value: string) => {
|
||||
const keys = value.match(regex)?.map((item) => {
|
||||
return item.replace('{{', '').replace('}}', '')
|
||||
}) || []
|
||||
const keyObj: Record<string, boolean> = {}
|
||||
// remove duplicate keys
|
||||
const res: string[] = []
|
||||
keys.forEach((key) => {
|
||||
if (keyObj[key])
|
||||
return
|
||||
|
||||
keyObj[key] = true
|
||||
res.push(key)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export type IBlockInputProps = {
|
||||
value: string
|
||||
className?: string // wrapper class
|
||||
highLightClassName?: string // class for the highlighted text default is text-blue-500
|
||||
readonly?: boolean
|
||||
onConfirm?: (value: string, keys: string[]) => void
|
||||
}
|
||||
|
||||
const BlockInput: FC<IBlockInputProps> = ({
|
||||
value = '',
|
||||
className,
|
||||
readonly = false,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
// current is used to store the current value of the contentEditable element
|
||||
const [currentValue, setCurrentValue] = useState<string>(value)
|
||||
useEffect(() => {
|
||||
setCurrentValue(value)
|
||||
}, [value])
|
||||
|
||||
const contentEditableRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
if (isEditing && contentEditableRef.current) {
|
||||
// TODO: Focus at the click position
|
||||
if (currentValue)
|
||||
contentEditableRef.current.setSelectionRange(currentValue.length, currentValue.length)
|
||||
|
||||
contentEditableRef.current.focus()
|
||||
}
|
||||
}, [isEditing])
|
||||
|
||||
const style = classNames({
|
||||
'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': true,
|
||||
'block-input--editing': isEditing,
|
||||
})
|
||||
|
||||
const renderSafeContent = (value: string) => {
|
||||
const parts = value.split(/(\{\{[^}]+\}\}|\n)/g)
|
||||
return parts.map((part, index) => {
|
||||
const variableMatch = part.match(/^\{\{([^}]+)\}\}$/)
|
||||
if (variableMatch) {
|
||||
return (
|
||||
<VarHighlight
|
||||
key={`var-${index}`}
|
||||
name={variableMatch[1]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (part === '\n')
|
||||
return <br key={`br-${index}`} />
|
||||
|
||||
return <span key={`text-${index}`}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
// Not use useCallback. That will cause out callback get old data.
|
||||
const handleSubmit = (value: string) => {
|
||||
if (onConfirm) {
|
||||
const keys = getInputKeys(value)
|
||||
const { isValid, errorKey, errorMessageKey } = checkKeys(keys)
|
||||
if (!isValid) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }),
|
||||
})
|
||||
return
|
||||
}
|
||||
onConfirm(value, keys)
|
||||
}
|
||||
}
|
||||
|
||||
const onValueChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setCurrentValue(value)
|
||||
handleSubmit(value)
|
||||
}, [])
|
||||
|
||||
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
|
||||
const TextAreaContentView = () => {
|
||||
return (
|
||||
<div className={classNames(style, className)}>
|
||||
{renderSafeContent(currentValue || '')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const placeholder = ''
|
||||
const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
|
||||
|
||||
const textAreaContent = (
|
||||
<div className={classNames(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', ' overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
|
||||
{isEditing
|
||||
? <div className='h-full px-4 py-2'>
|
||||
<textarea
|
||||
ref={contentEditableRef}
|
||||
className={classNames(editAreaClassName, 'block h-full w-full resize-none')}
|
||||
placeholder={placeholder}
|
||||
onChange={onValueChange}
|
||||
value={currentValue}
|
||||
onBlur={() => {
|
||||
blur()
|
||||
setIsEditing(false)
|
||||
// click confirm also make blur. Then outer value is change. So below code has problem.
|
||||
// setTimeout(() => {
|
||||
// handleCancel()
|
||||
// }, 1000)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
: <TextAreaContentView />}
|
||||
</div>)
|
||||
|
||||
return (
|
||||
<div className={classNames('block-input w-full overflow-y-auto rounded-xl border-none bg-white')}>
|
||||
{textAreaContent}
|
||||
{/* footer */}
|
||||
{!readonly && (
|
||||
<div className='flex pb-2 pl-4'>
|
||||
<div className="h-[18px] rounded-md bg-gray-100 px-1 text-xs leading-[18px] text-gray-500">{currentValue?.length}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BlockInput)
|
||||
52
dify/web/app/components/base/button/add-button.stories.tsx
Normal file
52
dify/web/app/components/base/button/add-button.stories.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import AddButton from './add-button'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/AddButton',
|
||||
component: AddButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compact icon-only button used for inline “add” actions in lists, cards, and modals.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Extra classes appended to the clickable container.',
|
||||
},
|
||||
onClick: {
|
||||
control: false,
|
||||
description: 'Triggered when the add button is pressed.',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onClick: () => console.log('Add button clicked'),
|
||||
},
|
||||
} satisfies Meta<typeof AddButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
className: 'bg-white/80 shadow-sm backdrop-blur-sm',
|
||||
},
|
||||
}
|
||||
|
||||
export const InToolbar: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-3">
|
||||
<span className="text-xs text-text-tertiary">Attachments</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<AddButton {...args} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
className: 'border border-dashed border-primary-200',
|
||||
},
|
||||
}
|
||||
22
dify/web/app/components/base/button/add-button.tsx
Normal file
22
dify/web/app/components/base/button/add-button.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { RiAddLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const AddButton: FC<Props> = ({
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
|
||||
<RiAddLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AddButton)
|
||||
188
dify/web/app/components/base/button/index.css
Normal file
188
dify/web/app/components/base/button/index.css
Normal file
@@ -0,0 +1,188 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex justify-center items-center cursor-pointer whitespace-nowrap;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
@apply px-2 h-6 rounded-md text-xs font-medium;
|
||||
}
|
||||
|
||||
.btn-medium {
|
||||
@apply px-3.5 h-8 rounded-lg text-[13px] leading-4 font-medium;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
@apply px-4 h-9 rounded-[10px] text-sm font-semibold;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply
|
||||
shadow
|
||||
bg-components-button-primary-bg
|
||||
border-components-button-primary-border
|
||||
hover:bg-components-button-primary-bg-hover
|
||||
hover:border-components-button-primary-border-hover
|
||||
text-components-button-primary-text;
|
||||
}
|
||||
|
||||
.btn-primary.btn-destructive {
|
||||
@apply
|
||||
bg-components-button-destructive-primary-bg
|
||||
border-components-button-destructive-primary-border
|
||||
hover:bg-components-button-destructive-primary-bg-hover
|
||||
hover:border-components-button-destructive-primary-border-hover
|
||||
text-components-button-destructive-primary-text;
|
||||
}
|
||||
|
||||
.btn-primary.btn-disabled {
|
||||
@apply
|
||||
shadow-none
|
||||
bg-components-button-primary-bg-disabled
|
||||
border-components-button-primary-border-disabled
|
||||
text-components-button-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-primary.btn-destructive.btn-disabled {
|
||||
@apply
|
||||
shadow-none
|
||||
bg-components-button-destructive-primary-bg-disabled
|
||||
border-components-button-destructive-primary-border-disabled
|
||||
text-components-button-destructive-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply
|
||||
border-[0.5px]
|
||||
shadow-xs
|
||||
backdrop-blur-[5px]
|
||||
bg-components-button-secondary-bg
|
||||
border-components-button-secondary-border
|
||||
hover:bg-components-button-secondary-bg-hover
|
||||
hover:border-components-button-secondary-border-hover
|
||||
text-components-button-secondary-text;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-disabled {
|
||||
@apply
|
||||
backdrop-blur-sm
|
||||
bg-components-button-secondary-bg-disabled
|
||||
border-components-button-secondary-border-disabled
|
||||
text-components-button-secondary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-destructive {
|
||||
@apply
|
||||
bg-components-button-destructive-secondary-bg
|
||||
border-components-button-destructive-secondary-border
|
||||
hover:bg-components-button-destructive-secondary-bg-hover
|
||||
hover:border-components-button-destructive-secondary-border-hover
|
||||
text-components-button-destructive-secondary-text;
|
||||
}
|
||||
|
||||
.btn-secondary.btn-destructive.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-destructive-secondary-bg-disabled
|
||||
border-components-button-destructive-secondary-border-disabled
|
||||
text-components-button-destructive-secondary-text-disabled;
|
||||
}
|
||||
|
||||
|
||||
.btn-secondary-accent {
|
||||
@apply
|
||||
border-[0.5px]
|
||||
shadow-xs
|
||||
bg-components-button-secondary-bg
|
||||
border-components-button-secondary-border
|
||||
hover:bg-components-button-secondary-bg-hover
|
||||
hover:border-components-button-secondary-border-hover
|
||||
text-components-button-secondary-accent-text;
|
||||
}
|
||||
|
||||
.btn-secondary-accent.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-secondary-bg-disabled
|
||||
border-components-button-secondary-border-disabled
|
||||
text-components-button-secondary-accent-text-disabled;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply
|
||||
bg-components-button-destructive-primary-bg
|
||||
border-components-button-destructive-primary-border
|
||||
hover:bg-components-button-destructive-primary-bg-hover
|
||||
hover:border-components-button-destructive-primary-border-hover
|
||||
text-components-button-destructive-primary-text;
|
||||
}
|
||||
|
||||
.btn-warning.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-destructive-primary-bg-disabled
|
||||
border-components-button-destructive-primary-border-disabled
|
||||
text-components-button-destructive-primary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-tertiary {
|
||||
@apply
|
||||
bg-components-button-tertiary-bg
|
||||
hover:bg-components-button-tertiary-bg-hover
|
||||
text-components-button-tertiary-text;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-tertiary-bg-disabled
|
||||
text-components-button-tertiary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-destructive {
|
||||
@apply
|
||||
bg-components-button-destructive-tertiary-bg
|
||||
hover:bg-components-button-destructive-tertiary-bg-hover
|
||||
text-components-button-destructive-tertiary-text;
|
||||
}
|
||||
|
||||
.btn-tertiary.btn-destructive.btn-disabled {
|
||||
@apply
|
||||
bg-components-button-destructive-tertiary-bg-disabled
|
||||
text-components-button-destructive-tertiary-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply
|
||||
hover:bg-components-button-ghost-bg-hover
|
||||
text-components-button-ghost-text;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-disabled {
|
||||
@apply
|
||||
text-components-button-ghost-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-destructive {
|
||||
@apply
|
||||
hover:bg-components-button-destructive-ghost-bg-hover
|
||||
text-components-button-destructive-ghost-text;
|
||||
}
|
||||
|
||||
.btn-ghost.btn-destructive.btn-disabled {
|
||||
@apply
|
||||
text-components-button-destructive-ghost-text-disabled;
|
||||
}
|
||||
|
||||
.btn-ghost-accent {
|
||||
@apply
|
||||
hover:bg-state-accent-hover
|
||||
text-components-button-secondary-accent-text;
|
||||
}
|
||||
|
||||
.btn-ghost-accent.btn-disabled {
|
||||
@apply
|
||||
text-components-button-secondary-accent-text-disabled;
|
||||
}
|
||||
}
|
||||
110
dify/web/app/components/base/button/index.spec.tsx
Normal file
110
dify/web/app/components/base/button/index.spec.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import Button from './index'
|
||||
|
||||
afterEach(cleanup)
|
||||
// https://testing-library.com/docs/queries/about
|
||||
describe('Button', () => {
|
||||
describe('Button text', () => {
|
||||
test('Button text should be same as children', async () => {
|
||||
const { getByRole, container } = render(<Button>Click me</Button>)
|
||||
expect(getByRole('button').textContent).toBe('Click me')
|
||||
expect(container.querySelector('button')?.textContent).toBe('Click me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button loading', () => {
|
||||
test('Loading button text should include same as children', async () => {
|
||||
const { getByRole } = render(<Button loading>Click me</Button>)
|
||||
expect(getByRole('button').textContent?.includes('Loading')).toBe(true)
|
||||
})
|
||||
test('Not loading button text should include same as children', async () => {
|
||||
const { getByRole } = render(<Button loading={false}>Click me</Button>)
|
||||
expect(getByRole('button').textContent?.includes('Loading')).toBe(false)
|
||||
})
|
||||
|
||||
test('Loading button should have loading classname', async () => {
|
||||
const animClassName = 'anim-breath'
|
||||
const { getByRole } = render(<Button loading spinnerClassName={animClassName}>Click me</Button>)
|
||||
expect(getByRole('button').getElementsByClassName('animate-spin')[0]?.className).toContain(animClassName)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button style', () => {
|
||||
test('Button should have default variant', async () => {
|
||||
const { getByRole } = render(<Button>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-secondary')
|
||||
})
|
||||
|
||||
test('Button should have primary variant', async () => {
|
||||
const { getByRole } = render(<Button variant='primary'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-primary')
|
||||
})
|
||||
|
||||
test('Button should have warning variant', async () => {
|
||||
const { getByRole } = render(<Button variant='warning'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-warning')
|
||||
})
|
||||
|
||||
test('Button should have secondary variant', async () => {
|
||||
const { getByRole } = render(<Button variant='secondary'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-secondary')
|
||||
})
|
||||
|
||||
test('Button should have secondary-accent variant', async () => {
|
||||
const { getByRole } = render(<Button variant='secondary-accent'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-secondary-accent')
|
||||
})
|
||||
test('Button should have ghost variant', async () => {
|
||||
const { getByRole } = render(<Button variant='ghost'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-ghost')
|
||||
})
|
||||
test('Button should have ghost-accent variant', async () => {
|
||||
const { getByRole } = render(<Button variant='ghost-accent'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-ghost-accent')
|
||||
})
|
||||
|
||||
test('Button disabled should have disabled variant', async () => {
|
||||
const { getByRole } = render(<Button disabled>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button size', () => {
|
||||
test('Button should have default size', async () => {
|
||||
const { getByRole } = render(<Button>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-medium')
|
||||
})
|
||||
|
||||
test('Button should have small size', async () => {
|
||||
const { getByRole } = render(<Button size='small'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-small')
|
||||
})
|
||||
|
||||
test('Button should have medium size', async () => {
|
||||
const { getByRole } = render(<Button size='medium'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-medium')
|
||||
})
|
||||
|
||||
test('Button should have large size', async () => {
|
||||
const { getByRole } = render(<Button size='large'>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-large')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button destructive', () => {
|
||||
test('Button should have destructive classname', async () => {
|
||||
const { getByRole } = render(<Button destructive>Click me</Button>)
|
||||
expect(getByRole('button').className).toContain('btn-destructive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button events', () => {
|
||||
test('onClick should been call after clicked', async () => {
|
||||
const onClick = jest.fn()
|
||||
const { getByRole } = render(<Button onClick={onClick}>Click me</Button>)
|
||||
fireEvent.click(getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
108
dify/web/app/components/base/button/index.stories.tsx
Normal file
108
dify/web/app/components/base/button/index.stories.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
|
||||
import { RocketLaunchIcon } from '@heroicons/react/20/solid'
|
||||
import { Button } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
loading: { control: 'boolean' },
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'warning', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary'],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
variant: 'ghost',
|
||||
children: 'Button',
|
||||
},
|
||||
} satisfies Meta<typeof Button>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
loading: false,
|
||||
children: 'Primary Button',
|
||||
styleCss: {},
|
||||
spinnerClassName: '',
|
||||
destructive: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
children: 'Secondary Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const SecondaryAccent: Story = {
|
||||
args: {
|
||||
variant: 'secondary-accent',
|
||||
children: 'Secondary Accent Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
variant: 'ghost',
|
||||
children: 'Ghost Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const GhostAccent: Story = {
|
||||
args: {
|
||||
variant: 'ghost-accent',
|
||||
children: 'Ghost Accent Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Tertiary: Story = {
|
||||
args: {
|
||||
variant: 'tertiary',
|
||||
children: 'Tertiary Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
children: 'Warning Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
disabled: true,
|
||||
children: 'Disabled Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
loading: true,
|
||||
children: 'Loading Button',
|
||||
},
|
||||
}
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
children: (
|
||||
<>
|
||||
<RocketLaunchIcon className="mr-1.5 h-4 w-4 stroke-[1.8px]" />
|
||||
Launch
|
||||
</>
|
||||
),
|
||||
},
|
||||
}
|
||||
61
dify/web/app/components/base/button/index.tsx
Normal file
61
dify/web/app/components/base/button/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import React from 'react'
|
||||
import { type VariantProps, cva } from 'class-variance-authority'
|
||||
import Spinner from '../spinner'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'btn disabled:btn-disabled',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
'primary': 'btn-primary',
|
||||
'warning': 'btn-warning',
|
||||
'secondary': 'btn-secondary',
|
||||
'secondary-accent': 'btn-secondary-accent',
|
||||
'ghost': 'btn-ghost',
|
||||
'ghost-accent': 'btn-ghost-accent',
|
||||
'tertiary': 'btn-tertiary',
|
||||
},
|
||||
size: {
|
||||
small: 'btn-small',
|
||||
medium: 'btn-medium',
|
||||
large: 'btn-large',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'secondary',
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonProps = {
|
||||
destructive?: boolean
|
||||
loading?: boolean
|
||||
styleCss?: CSSProperties
|
||||
spinnerClassName?: string
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
|
||||
|
||||
const Button = ({ className, variant, size, destructive, loading, styleCss, children, spinnerClassName, ref, ...props }: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
buttonVariants({ variant, size, className }),
|
||||
destructive && 'btn-destructive',
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export default Button
|
||||
export { Button, buttonVariants }
|
||||
57
dify/web/app/components/base/button/sync-button.stories.tsx
Normal file
57
dify/web/app/components/base/button/sync-button.stories.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import SyncButton from './sync-button'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/General/SyncButton',
|
||||
component: SyncButton,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Icon-only refresh button that surfaces a tooltip and is used for manual sync actions across the UI.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
className: {
|
||||
control: 'text',
|
||||
description: 'Additional classes appended to the clickable container.',
|
||||
},
|
||||
popupContent: {
|
||||
control: 'text',
|
||||
description: 'Tooltip text shown on hover.',
|
||||
},
|
||||
onClick: {
|
||||
control: false,
|
||||
description: 'Triggered when the sync button is pressed.',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
popupContent: 'Sync now',
|
||||
onClick: () => console.log('Sync button clicked'),
|
||||
},
|
||||
} satisfies Meta<typeof SyncButton>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
className: 'bg-white/80 shadow-sm backdrop-blur-sm',
|
||||
},
|
||||
}
|
||||
|
||||
export const InHeader: Story = {
|
||||
render: args => (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-3">
|
||||
<span className="text-xs text-text-tertiary">Logs</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SyncButton {...args} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
args: {
|
||||
popupContent: 'Refresh logs',
|
||||
},
|
||||
}
|
||||
27
dify/web/app/components/base/button/sync-button.tsx
Normal file
27
dify/web/app/components/base/button/sync-button.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { RiRefreshLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import TooltipPlus from '@/app/components/base/tooltip'
|
||||
|
||||
type Props = {
|
||||
className?: string,
|
||||
popupContent?: string,
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const SyncButton: FC<Props> = ({
|
||||
className,
|
||||
popupContent = '',
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<TooltipPlus popupContent={popupContent}>
|
||||
<div className={cn(className, 'cursor-pointer select-none rounded-md p-1 hover:bg-state-base-hover')} onClick={onClick}>
|
||||
<RiRefreshLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
)
|
||||
}
|
||||
export default React.memo(SyncButton)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": null
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "2"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "2"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": null
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "2"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
},
|
||||
{
|
||||
"id": "question-5",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": null
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-5"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": "question-1",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-1"
|
||||
},
|
||||
{
|
||||
"id": "question-2",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-2"
|
||||
},
|
||||
{
|
||||
"id": "question-3",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "00000000-0000-0000-0000-000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3"
|
||||
},
|
||||
{
|
||||
"id": "question-4",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "1"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-4"
|
||||
},
|
||||
{
|
||||
"id": "question-5",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": null
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-5"
|
||||
}
|
||||
]
|
||||
122
dify/web/app/components/base/chat/__tests__/partialMessages.json
Normal file
122
dify/web/app/components/base/chat/__tests__/partialMessages.json
Normal file
@@ -0,0 +1,122 @@
|
||||
[
|
||||
{
|
||||
"id": "question-ebb73fe2-15de-46dd-aab5-75416d8448eb",
|
||||
"content": "123",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
|
||||
},
|
||||
{
|
||||
"id": "ebb73fe2-15de-46dd-aab5-75416d8448eb",
|
||||
"content": "237.",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-ebb73fe2-15de-46dd-aab5-75416d8448eb"
|
||||
},
|
||||
{
|
||||
"id": "question-3553d508-3850-462e-8594-078539f940f9",
|
||||
"content": "123",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
|
||||
},
|
||||
{
|
||||
"id": "3553d508-3850-462e-8594-078539f940f9",
|
||||
"content": "My number is 256.",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3553d508-3850-462e-8594-078539f940f9"
|
||||
},
|
||||
{
|
||||
"id": "question-507f9df9-1f06-4a57-bb38-f00228c42c22",
|
||||
"content": "123",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "57c989f9-3fa4-4dec-9ee5-c3568dd27418"
|
||||
},
|
||||
{
|
||||
"id": "507f9df9-1f06-4a57-bb38-f00228c42c22",
|
||||
"content": "My number is 259.",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-507f9df9-1f06-4a57-bb38-f00228c42c22"
|
||||
},
|
||||
{
|
||||
"id": "question-9e51a13b-7780-4565-98dc-f2d8c3b1758f",
|
||||
"content": "1024",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "507f9df9-1f06-4a57-bb38-f00228c42c22"
|
||||
},
|
||||
{
|
||||
"id": "9e51a13b-7780-4565-98dc-f2d8c3b1758f",
|
||||
"content": "My number is 2048.",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-9e51a13b-7780-4565-98dc-f2d8c3b1758f"
|
||||
},
|
||||
{
|
||||
"id": "question-93bac05d-1470-4ac9-b090-fe21cd7c3d55",
|
||||
"content": "3306",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "9e51a13b-7780-4565-98dc-f2d8c3b1758f"
|
||||
},
|
||||
{
|
||||
"id": "93bac05d-1470-4ac9-b090-fe21cd7c3d55",
|
||||
"content": "My number is 4782.",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-93bac05d-1470-4ac9-b090-fe21cd7c3d55"
|
||||
},
|
||||
{
|
||||
"id": "question-a956de3d-ef95-4d90-84fe-f7a26ef28cd7",
|
||||
"content": "-5",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "93bac05d-1470-4ac9-b090-fe21cd7c3d55"
|
||||
},
|
||||
{
|
||||
"id": "a956de3d-ef95-4d90-84fe-f7a26ef28cd7",
|
||||
"content": "My number is 22.",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-a956de3d-ef95-4d90-84fe-f7a26ef28cd7"
|
||||
},
|
||||
{
|
||||
"id": "question-3cded945-855a-4a24-aab7-43c7dd54664c",
|
||||
"content": "3.11",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "a956de3d-ef95-4d90-84fe-f7a26ef28cd7"
|
||||
},
|
||||
{
|
||||
"id": "3cded945-855a-4a24-aab7-43c7dd54664c",
|
||||
"content": "My number is 7.89.",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-3cded945-855a-4a24-aab7-43c7dd54664c"
|
||||
},
|
||||
{
|
||||
"id": "question-46a49bb9-0881-459e-8c6a-24d20ae48d2f",
|
||||
"content": "78",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "3cded945-855a-4a24-aab7-43c7dd54664c"
|
||||
},
|
||||
{
|
||||
"id": "46a49bb9-0881-459e-8c6a-24d20ae48d2f",
|
||||
"content": "My number is 145.",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-46a49bb9-0881-459e-8c6a-24d20ae48d2f"
|
||||
},
|
||||
{
|
||||
"id": "question-5c56a2b3-f057-42a0-9b2c-52a35713cd8c",
|
||||
"content": "π",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "46a49bb9-0881-459e-8c6a-24d20ae48d2f"
|
||||
},
|
||||
{
|
||||
"id": "5c56a2b3-f057-42a0-9b2c-52a35713cd8c",
|
||||
"content": "My number is 2π (approximately 6.28).",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-5c56a2b3-f057-42a0-9b2c-52a35713cd8c"
|
||||
},
|
||||
{
|
||||
"id": "question-9eac3bcc-8d3b-4e56-a12b-44c34cebc719",
|
||||
"content": "e",
|
||||
"isAnswer": false,
|
||||
"parentMessageId": "5c56a2b3-f057-42a0-9b2c-52a35713cd8c"
|
||||
},
|
||||
{
|
||||
"id": "9eac3bcc-8d3b-4e56-a12b-44c34cebc719",
|
||||
"content": "My number is 3e (approximately 8.15).",
|
||||
"isAnswer": true,
|
||||
"parentMessageId": "question-9eac3bcc-8d3b-4e56-a12b-44c34cebc719"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,441 @@
|
||||
[
|
||||
{
|
||||
"id": "question-ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
|
||||
"content": "Let's play a game, I say a number , and you response me with another bigger, yet random-looking number. I'll start first, 38",
|
||||
"isAnswer": false,
|
||||
"message_files": []
|
||||
},
|
||||
{
|
||||
"id": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
|
||||
"content": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "f9d7ff7c-3a3b-4d9a-a289-657817f4caff",
|
||||
"chain_id": null,
|
||||
"message_id": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b",
|
||||
"position": 1,
|
||||
"thought": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105791,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:49 PM",
|
||||
"tokens": 49,
|
||||
"latency": "1.56"
|
||||
},
|
||||
"parentMessageId": "question-ff4c2b43-48a5-47ad-9dc5-08b34ddba61b"
|
||||
},
|
||||
{
|
||||
"id": "question-73bbad14-d915-499d-87bf-0df14d40779d",
|
||||
"content": "58",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "ff4c2b43-48a5-47ad-9dc5-08b34ddba61b"
|
||||
},
|
||||
{
|
||||
"id": "73bbad14-d915-499d-87bf-0df14d40779d",
|
||||
"content": "I choose 83. What's your next number?",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "f61a3fce-37ac-4f9d-9935-95f97e598dfe",
|
||||
"chain_id": null,
|
||||
"message_id": "73bbad14-d915-499d-87bf-0df14d40779d",
|
||||
"position": 1,
|
||||
"thought": "I choose 83. What's your next number?",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105795,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "58",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "I choose 83. What's your next number?",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "58"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:49 PM",
|
||||
"tokens": 68,
|
||||
"latency": "1.33"
|
||||
},
|
||||
"parentMessageId": "question-73bbad14-d915-499d-87bf-0df14d40779d"
|
||||
},
|
||||
{
|
||||
"id": "question-4c5d0841-1206-463e-95d8-71f812877658",
|
||||
"content": "99",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "73bbad14-d915-499d-87bf-0df14d40779d"
|
||||
},
|
||||
{
|
||||
"id": "4c5d0841-1206-463e-95d8-71f812877658",
|
||||
"content": "I'll go with 112. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "9730d587-9268-4683-9dd9-91a1cab9510b",
|
||||
"chain_id": null,
|
||||
"message_id": "4c5d0841-1206-463e-95d8-71f812877658",
|
||||
"position": 1,
|
||||
"thought": "I'll go with 112. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105799,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure, I'll play! My number is 57. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "58",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "I choose 83. What's your next number?",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "99",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "I'll go with 112. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "99"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:50 PM",
|
||||
"tokens": 86,
|
||||
"latency": "1.49"
|
||||
},
|
||||
"parentMessageId": "question-4c5d0841-1206-463e-95d8-71f812877658"
|
||||
},
|
||||
{
|
||||
"id": "question-cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
|
||||
"content": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"isAnswer": false,
|
||||
"message_files": []
|
||||
},
|
||||
{
|
||||
"id": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
|
||||
"content": "Sure! My number is 54. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "1019cd79-d141-4f9f-880a-fc1441cfd802",
|
||||
"chain_id": null,
|
||||
"message_id": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd",
|
||||
"position": 1,
|
||||
"thought": "Sure! My number is 54. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105809,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure! My number is 54. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:50 PM",
|
||||
"tokens": 46,
|
||||
"latency": "1.52"
|
||||
},
|
||||
"parentMessageId": "question-cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
|
||||
},
|
||||
{
|
||||
"id": "question-324bce32-c98c-435d-a66b-bac974ebb5ed",
|
||||
"content": "3306",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
|
||||
},
|
||||
{
|
||||
"id": "324bce32-c98c-435d-a66b-bac974ebb5ed",
|
||||
"content": "My number is 4729. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "0773bec7-b992-4a53-92b2-20ebaeae8798",
|
||||
"chain_id": null,
|
||||
"message_id": "324bce32-c98c-435d-a66b-bac974ebb5ed",
|
||||
"position": 1,
|
||||
"thought": "My number is 4729. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726105822,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure! My number is 54. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "3306",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "My number is 4729. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "3306"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 09:50 PM",
|
||||
"tokens": 66,
|
||||
"latency": "1.30"
|
||||
},
|
||||
"parentMessageId": "question-324bce32-c98c-435d-a66b-bac974ebb5ed"
|
||||
},
|
||||
{
|
||||
"id": "question-684b5396-4e91-4043-88e9-aabe48b21acc",
|
||||
"content": "3306",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "cd5affb0-7bc2-4a6f-be7e-25e74595c9dd"
|
||||
},
|
||||
{
|
||||
"id": "684b5396-4e91-4043-88e9-aabe48b21acc",
|
||||
"content": "My number is 4821. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "5ca650f3-982c-4399-8b95-9ea241c76707",
|
||||
"chain_id": null,
|
||||
"message_id": "684b5396-4e91-4043-88e9-aabe48b21acc",
|
||||
"position": 1,
|
||||
"thought": "My number is 4821. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726107812,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure! My number is 54. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "3306",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "My number is 4821. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "3306"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 10:23 PM",
|
||||
"tokens": 66,
|
||||
"latency": "1.48"
|
||||
},
|
||||
"parentMessageId": "question-684b5396-4e91-4043-88e9-aabe48b21acc"
|
||||
},
|
||||
{
|
||||
"id": "question-19904a7b-7494-4ed8-b72c-1d18668cea8c",
|
||||
"content": "1003",
|
||||
"isAnswer": false,
|
||||
"message_files": [],
|
||||
"parentMessageId": "684b5396-4e91-4043-88e9-aabe48b21acc"
|
||||
},
|
||||
{
|
||||
"id": "19904a7b-7494-4ed8-b72c-1d18668cea8c",
|
||||
"content": "My number is 1456. Your turn!",
|
||||
"agent_thoughts": [
|
||||
{
|
||||
"id": "095cacab-afad-4387-a41d-1662578b8b13",
|
||||
"chain_id": null,
|
||||
"message_id": "19904a7b-7494-4ed8-b72c-1d18668cea8c",
|
||||
"position": 1,
|
||||
"thought": "My number is 1456. Your turn!",
|
||||
"tool": "",
|
||||
"tool_labels": {},
|
||||
"tool_input": "",
|
||||
"created_at": 1726111024,
|
||||
"observation": "",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"feedbackDisabled": false,
|
||||
"isAnswer": true,
|
||||
"message_files": [],
|
||||
"log": [
|
||||
{
|
||||
"role": "user",
|
||||
"text": "Let's play a game, I say a number , and you response me with another bigger, yet randomly number. I'll start first, 38",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "Sure! My number is 54. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "3306",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "My number is 4821. Your turn!",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"text": "1003",
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"text": "My number is 1456. Your turn!",
|
||||
"files": []
|
||||
}
|
||||
],
|
||||
"workflow_run_id": null,
|
||||
"conversationId": "dd6c9cfd-2656-48ec-bd51-2139c1790d80",
|
||||
"input": {
|
||||
"inputs": {},
|
||||
"query": "1003"
|
||||
},
|
||||
"more": {
|
||||
"time": "09/11/2024 11:17 PM",
|
||||
"tokens": 86,
|
||||
"latency": "1.38"
|
||||
},
|
||||
"parentMessageId": "question-19904a7b-7494-4ed8-b72c-1d18668cea8c"
|
||||
}
|
||||
]
|
||||
271
dify/web/app/components/base/chat/__tests__/utils.spec.ts
Normal file
271
dify/web/app/components/base/chat/__tests__/utils.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { get } from 'lodash-es'
|
||||
import { buildChatItemTree, getThreadMessages } from '../utils'
|
||||
import type { ChatItemInTree } from '../types'
|
||||
import branchedTestMessages from './branchedTestMessages.json'
|
||||
import legacyTestMessages from './legacyTestMessages.json'
|
||||
import mixedTestMessages from './mixedTestMessages.json'
|
||||
import multiRootNodesMessages from './multiRootNodesMessages.json'
|
||||
import multiRootNodesWithLegacyTestMessages from './multiRootNodesWithLegacyTestMessages.json'
|
||||
import realWorldMessages from './realWorldMessages.json'
|
||||
import partialMessages from './partialMessages.json'
|
||||
|
||||
function visitNode(tree: ChatItemInTree | ChatItemInTree[], path: string): ChatItemInTree {
|
||||
return get(tree, path)
|
||||
}
|
||||
|
||||
describe('build chat item tree and get thread messages', () => {
|
||||
const tree1 = buildChatItemTree(branchedTestMessages as ChatItemInTree[])
|
||||
|
||||
it('should build chat item tree1', () => {
|
||||
const a1 = visitNode(tree1, '0.children.0')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(a1.children).toHaveLength(2)
|
||||
|
||||
const a2 = visitNode(a1, 'children.0.children.0')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
|
||||
const a3 = visitNode(a2, 'children.0.children.0')
|
||||
expect(a3.id).toBe('3')
|
||||
|
||||
const a4 = visitNode(a1, 'children.1.children.0')
|
||||
expect(a4.id).toBe('4')
|
||||
expect(a4.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree1, using the last message as the target', () => {
|
||||
const threadChatItems1_1 = getThreadMessages(tree1)
|
||||
expect(threadChatItems1_1).toHaveLength(4)
|
||||
|
||||
const q1 = visitNode(threadChatItems1_1, '0')
|
||||
const a1 = visitNode(threadChatItems1_1, '1')
|
||||
const q4 = visitNode(threadChatItems1_1, '2')
|
||||
const a4 = visitNode(threadChatItems1_1, '3')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q4.id).toBe('question-4')
|
||||
expect(a4.id).toBe('4')
|
||||
|
||||
expect(a4.siblingCount).toBe(2)
|
||||
expect(a4.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree1, using the message with id 3 as the target', () => {
|
||||
const threadChatItems1_2 = getThreadMessages(tree1, '3')
|
||||
expect(threadChatItems1_2).toHaveLength(6)
|
||||
|
||||
const q1 = visitNode(threadChatItems1_2, '0')
|
||||
const a1 = visitNode(threadChatItems1_2, '1')
|
||||
const q2 = visitNode(threadChatItems1_2, '2')
|
||||
const a2 = visitNode(threadChatItems1_2, '3')
|
||||
const q3 = visitNode(threadChatItems1_2, '4')
|
||||
const a3 = visitNode(threadChatItems1_2, '5')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q2.id).toBe('question-2')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(q3.id).toBe('question-3')
|
||||
expect(a3.id).toBe('3')
|
||||
|
||||
expect(a2.siblingCount).toBe(2)
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
})
|
||||
|
||||
const tree2 = buildChatItemTree(legacyTestMessages as ChatItemInTree[])
|
||||
it('should work with legacy chat items', () => {
|
||||
expect(tree2).toHaveLength(1)
|
||||
const q1 = visitNode(tree2, '0')
|
||||
const a1 = visitNode(q1, 'children.0')
|
||||
const q2 = visitNode(a1, 'children.0')
|
||||
const a2 = visitNode(q2, 'children.0')
|
||||
const q3 = visitNode(a2, 'children.0')
|
||||
const a3 = visitNode(q3, 'children.0')
|
||||
const q4 = visitNode(a3, 'children.0')
|
||||
const a4 = visitNode(q4, 'children.0')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q2.id).toBe('question-2')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(q3.id).toBe('question-3')
|
||||
expect(a3.id).toBe('3')
|
||||
expect(q4.id).toBe('question-4')
|
||||
expect(a4.id).toBe('4')
|
||||
})
|
||||
|
||||
it('should get thread messages from tree2, using the last message as the target', () => {
|
||||
const threadMessages2 = getThreadMessages(tree2)
|
||||
expect(threadMessages2).toHaveLength(8)
|
||||
|
||||
const q1 = visitNode(threadMessages2, '0')
|
||||
const a1 = visitNode(threadMessages2, '1')
|
||||
const q2 = visitNode(threadMessages2, '2')
|
||||
const a2 = visitNode(threadMessages2, '3')
|
||||
const q3 = visitNode(threadMessages2, '4')
|
||||
const a3 = visitNode(threadMessages2, '5')
|
||||
const q4 = visitNode(threadMessages2, '6')
|
||||
const a4 = visitNode(threadMessages2, '7')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q2.id).toBe('question-2')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(q3.id).toBe('question-3')
|
||||
expect(a3.id).toBe('3')
|
||||
expect(q4.id).toBe('question-4')
|
||||
expect(a4.id).toBe('4')
|
||||
|
||||
expect(a1.siblingCount).toBe(1)
|
||||
expect(a1.siblingIndex).toBe(0)
|
||||
expect(a2.siblingCount).toBe(1)
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
expect(a3.siblingCount).toBe(1)
|
||||
expect(a3.siblingIndex).toBe(0)
|
||||
expect(a4.siblingCount).toBe(1)
|
||||
expect(a4.siblingIndex).toBe(0)
|
||||
})
|
||||
|
||||
const tree3 = buildChatItemTree(mixedTestMessages as ChatItemInTree[])
|
||||
it('should build mixed chat items tree', () => {
|
||||
expect(tree3).toHaveLength(1)
|
||||
|
||||
const a1 = visitNode(tree3, '0.children.0')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(a1.children).toHaveLength(2)
|
||||
|
||||
const a2 = visitNode(a1, 'children.0.children.0')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
|
||||
const a3 = visitNode(a2, 'children.0.children.0')
|
||||
expect(a3.id).toBe('3')
|
||||
|
||||
const a4 = visitNode(a1, 'children.1.children.0')
|
||||
expect(a4.id).toBe('4')
|
||||
expect(a4.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree3, using the last message as the target', () => {
|
||||
const threadMessages3_1 = getThreadMessages(tree3)
|
||||
expect(threadMessages3_1).toHaveLength(4)
|
||||
|
||||
const q1 = visitNode(threadMessages3_1, '0')
|
||||
const a1 = visitNode(threadMessages3_1, '1')
|
||||
const q4 = visitNode(threadMessages3_1, '2')
|
||||
const a4 = visitNode(threadMessages3_1, '3')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q4.id).toBe('question-4')
|
||||
expect(a4.id).toBe('4')
|
||||
|
||||
expect(a4.siblingCount).toBe(2)
|
||||
expect(a4.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree3, using the message with id 3 as the target', () => {
|
||||
const threadMessages3_2 = getThreadMessages(tree3, '3')
|
||||
expect(threadMessages3_2).toHaveLength(6)
|
||||
|
||||
const q1 = visitNode(threadMessages3_2, '0')
|
||||
const a1 = visitNode(threadMessages3_2, '1')
|
||||
const q2 = visitNode(threadMessages3_2, '2')
|
||||
const a2 = visitNode(threadMessages3_2, '3')
|
||||
const q3 = visitNode(threadMessages3_2, '4')
|
||||
const a3 = visitNode(threadMessages3_2, '5')
|
||||
|
||||
expect(q1.id).toBe('question-1')
|
||||
expect(a1.id).toBe('1')
|
||||
expect(q2.id).toBe('question-2')
|
||||
expect(a2.id).toBe('2')
|
||||
expect(q3.id).toBe('question-3')
|
||||
expect(a3.id).toBe('3')
|
||||
|
||||
expect(a2.siblingCount).toBe(2)
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
})
|
||||
|
||||
const tree4 = buildChatItemTree(multiRootNodesMessages as ChatItemInTree[])
|
||||
it('should build multi root nodes chat items tree', () => {
|
||||
expect(tree4).toHaveLength(2)
|
||||
|
||||
const a5 = visitNode(tree4, '1.children.0')
|
||||
expect(a5.id).toBe('5')
|
||||
expect(a5.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree4, using the last message as the target', () => {
|
||||
const threadMessages4 = getThreadMessages(tree4)
|
||||
expect(threadMessages4).toHaveLength(2)
|
||||
|
||||
const a1 = visitNode(threadMessages4, '0.children.0')
|
||||
expect(a1.id).toBe('5')
|
||||
})
|
||||
|
||||
it('should get thread messages from tree4, using the message with id 2 as the target', () => {
|
||||
const threadMessages4_1 = getThreadMessages(tree4, '2')
|
||||
expect(threadMessages4_1).toHaveLength(6)
|
||||
const a1 = visitNode(threadMessages4_1, '1')
|
||||
expect(a1.id).toBe('1')
|
||||
const a2 = visitNode(threadMessages4_1, '3')
|
||||
expect(a2.id).toBe('2')
|
||||
const a3 = visitNode(threadMessages4_1, '5')
|
||||
expect(a3.id).toBe('3')
|
||||
})
|
||||
|
||||
const tree5 = buildChatItemTree(multiRootNodesWithLegacyTestMessages as ChatItemInTree[])
|
||||
it('should work with multi root nodes chat items with legacy chat items', () => {
|
||||
expect(tree5).toHaveLength(2)
|
||||
|
||||
const q5 = visitNode(tree5, '1')
|
||||
expect(q5.id).toBe('question-5')
|
||||
expect(q5.parentMessageId).toBe(null)
|
||||
|
||||
const a5 = visitNode(q5, 'children.0')
|
||||
expect(a5.id).toBe('5')
|
||||
expect(a5.children).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should get thread messages from tree5, using the last message as the target', () => {
|
||||
const threadMessages5 = getThreadMessages(tree5)
|
||||
expect(threadMessages5).toHaveLength(2)
|
||||
|
||||
const q5 = visitNode(threadMessages5, '0')
|
||||
const a5 = visitNode(threadMessages5, '1')
|
||||
|
||||
expect(q5.id).toBe('question-5')
|
||||
expect(a5.id).toBe('5')
|
||||
|
||||
expect(a5.siblingCount).toBe(2)
|
||||
expect(a5.siblingIndex).toBe(1)
|
||||
})
|
||||
|
||||
const tree6 = buildChatItemTree(realWorldMessages as ChatItemInTree[])
|
||||
it('should work with real world messages', () => {
|
||||
expect(tree6).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it ('should get thread messages from tree6, using the last message as target', () => {
|
||||
const threadMessages6_1 = getThreadMessages(tree6)
|
||||
expect(threadMessages6_1).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it ('should get thread messages from tree6, using specified message as target', () => {
|
||||
const threadMessages6_2 = getThreadMessages(tree6, 'ff4c2b43-48a5-47ad-9dc5-08b34ddba61b')
|
||||
expect(threadMessages6_2).toMatchSnapshot()
|
||||
})
|
||||
|
||||
const partialMessages1 = (realWorldMessages as ChatItemInTree[]).slice(-10)
|
||||
const tree7 = buildChatItemTree(partialMessages1)
|
||||
it('should work with partial messages 1', () => {
|
||||
expect(tree7).toMatchSnapshot()
|
||||
})
|
||||
|
||||
const partialMessages2 = partialMessages as ChatItemInTree[]
|
||||
const tree8 = buildChatItemTree(partialMessages2)
|
||||
it('should work with partial messages 2', () => {
|
||||
expect(tree8).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Chat from '../chat'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import { useChat } from '../chat/hooks'
|
||||
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
|
||||
import { useChatWithHistoryContext } from './context'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
|
||||
import {
|
||||
fetchSuggestedQuestions,
|
||||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
} from '@/service/share'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AnswerIcon from '@/app/components/base/answer-icon'
|
||||
import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested-questions'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { FileEntity } from '../../file-uploader/types'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
import Avatar from '../../avatar'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const {
|
||||
appParams,
|
||||
appPrevChatTree,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
currentConversationInputs,
|
||||
inputsForms,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationCompleted,
|
||||
isMobile,
|
||||
isInstalledApp,
|
||||
appId,
|
||||
appMeta,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
appData,
|
||||
themeBuilder,
|
||||
sidebarCollapseState,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
setIsResponding,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useChatWithHistoryContext()
|
||||
|
||||
const appConfig = useMemo(() => {
|
||||
const config = appParams || {}
|
||||
|
||||
return {
|
||||
...config,
|
||||
file_upload: {
|
||||
...(config as any).file_upload,
|
||||
fileUploadConfig: (config as any).system_parameters,
|
||||
},
|
||||
supportFeedback: true,
|
||||
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
|
||||
} as ChatConfig
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSend,
|
||||
handleStop,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
appConfig,
|
||||
{
|
||||
inputs: (currentConversationId ? currentConversationInputs : newConversationInputs) as any,
|
||||
inputsForm: inputsForms,
|
||||
},
|
||||
appPrevChatTree,
|
||||
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
if (allInputsHidden)
|
||||
return false
|
||||
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
|
||||
if (requiredVars.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!inputsFormValue?.[variable])
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputsFormValue?.[variable]) {
|
||||
const files = inputsFormValue[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
})
|
||||
}
|
||||
if (hasEmptyInput)
|
||||
return true
|
||||
|
||||
if (fileIsUploading)
|
||||
return true
|
||||
return false
|
||||
}, [inputsFormValue, inputsForms, allInputsHidden])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatInstanceRef.current)
|
||||
currentChatInstanceRef.current.handleStop = handleStop
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
files,
|
||||
inputs: formatBooleanInputs(inputsForms, currentConversationId ? currentConversationInputs : newConversationInputs),
|
||||
conversation_id: currentConversationId,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
|
||||
handleSend(
|
||||
getUrl('chat-messages', isInstalledApp, appId || ''),
|
||||
data,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: !isInstalledApp,
|
||||
},
|
||||
)
|
||||
}, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
|
||||
doSend(editedQuestion ? editedQuestion.message : question.content,
|
||||
editedQuestion ? editedQuestion.files : question.message_files,
|
||||
true,
|
||||
isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null,
|
||||
)
|
||||
}, [chatList, doSend])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId || chatList.length > 1)
|
||||
return chatList
|
||||
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
|
||||
return chatList.filter(item => !item.isOpeningStatement)
|
||||
}, [chatList])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
return null
|
||||
if (isMobile) {
|
||||
if (!currentConversationId)
|
||||
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
return null
|
||||
}
|
||||
else {
|
||||
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
}
|
||||
},
|
||||
[
|
||||
inputsForms.length,
|
||||
isMobile,
|
||||
currentConversationId,
|
||||
collapsed, allInputsHidden,
|
||||
])
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
|
||||
if (respondingState)
|
||||
return null
|
||||
if (currentConversationId)
|
||||
return null
|
||||
if (!welcomeMessage)
|
||||
return null
|
||||
if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
|
||||
return null
|
||||
if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
|
||||
return (
|
||||
<div className='flex min-h-[50vh] items-center justify-center px-4 py-12'>
|
||||
<div className='flex max-w-[720px] grow gap-4'>
|
||||
<AppIcon
|
||||
size='xl'
|
||||
iconType={appData?.site.icon_type}
|
||||
icon={appData?.site.icon}
|
||||
background={appData?.site.icon_background}
|
||||
imageUrl={appData?.site.icon_url}
|
||||
/>
|
||||
<div className='w-0 grow'>
|
||||
<div className='body-lg-regular grow rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary'>
|
||||
<Markdown content={welcomeMessage.content} />
|
||||
<SuggestedQuestions item={welcomeMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex h-[50vh] flex-col items-center justify-center gap-3 py-12')}>
|
||||
<AppIcon
|
||||
size='xl'
|
||||
iconType={appData?.site.icon_type}
|
||||
icon={appData?.site.icon}
|
||||
background={appData?.site.icon_background}
|
||||
imageUrl={appData?.site.icon_url}
|
||||
/>
|
||||
<div className='max-w-[768px] px-4'>
|
||||
<Markdown className='!body-2xl-regular !text-text-tertiary' content={welcomeMessage.content} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
appData?.site.icon,
|
||||
appData?.site.icon_background,
|
||||
appData?.site.icon_type,
|
||||
appData?.site.icon_url,
|
||||
chatList, collapsed,
|
||||
currentConversationId,
|
||||
inputsForms.length,
|
||||
respondingState,
|
||||
allInputsHidden,
|
||||
])
|
||||
|
||||
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
|
||||
? <AnswerIcon
|
||||
iconType={appData.site.icon_type}
|
||||
icon={appData.site.icon}
|
||||
background={appData.site.icon_background}
|
||||
imageUrl={appData.site.icon_url}
|
||||
/>
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='h-full overflow-hidden bg-chatbot-bg'
|
||||
>
|
||||
<Chat
|
||||
appData={appData ?? undefined}
|
||||
config={appConfig}
|
||||
chatList={messageList}
|
||||
isResponding={respondingState}
|
||||
chatContainerInnerClassName={`mx-auto pt-6 w-full max-w-[768px] ${isMobile && 'px-4'}`}
|
||||
chatFooterClassName='pb-4'
|
||||
chatFooterInnerClassName={`mx-auto w-full max-w-[768px] ${isMobile ? 'px-2' : 'px-4'}`}
|
||||
onSend={doSend}
|
||||
inputs={currentConversationId ? currentConversationInputs as any : newConversationInputs}
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
chatNode={
|
||||
<>
|
||||
{chatNode}
|
||||
{welcome}
|
||||
</>
|
||||
}
|
||||
allToolIcons={appMeta?.tool_icons || {}}
|
||||
onFeedback={handleFeedback}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
inputDisabled={inputDisabled}
|
||||
isMobile={isMobile}
|
||||
sidebarCollapseState={sidebarCollapseState}
|
||||
questionIcon={
|
||||
initUserVariables?.avatar_url
|
||||
? <Avatar
|
||||
avatar={initUserVariables.avatar_url}
|
||||
name={initUserVariables.name || 'user'}
|
||||
size={40}
|
||||
/> : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatWrapper
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import type { RefObject } from 'react'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
import type {
|
||||
Callback,
|
||||
ChatConfig,
|
||||
ChatItemInTree,
|
||||
Feedback,
|
||||
} from '../types'
|
||||
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import type {
|
||||
AppConversationData,
|
||||
AppData,
|
||||
AppMeta,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type ChatWithHistoryContextValue = {
|
||||
appMeta?: AppMeta | null
|
||||
appData?: AppData | null
|
||||
appParams?: ChatConfig
|
||||
appChatListDataLoading?: boolean
|
||||
currentConversationId: string
|
||||
currentConversationItem?: ConversationItem
|
||||
appPrevChatTree: ChatItemInTree[]
|
||||
pinnedConversationList: AppConversationData['data']
|
||||
conversationList: AppConversationData['data']
|
||||
newConversationInputs: Record<string, any>
|
||||
newConversationInputsRef: RefObject<Record<string, any>>
|
||||
handleNewConversationInputsChange: (v: Record<string, any>) => void
|
||||
inputsForms: any[]
|
||||
handleNewConversation: () => void
|
||||
handleStartChat: (callback?: any) => void
|
||||
handleChangeConversation: (conversationId: string) => void
|
||||
handlePinConversation: (conversationId: string) => void
|
||||
handleUnpinConversation: (conversationId: string) => void
|
||||
handleDeleteConversation: (conversationId: string, callback: Callback) => void
|
||||
conversationRenaming: boolean
|
||||
handleRenameConversation: (conversationId: string, newName: string, callback: Callback) => void
|
||||
handleNewConversationCompleted: (newConversationId: string) => void
|
||||
chatShouldReloadKey: string
|
||||
isMobile: boolean
|
||||
isInstalledApp: boolean
|
||||
appId?: string
|
||||
handleFeedback: (messageId: string, feedback: Feedback) => void
|
||||
currentChatInstanceRef: RefObject<{ handleStop: () => void }>
|
||||
themeBuilder?: ThemeBuilder
|
||||
sidebarCollapseState?: boolean
|
||||
handleSidebarCollapse: (state: boolean) => void
|
||||
clearChatList?: boolean
|
||||
setClearChatList: (state: boolean) => void
|
||||
isResponding?: boolean
|
||||
setIsResponding: (state: boolean) => void,
|
||||
currentConversationInputs: Record<string, any> | null,
|
||||
setCurrentConversationInputs: (v: Record<string, any>) => void,
|
||||
allInputsHidden: boolean,
|
||||
initUserVariables?: {
|
||||
name?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
|
||||
currentConversationId: '',
|
||||
appPrevChatTree: [],
|
||||
pinnedConversationList: [],
|
||||
conversationList: [],
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
handleNewConversationInputsChange: noop,
|
||||
inputsForms: [],
|
||||
handleNewConversation: noop,
|
||||
handleStartChat: noop,
|
||||
handleChangeConversation: noop,
|
||||
handlePinConversation: noop,
|
||||
handleUnpinConversation: noop,
|
||||
handleDeleteConversation: noop,
|
||||
conversationRenaming: false,
|
||||
handleRenameConversation: noop,
|
||||
handleNewConversationCompleted: noop,
|
||||
chatShouldReloadKey: '',
|
||||
isMobile: false,
|
||||
isInstalledApp: false,
|
||||
handleFeedback: noop,
|
||||
currentChatInstanceRef: { current: { handleStop: noop } },
|
||||
sidebarCollapseState: false,
|
||||
handleSidebarCollapse: noop,
|
||||
clearChatList: false,
|
||||
setClearChatList: noop,
|
||||
isResponding: false,
|
||||
setIsResponding: noop,
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: noop,
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
})
|
||||
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiMenuLine,
|
||||
} from '@remixicon/react'
|
||||
import { useChatWithHistoryContext } from './context'
|
||||
import Operation from './header/operation'
|
||||
import Sidebar from './sidebar'
|
||||
import MobileOperationDropdown from './header/mobile-operation-dropdown'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
|
||||
const HeaderInMobile = () => {
|
||||
const {
|
||||
appData,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
pinnedConversationList,
|
||||
handleNewConversation,
|
||||
handlePinConversation,
|
||||
handleUnpinConversation,
|
||||
handleDeleteConversation,
|
||||
handleRenameConversation,
|
||||
conversationRenaming,
|
||||
inputsForms,
|
||||
} = useChatWithHistoryContext()
|
||||
const { t } = useTranslation()
|
||||
const isPin = pinnedConversationList.some(item => item.id === currentConversationId)
|
||||
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
|
||||
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
|
||||
const handleOperate = useCallback((type: string) => {
|
||||
if (type === 'pin')
|
||||
handlePinConversation(currentConversationId)
|
||||
|
||||
if (type === 'unpin')
|
||||
handleUnpinConversation(currentConversationId)
|
||||
|
||||
if (type === 'delete')
|
||||
setShowConfirm(currentConversationItem as any)
|
||||
|
||||
if (type === 'rename')
|
||||
setShowRename(currentConversationItem as any)
|
||||
}, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation])
|
||||
const handleCancelConfirm = useCallback(() => {
|
||||
setShowConfirm(null)
|
||||
}, [])
|
||||
const handleDelete = useCallback(() => {
|
||||
if (showConfirm)
|
||||
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
|
||||
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setShowRename(null)
|
||||
}, [])
|
||||
const handleRename = useCallback((newName: string) => {
|
||||
if (showRename)
|
||||
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
|
||||
}, [showRename, handleRenameConversation, handleCancelRename])
|
||||
const [showSidebar, setShowSidebar] = useState(false)
|
||||
const [showChatSettings, setShowChatSettings] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex shrink-0 items-center gap-1 bg-mask-top2bottom-gray-50-to-transparent px-2 py-3'>
|
||||
<ActionButton size='l' className='shrink-0' onClick={() => setShowSidebar(true)}>
|
||||
<RiMenuLine className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
<div className='flex grow items-center justify-center'>
|
||||
{!currentConversationId && (
|
||||
<>
|
||||
<AppIcon
|
||||
className='mr-2'
|
||||
size='tiny'
|
||||
icon={appData?.site.icon}
|
||||
iconType={appData?.site.icon_type}
|
||||
imageUrl={appData?.site.icon_url}
|
||||
background={appData?.site.icon_background}
|
||||
/>
|
||||
<div className='system-md-semibold truncate text-text-secondary'>
|
||||
{appData?.site.title}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{currentConversationId && (
|
||||
<Operation
|
||||
title={currentConversationItem?.name || ''}
|
||||
isPinned={!!isPin}
|
||||
togglePin={() => handleOperate(isPin ? 'unpin' : 'pin')}
|
||||
isShowDelete
|
||||
isShowRenameConversation
|
||||
onRenameConversation={() => handleOperate('rename')}
|
||||
onDelete={() => handleOperate('delete')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<MobileOperationDropdown
|
||||
handleResetChat={handleNewConversation}
|
||||
handleViewChatSettings={() => setShowChatSettings(true)}
|
||||
hideViewChatSettings={inputsForms.length < 1}
|
||||
/>
|
||||
</div>
|
||||
{showSidebar && (
|
||||
<div className='fixed inset-0 z-50 flex bg-background-overlay p-1'
|
||||
onClick={() => setShowSidebar(false)}
|
||||
>
|
||||
<div className='flex h-full w-[calc(100vw_-_40px)] rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-sm' onClick={e => e.stopPropagation()}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showChatSettings && (
|
||||
<div className='fixed inset-0 z-50 flex justify-end bg-background-overlay p-1'
|
||||
onClick={() => setShowChatSettings(false)}
|
||||
>
|
||||
<div className='flex h-full w-[calc(100vw_-_40px)] flex-col rounded-xl bg-components-panel-bg shadow-lg backdrop-blur-sm' onClick={e => e.stopPropagation()}>
|
||||
<div className='flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-4 py-3'>
|
||||
<Message3Fill className='h-6 w-6 shrink-0' />
|
||||
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
|
||||
</div>
|
||||
<div className='p-4'>
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!showConfirm && (
|
||||
<Confirm
|
||||
title={t('share.chat.deleteConversation.title')}
|
||||
content={t('share.chat.deleteConversation.content') || ''}
|
||||
isShow
|
||||
onCancel={handleCancelConfirm}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderInMobile
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
RiEditBoxLine,
|
||||
RiLayoutRight2Line,
|
||||
RiResetLeftLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useChatWithHistoryContext,
|
||||
} from '../context'
|
||||
import Operation from './operation'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const Header = () => {
|
||||
const {
|
||||
appData,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
inputsForms,
|
||||
pinnedConversationList,
|
||||
handlePinConversation,
|
||||
handleUnpinConversation,
|
||||
conversationRenaming,
|
||||
handleRenameConversation,
|
||||
handleDeleteConversation,
|
||||
handleNewConversation,
|
||||
sidebarCollapseState,
|
||||
handleSidebarCollapse,
|
||||
isResponding,
|
||||
} = useChatWithHistoryContext()
|
||||
const { t } = useTranslation()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
|
||||
const isPin = pinnedConversationList.some(item => item.id === currentConversationId)
|
||||
|
||||
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
|
||||
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
|
||||
const handleOperate = useCallback((type: string) => {
|
||||
if (type === 'pin')
|
||||
handlePinConversation(currentConversationId)
|
||||
|
||||
if (type === 'unpin')
|
||||
handleUnpinConversation(currentConversationId)
|
||||
|
||||
if (type === 'delete')
|
||||
setShowConfirm(currentConversationItem as any)
|
||||
|
||||
if (type === 'rename')
|
||||
setShowRename(currentConversationItem as any)
|
||||
}, [currentConversationId, currentConversationItem, handlePinConversation, handleUnpinConversation])
|
||||
const handleCancelConfirm = useCallback(() => {
|
||||
setShowConfirm(null)
|
||||
}, [])
|
||||
const handleDelete = useCallback(() => {
|
||||
if (showConfirm)
|
||||
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
|
||||
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setShowRename(null)
|
||||
}, [])
|
||||
const handleRename = useCallback((newName: string) => {
|
||||
if (showRename)
|
||||
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
|
||||
}, [showRename, handleRenameConversation, handleCancelRename])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-14 shrink-0 items-center justify-between p-3'>
|
||||
<div className={cn('flex items-center gap-1 transition-all duration-200 ease-in-out', !isSidebarCollapsed && 'user-select-none opacity-0')}>
|
||||
<ActionButton className={cn(!isSidebarCollapsed && 'cursor-default')} size='l' onClick={() => handleSidebarCollapse(false)}>
|
||||
<RiLayoutRight2Line className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
<div className='mr-1 shrink-0'>
|
||||
<AppIcon
|
||||
size='large'
|
||||
iconType={appData?.site.icon_type}
|
||||
icon={appData?.site.icon}
|
||||
background={appData?.site.icon_background}
|
||||
imageUrl={appData?.site.icon_url}
|
||||
/>
|
||||
</div>
|
||||
{!currentConversationId && (
|
||||
<div className={cn('system-md-semibold grow truncate text-text-secondary')}>{appData?.site.title}</div>
|
||||
)}
|
||||
{currentConversationId && currentConversationItem && isSidebarCollapsed && (
|
||||
<>
|
||||
<div className='p-1 text-divider-deep'>/</div>
|
||||
<Operation
|
||||
title={currentConversationItem?.name || ''}
|
||||
isPinned={!!isPin}
|
||||
togglePin={() => handleOperate(isPin ? 'unpin' : 'pin')}
|
||||
isShowDelete
|
||||
isShowRenameConversation
|
||||
onRenameConversation={() => handleOperate('rename')}
|
||||
onDelete={() => handleOperate('delete')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className='flex items-center px-1'>
|
||||
<div className='h-[14px] w-px bg-divider-regular'></div>
|
||||
</div>
|
||||
{isSidebarCollapsed && (
|
||||
<Tooltip
|
||||
disabled={!!currentConversationId}
|
||||
popupContent={t('share.chat.newChatTip')}
|
||||
>
|
||||
<div>
|
||||
<ActionButton
|
||||
size='l'
|
||||
state={(!currentConversationId || isResponding) ? ActionButtonState.Disabled : ActionButtonState.Default}
|
||||
disabled={!currentConversationId || isResponding}
|
||||
onClick={handleNewConversation}
|
||||
>
|
||||
<RiEditBoxLine className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{currentConversationId && (
|
||||
<Tooltip
|
||||
popupContent={t('share.chat.resetChat')}
|
||||
>
|
||||
<ActionButton size='l' onClick={handleNewConversation}>
|
||||
<RiResetLeftLine className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{currentConversationId && inputsForms.length > 0 && (
|
||||
<ViewFormDropdown />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!!showConfirm && (
|
||||
<Confirm
|
||||
title={t('share.chat.deleteConversation.title')}
|
||||
content={t('share.chat.deleteConversation.content') || ''}
|
||||
isShow
|
||||
onCancel={handleCancelConfirm}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiMoreFill,
|
||||
} from '@remixicon/react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
|
||||
type Props = {
|
||||
handleResetChat: () => void
|
||||
handleViewChatSettings: () => void
|
||||
hideViewChatSettings?: boolean
|
||||
}
|
||||
|
||||
const MobileOperationDropdown = ({
|
||||
handleResetChat,
|
||||
handleViewChatSettings,
|
||||
hideViewChatSettings = false,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<ActionButton size='l' state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiMoreFill className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-40">
|
||||
<div
|
||||
className={'min-w-[160px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'}
|
||||
>
|
||||
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleResetChat}>
|
||||
<span className='grow'>{t('share.chat.resetChat')}</span>
|
||||
</div>
|
||||
{!hideViewChatSettings && (
|
||||
<div className='system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover' onClick={handleViewChatSettings}>
|
||||
<span className='grow'>{t('share.chat.viewChatSettings')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileOperationDropdown
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import type { Placement } from '@floating-ui/react'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
isPinned: boolean
|
||||
isShowRenameConversation?: boolean
|
||||
onRenameConversation?: () => void
|
||||
isShowDelete: boolean
|
||||
togglePin: () => void
|
||||
onDelete: () => void
|
||||
placement?: Placement
|
||||
}
|
||||
|
||||
const Operation: FC<Props> = ({
|
||||
title,
|
||||
isPinned,
|
||||
togglePin,
|
||||
isShowRenameConversation,
|
||||
onRenameConversation,
|
||||
isShowDelete,
|
||||
onDelete,
|
||||
placement = 'bottom-start',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={placement}
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<div className={cn('flex cursor-pointer items-center rounded-lg p-1.5 pl-2 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
<div className='system-md-semibold'>{title}</div>
|
||||
<RiArrowDownSLine className='h-4 w-4 ' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div
|
||||
className={'min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'}
|
||||
>
|
||||
<div className={cn('system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
|
||||
<span className='grow'>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
|
||||
</div>
|
||||
{isShowRenameConversation && (
|
||||
<div className={cn('system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
|
||||
<span className='grow'>{t('explore.sidebar.action.rename')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<div className={cn('system-md-regular group flex cursor-pointer items-center space-x-1 rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete} >
|
||||
<span className='grow'>{t('explore.sidebar.action.delete')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(Operation)
|
||||
576
dify/web/app/components/base/chat/chat-with-history/hooks.tsx
Normal file
576
dify/web/app/components/base/chat/chat-with-history/hooks.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import type {
|
||||
Callback,
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
Feedback,
|
||||
} from '../types'
|
||||
import { CONVERSATION_ID_INFO } from '../constants'
|
||||
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams, getRawUserVariablesFromUrlParams } from '../utils'
|
||||
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import {
|
||||
delConversation,
|
||||
fetchChatList,
|
||||
fetchConversations,
|
||||
generationConversationName,
|
||||
pinConversation,
|
||||
renameConversation,
|
||||
unpinConversation,
|
||||
updateFeedback,
|
||||
} from '@/service/share'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import type {
|
||||
AppData,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { changeLanguage } from '@/i18n-config/i18next-config'
|
||||
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
|
||||
function getFormattedChatList(messages: any[]) {
|
||||
const newChatList: ChatItem[] = []
|
||||
messages.forEach((item) => {
|
||||
const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || []
|
||||
newChatList.push({
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
|
||||
parentMessageId: item.parent_message_id || undefined,
|
||||
})
|
||||
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files),
|
||||
feedback: item.feedback,
|
||||
isAnswer: true,
|
||||
citation: item.retriever_resources,
|
||||
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
|
||||
parentMessageId: `question-${item.id}`,
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
}
|
||||
|
||||
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
|
||||
const appInfo = useWebAppStore(s => s.appInfo)
|
||||
const appParams = useWebAppStore(s => s.appParams)
|
||||
const appMeta = useWebAppStore(s => s.appMeta)
|
||||
|
||||
useAppFavicon({
|
||||
enable: !installedAppInfo,
|
||||
icon_type: appInfo?.site.icon_type,
|
||||
icon: appInfo?.site.icon,
|
||||
icon_background: appInfo?.site.icon_background,
|
||||
icon_url: appInfo?.site.icon_url,
|
||||
})
|
||||
|
||||
const appData = useMemo(() => {
|
||||
if (isInstalledApp) {
|
||||
const { id, app } = installedAppInfo!
|
||||
return {
|
||||
app_id: id,
|
||||
site: {
|
||||
title: app.name,
|
||||
icon_type: app.icon_type,
|
||||
icon: app.icon,
|
||||
icon_background: app.icon_background,
|
||||
icon_url: app.icon_url,
|
||||
prompt_public: false,
|
||||
copyright: '',
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: app.use_icon_as_answer_icon,
|
||||
},
|
||||
plan: 'basic',
|
||||
custom_config: null,
|
||||
} as AppData
|
||||
}
|
||||
|
||||
return appInfo
|
||||
}, [isInstalledApp, installedAppInfo, appInfo])
|
||||
const appId = useMemo(() => appData?.app_id, [appData])
|
||||
|
||||
const [userId, setUserId] = useState<string>()
|
||||
useEffect(() => {
|
||||
getProcessedSystemVariablesFromUrlParams().then(({ user_id }) => {
|
||||
setUserId(user_id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const setLocaleFromProps = async () => {
|
||||
if (appData?.site.default_language)
|
||||
await changeLanguage(appData.site.default_language)
|
||||
}
|
||||
setLocaleFromProps()
|
||||
}, [appData])
|
||||
|
||||
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const localState = localStorage.getItem('webappSidebarCollapse')
|
||||
return localState === 'collapsed'
|
||||
}
|
||||
catch {
|
||||
// localStorage may be disabled in private browsing mode or by security settings
|
||||
// fallback to default value
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
const handleSidebarCollapse = useCallback((state: boolean) => {
|
||||
if (appId) {
|
||||
setSidebarCollapseState(state)
|
||||
try {
|
||||
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
|
||||
}
|
||||
catch {
|
||||
// localStorage may be disabled, continue without persisting state
|
||||
}
|
||||
}
|
||||
}, [appId, setSidebarCollapseState])
|
||||
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
|
||||
defaultValue: {},
|
||||
})
|
||||
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || '', [appId, conversationIdInfo, userId])
|
||||
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
|
||||
if (appId) {
|
||||
let prevValue = conversationIdInfo?.[appId || '']
|
||||
if (typeof prevValue === 'string')
|
||||
prevValue = {}
|
||||
setConversationIdInfo({
|
||||
...conversationIdInfo,
|
||||
[appId || '']: {
|
||||
...prevValue,
|
||||
[userId || 'DEFAULT']: changeConversationId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [appId, conversationIdInfo, setConversationIdInfo, userId])
|
||||
|
||||
const [newConversationId, setNewConversationId] = useState('')
|
||||
const chatShouldReloadKey = useMemo(() => {
|
||||
if (currentConversationId === newConversationId)
|
||||
return ''
|
||||
|
||||
return currentConversationId
|
||||
}, [currentConversationId, newConversationId])
|
||||
|
||||
const { data: appPinnedConversationData, mutate: mutateAppPinnedConversationData } = useSWR(
|
||||
appId ? ['appConversationData', isInstalledApp, appId, true] : null,
|
||||
() => fetchConversations(isInstalledApp, appId, undefined, true, 100),
|
||||
{ revalidateOnFocus: false, revalidateOnReconnect: false },
|
||||
)
|
||||
const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(
|
||||
appId ? ['appConversationData', isInstalledApp, appId, false] : null,
|
||||
() => fetchConversations(isInstalledApp, appId, undefined, false, 100),
|
||||
{ revalidateOnFocus: false, revalidateOnReconnect: false },
|
||||
)
|
||||
const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(
|
||||
chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null,
|
||||
() => fetchChatList(chatShouldReloadKey, isInstalledApp, appId),
|
||||
{ revalidateOnFocus: false, revalidateOnReconnect: false },
|
||||
)
|
||||
|
||||
const [clearChatList, setClearChatList] = useState(false)
|
||||
const [isResponding, setIsResponding] = useState(false)
|
||||
const appPrevChatTree = useMemo(
|
||||
() => (currentConversationId && appChatListData?.data.length)
|
||||
? buildChatItemTree(getFormattedChatList(appChatListData.data))
|
||||
: [],
|
||||
[appChatListData, currentConversationId],
|
||||
)
|
||||
|
||||
const [showNewConversationItemInList, setShowNewConversationItemInList] = useState(false)
|
||||
|
||||
const pinnedConversationList = useMemo(() => {
|
||||
return appPinnedConversationData?.data || []
|
||||
}, [appPinnedConversationData])
|
||||
const { t } = useTranslation()
|
||||
const newConversationInputsRef = useRef<Record<string, any>>({})
|
||||
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
|
||||
const [initInputs, setInitInputs] = useState<Record<string, any>>({})
|
||||
const [initUserVariables, setInitUserVariables] = useState<Record<string, any>>({})
|
||||
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
|
||||
newConversationInputsRef.current = newInputs
|
||||
setNewConversationInputs(newInputs)
|
||||
}, [])
|
||||
const inputsForms = useMemo(() => {
|
||||
return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => {
|
||||
if (item.paragraph) {
|
||||
let value = initInputs[item.paragraph.variable]
|
||||
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
|
||||
value = value.slice(0, item.paragraph.max_length)
|
||||
|
||||
return {
|
||||
...item.paragraph,
|
||||
default: value || item.default || item.paragraph.default,
|
||||
type: 'paragraph',
|
||||
}
|
||||
}
|
||||
if (item.number) {
|
||||
const convertedNumber = Number(initInputs[item.number.variable])
|
||||
return {
|
||||
...item.number,
|
||||
default: convertedNumber || item.default || item.number.default,
|
||||
type: 'number',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.checkbox) {
|
||||
const preset = initInputs[item.checkbox.variable] === true
|
||||
return {
|
||||
...item.checkbox,
|
||||
default: preset || item.default || item.checkbox.default,
|
||||
type: 'checkbox',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.select) {
|
||||
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
|
||||
return {
|
||||
...item.select,
|
||||
default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.select.default,
|
||||
type: 'select',
|
||||
}
|
||||
}
|
||||
|
||||
if (item['file-list']) {
|
||||
return {
|
||||
...item['file-list'],
|
||||
type: 'file-list',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.file) {
|
||||
return {
|
||||
...item.file,
|
||||
type: 'file',
|
||||
}
|
||||
}
|
||||
|
||||
if (item.json_object) {
|
||||
return {
|
||||
...item.json_object,
|
||||
type: 'json_object',
|
||||
}
|
||||
}
|
||||
|
||||
let value = initInputs[item['text-input'].variable]
|
||||
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
|
||||
value = value.slice(0, item['text-input'].max_length)
|
||||
|
||||
return {
|
||||
...item['text-input'],
|
||||
default: value || item.default || item['text-input'].default,
|
||||
type: 'text-input',
|
||||
}
|
||||
})
|
||||
}, [initInputs, appParams])
|
||||
|
||||
const allInputsHidden = useMemo(() => {
|
||||
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
|
||||
}, [inputsForms])
|
||||
|
||||
useEffect(() => {
|
||||
// init inputs from url params
|
||||
(async () => {
|
||||
const inputs = await getRawInputsFromUrlParams()
|
||||
const userVariables = await getRawUserVariablesFromUrlParams()
|
||||
setInitInputs(inputs)
|
||||
setInitUserVariables(userVariables)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const conversationInputs: Record<string, any> = {}
|
||||
|
||||
inputsForms.forEach((item: any) => {
|
||||
conversationInputs[item.variable] = item.default || null
|
||||
})
|
||||
handleNewConversationInputsChange(conversationInputs)
|
||||
}, [handleNewConversationInputsChange, inputsForms])
|
||||
|
||||
const { data: newConversation } = useSWR(newConversationId ? [isInstalledApp, appId, newConversationId] : null, () => generationConversationName(isInstalledApp, appId, newConversationId), { revalidateOnFocus: false })
|
||||
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
|
||||
useEffect(() => {
|
||||
if (appConversationData?.data && !appConversationDataLoading)
|
||||
setOriginConversationList(appConversationData?.data)
|
||||
}, [appConversationData, appConversationDataLoading])
|
||||
const conversationList = useMemo(() => {
|
||||
const data = originConversationList.slice()
|
||||
|
||||
if (showNewConversationItemInList && data[0]?.id !== '') {
|
||||
data.unshift({
|
||||
id: '',
|
||||
name: t('share.chat.newChatDefaultName'),
|
||||
inputs: {},
|
||||
introduction: '',
|
||||
})
|
||||
}
|
||||
return data
|
||||
}, [originConversationList, showNewConversationItemInList, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (newConversation) {
|
||||
setOriginConversationList(produce((draft) => {
|
||||
const index = draft.findIndex(item => item.id === newConversation.id)
|
||||
|
||||
if (index > -1)
|
||||
draft[index] = newConversation
|
||||
else
|
||||
draft.unshift(newConversation)
|
||||
}))
|
||||
}
|
||||
}, [newConversation])
|
||||
|
||||
const currentConversationItem = useMemo(() => {
|
||||
let conversationItem = conversationList.find(item => item.id === currentConversationId)
|
||||
|
||||
if (!conversationItem && pinnedConversationList.length)
|
||||
conversationItem = pinnedConversationList.find(item => item.id === currentConversationId)
|
||||
|
||||
return conversationItem
|
||||
}, [conversationList, currentConversationId, pinnedConversationList])
|
||||
|
||||
const currentConversationLatestInputs = useMemo(() => {
|
||||
if (!currentConversationId || !appChatListData?.data.length)
|
||||
return newConversationInputsRef.current || {}
|
||||
return appChatListData.data.slice().pop().inputs || {}
|
||||
}, [appChatListData, currentConversationId])
|
||||
const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {})
|
||||
useEffect(() => {
|
||||
if (currentConversationItem)
|
||||
setCurrentConversationInputs(currentConversationLatestInputs || {})
|
||||
}, [currentConversationItem, currentConversationLatestInputs])
|
||||
|
||||
const { notify } = useToastContext()
|
||||
const checkInputsRequired = useCallback((silent?: boolean) => {
|
||||
if (allInputsHidden)
|
||||
return true
|
||||
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required, type }) => required && type !== InputVarType.checkbox)
|
||||
if (requiredVars.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!newConversationInputsRef.current[variable] && !silent)
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && newConversationInputsRef.current[variable] && !silent) {
|
||||
const files = newConversationInputsRef.current[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEmptyInput) {
|
||||
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileIsUploading) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
}, [inputsForms, notify, t, allInputsHidden])
|
||||
const handleStartChat = useCallback((callback: any) => {
|
||||
if (checkInputsRequired()) {
|
||||
setShowNewConversationItemInList(true)
|
||||
callback?.()
|
||||
}
|
||||
}, [setShowNewConversationItemInList, checkInputsRequired])
|
||||
const currentChatInstanceRef = useRef<{ handleStop: () => void }>({ handleStop: noop })
|
||||
const handleChangeConversation = useCallback((conversationId: string) => {
|
||||
currentChatInstanceRef.current.handleStop()
|
||||
setNewConversationId('')
|
||||
handleConversationIdInfoChange(conversationId)
|
||||
if (conversationId)
|
||||
setClearChatList(false)
|
||||
}, [handleConversationIdInfoChange, setClearChatList])
|
||||
const handleNewConversation = useCallback(async () => {
|
||||
currentChatInstanceRef.current.handleStop()
|
||||
setShowNewConversationItemInList(true)
|
||||
handleChangeConversation('')
|
||||
const conversationInputs: Record<string, any> = {}
|
||||
inputsForms.forEach((item: any) => {
|
||||
conversationInputs[item.variable] = item.default || null
|
||||
})
|
||||
handleNewConversationInputsChange(conversationInputs)
|
||||
setClearChatList(true)
|
||||
}, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList, inputsForms])
|
||||
const handleUpdateConversationList = useCallback(() => {
|
||||
mutateAppConversationData()
|
||||
mutateAppPinnedConversationData()
|
||||
}, [mutateAppConversationData, mutateAppPinnedConversationData])
|
||||
|
||||
const handlePinConversation = useCallback(async (conversationId: string) => {
|
||||
await pinConversation(isInstalledApp, appId, conversationId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
handleUpdateConversationList()
|
||||
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
|
||||
|
||||
const handleUnpinConversation = useCallback(async (conversationId: string) => {
|
||||
await unpinConversation(isInstalledApp, appId, conversationId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
handleUpdateConversationList()
|
||||
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
|
||||
|
||||
const [conversationDeleting, setConversationDeleting] = useState(false)
|
||||
const handleDeleteConversation = useCallback(async (
|
||||
conversationId: string,
|
||||
{
|
||||
onSuccess,
|
||||
}: Callback,
|
||||
) => {
|
||||
if (conversationDeleting)
|
||||
return
|
||||
|
||||
try {
|
||||
setConversationDeleting(true)
|
||||
await delConversation(isInstalledApp, appId, conversationId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
onSuccess()
|
||||
}
|
||||
finally {
|
||||
setConversationDeleting(false)
|
||||
}
|
||||
|
||||
if (conversationId === currentConversationId)
|
||||
handleNewConversation()
|
||||
|
||||
handleUpdateConversationList()
|
||||
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList, handleNewConversation, currentConversationId, conversationDeleting])
|
||||
|
||||
const [conversationRenaming, setConversationRenaming] = useState(false)
|
||||
const handleRenameConversation = useCallback(async (
|
||||
conversationId: string,
|
||||
newName: string,
|
||||
{
|
||||
onSuccess,
|
||||
}: Callback,
|
||||
) => {
|
||||
if (conversationRenaming)
|
||||
return
|
||||
|
||||
if (!newName.trim()) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('common.chat.conversationNameCanNotEmpty'),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setConversationRenaming(true)
|
||||
try {
|
||||
await renameConversation(isInstalledApp, appId, conversationId, newName)
|
||||
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('common.actionMsg.modifiedSuccessfully'),
|
||||
})
|
||||
setOriginConversationList(produce((draft) => {
|
||||
const index = originConversationList.findIndex(item => item.id === conversationId)
|
||||
const item = draft[index]
|
||||
|
||||
draft[index] = {
|
||||
...item,
|
||||
name: newName,
|
||||
}
|
||||
}))
|
||||
onSuccess()
|
||||
}
|
||||
finally {
|
||||
setConversationRenaming(false)
|
||||
}
|
||||
}, [isInstalledApp, appId, notify, t, conversationRenaming, originConversationList])
|
||||
|
||||
const handleNewConversationCompleted = useCallback((newConversationId: string) => {
|
||||
setNewConversationId(newConversationId)
|
||||
handleConversationIdInfoChange(newConversationId)
|
||||
setShowNewConversationItemInList(false)
|
||||
mutateAppConversationData()
|
||||
}, [mutateAppConversationData, handleConversationIdInfoChange])
|
||||
|
||||
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
|
||||
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
}, [isInstalledApp, appId, t, notify])
|
||||
|
||||
return {
|
||||
isInstalledApp,
|
||||
appId,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
handleConversationIdInfoChange,
|
||||
appData,
|
||||
appParams: appParams || {} as ChatConfig,
|
||||
appMeta,
|
||||
appPinnedConversationData,
|
||||
appConversationData,
|
||||
appConversationDataLoading,
|
||||
appChatListData,
|
||||
appChatListDataLoading,
|
||||
appPrevChatTree,
|
||||
pinnedConversationList,
|
||||
conversationList,
|
||||
setShowNewConversationItemInList,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
handleStartChat,
|
||||
handleChangeConversation,
|
||||
handlePinConversation,
|
||||
handleUnpinConversation,
|
||||
conversationDeleting,
|
||||
handleDeleteConversation,
|
||||
conversationRenaming,
|
||||
handleRenameConversation,
|
||||
handleNewConversationCompleted,
|
||||
newConversationId,
|
||||
chatShouldReloadKey,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
sidebarCollapseState,
|
||||
handleSidebarCollapse,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
isResponding,
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
}
|
||||
}
|
||||
209
dify/web/app/components/base/chat/chat-with-history/index.tsx
Normal file
209
dify/web/app/components/base/chat/chat-with-history/index.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useThemeContext } from '../embedded-chatbot/theme/theme-context'
|
||||
import {
|
||||
ChatWithHistoryContext,
|
||||
useChatWithHistoryContext,
|
||||
} from './context'
|
||||
import { useChatWithHistory } from './hooks'
|
||||
import Sidebar from './sidebar'
|
||||
import Header from './header'
|
||||
import HeaderInMobile from './header-in-mobile'
|
||||
import ChatWrapper from './chat-wrapper'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import cn from '@/utils/classnames'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
|
||||
type ChatWithHistoryProps = {
|
||||
className?: string
|
||||
}
|
||||
const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const {
|
||||
appData,
|
||||
appChatListDataLoading,
|
||||
chatShouldReloadKey,
|
||||
isMobile,
|
||||
themeBuilder,
|
||||
sidebarCollapseState,
|
||||
} = useChatWithHistoryContext()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
const customConfig = appData?.custom_config
|
||||
const site = appData?.site
|
||||
|
||||
const [showSidePanel, setShowSidePanel] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||
}, [site, customConfig, themeBuilder])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSidebarCollapsed)
|
||||
setShowSidePanel(false)
|
||||
}, [isSidebarCollapsed])
|
||||
|
||||
useDocumentTitle(site?.title || 'Chat')
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex h-full bg-background-default-burn',
|
||||
isMobile && 'flex-col',
|
||||
className,
|
||||
)}>
|
||||
{!isMobile && (
|
||||
<div className={cn(
|
||||
'flex w-[236px] flex-col p-1 pr-0 transition-all duration-200 ease-in-out',
|
||||
isSidebarCollapsed && 'w-0 overflow-hidden !p-0',
|
||||
)}>
|
||||
<Sidebar />
|
||||
</div>
|
||||
)}
|
||||
{isMobile && (
|
||||
<HeaderInMobile />
|
||||
)}
|
||||
<div className={cn('relative grow p-2', isMobile && 'h-[calc(100%_-_56px)] p-0')}>
|
||||
{isSidebarCollapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 z-20 flex h-full w-[256px] flex-col p-2 transition-all duration-500 ease-in-out',
|
||||
showSidePanel ? 'left-0' : 'left-[-248px]',
|
||||
)}
|
||||
onMouseEnter={() => setShowSidePanel(true)}
|
||||
onMouseLeave={() => setShowSidePanel(false)}
|
||||
>
|
||||
<Sidebar isPanel panelVisible={showSidePanel} />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', isMobile ? 'rounded-t-2xl' : 'rounded-2xl')}>
|
||||
{!isMobile && <Header />}
|
||||
{appChatListDataLoading && (
|
||||
<Loading type='app' />
|
||||
)}
|
||||
{!appChatListDataLoading && (
|
||||
<ChatWrapper key={chatShouldReloadKey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type ChatWithHistoryWrapProps = {
|
||||
installedAppInfo?: InstalledApp
|
||||
className?: string
|
||||
}
|
||||
const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
installedAppInfo,
|
||||
className,
|
||||
}) => {
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const themeBuilder = useThemeContext()
|
||||
|
||||
const {
|
||||
appData,
|
||||
appParams,
|
||||
appMeta,
|
||||
appChatListDataLoading,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
appPrevChatTree,
|
||||
pinnedConversationList,
|
||||
conversationList,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
handleStartChat,
|
||||
handleChangeConversation,
|
||||
handlePinConversation,
|
||||
handleUnpinConversation,
|
||||
handleDeleteConversation,
|
||||
conversationRenaming,
|
||||
handleRenameConversation,
|
||||
handleNewConversationCompleted,
|
||||
chatShouldReloadKey,
|
||||
isInstalledApp,
|
||||
appId,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
sidebarCollapseState,
|
||||
handleSidebarCollapse,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
isResponding,
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useChatWithHistory(installedAppInfo)
|
||||
|
||||
return (
|
||||
<ChatWithHistoryContext.Provider value={{
|
||||
appData,
|
||||
appParams,
|
||||
appMeta,
|
||||
appChatListDataLoading,
|
||||
currentConversationId,
|
||||
currentConversationItem,
|
||||
appPrevChatTree,
|
||||
pinnedConversationList,
|
||||
conversationList,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
inputsForms,
|
||||
handleNewConversation,
|
||||
handleStartChat,
|
||||
handleChangeConversation,
|
||||
handlePinConversation,
|
||||
handleUnpinConversation,
|
||||
handleDeleteConversation,
|
||||
conversationRenaming,
|
||||
handleRenameConversation,
|
||||
handleNewConversationCompleted,
|
||||
chatShouldReloadKey,
|
||||
isMobile,
|
||||
isInstalledApp,
|
||||
appId,
|
||||
handleFeedback,
|
||||
currentChatInstanceRef,
|
||||
themeBuilder,
|
||||
sidebarCollapseState,
|
||||
handleSidebarCollapse,
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
isResponding,
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
}}>
|
||||
<ChatWithHistory className={className} />
|
||||
</ChatWithHistoryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatWithHistoryWrapWithCheckToken: FC<ChatWithHistoryWrapProps> = ({
|
||||
installedAppInfo,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<ChatWithHistoryWrap
|
||||
installedAppInfo={installedAppInfo}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatWithHistoryWrapWithCheckToken
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { PortalSelect } from '@/app/components/base/select'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
|
||||
type Props = {
|
||||
showTip?: boolean
|
||||
}
|
||||
|
||||
const InputsFormContent = ({ showTip }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
appParams,
|
||||
inputsForms,
|
||||
currentConversationId,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
newConversationInputs,
|
||||
newConversationInputsRef,
|
||||
handleNewConversationInputsChange,
|
||||
} = useChatWithHistoryContext()
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputs
|
||||
|
||||
const handleFormChange = useCallback((variable: string, value: any) => {
|
||||
setCurrentConversationInputs({
|
||||
...currentConversationInputs,
|
||||
[variable]: value,
|
||||
})
|
||||
handleNewConversationInputsChange({
|
||||
...newConversationInputsRef.current,
|
||||
[variable]: value,
|
||||
})
|
||||
}, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs])
|
||||
|
||||
const visibleInputsForms = inputsForms.filter(form => form.hide !== true)
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{visibleInputsForms.map(form => (
|
||||
<div key={form.variable} className='space-y-1'>
|
||||
{form.type !== InputVarType.checkbox && (
|
||||
<div className='flex h-6 items-center gap-1'>
|
||||
<div className='system-md-semibold text-text-secondary'>{form.label}</div>
|
||||
{!form.required && (
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{form.type === InputVarType.textInput && (
|
||||
<Input
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.number && (
|
||||
<Input
|
||||
type='number'
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.paragraph && (
|
||||
<Textarea
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.checkbox && (
|
||||
<BoolInput
|
||||
name={form.label}
|
||||
value={!!inputsFormValue?.[form.variable]}
|
||||
required={form.required}
|
||||
onChange={value => handleFormChange(form.variable, value)}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.select && (
|
||||
<PortalSelect
|
||||
popupClassName='w-[200px]'
|
||||
value={inputsFormValue?.[form.variable] ?? form.default ?? ''}
|
||||
items={form.options.map((option: string) => ({ value: option, name: option }))}
|
||||
onSelect={item => handleFormChange(form.variable, item.value as string)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.singleFile && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={inputsFormValue?.[form.variable] ? [inputsFormValue?.[form.variable]] : []}
|
||||
onChange={files => handleFormChange(form.variable, files[0])}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: 1,
|
||||
fileUploadConfig: (appParams as any).system_parameters,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.multiFiles && (
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={inputsFormValue?.[form.variable] || []}
|
||||
onChange={files => handleFormChange(form.variable, files)}
|
||||
fileConfig={{
|
||||
allowed_file_types: form.allowed_file_types,
|
||||
allowed_file_extensions: form.allowed_file_extensions,
|
||||
allowed_file_upload_methods: form.allowed_file_upload_methods,
|
||||
number_limits: form.max_length,
|
||||
fileUploadConfig: (appParams as any).system_parameters,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{form.type === InputVarType.jsonObject && (
|
||||
<CodeEditor
|
||||
language={CodeLanguage.json}
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onChange={v => handleFormChange(form.variable, v)}
|
||||
noWrapper
|
||||
className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1'
|
||||
placeholder={
|
||||
<div className='whitespace-pre'>{form.json_schema}</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{showTip && (
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('share.chat.chatFormTip')}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(InputsFormContent)
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
collapsed: boolean
|
||||
setCollapsed: (collapsed: boolean) => void
|
||||
}
|
||||
|
||||
const InputsFormNode = ({
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isMobile,
|
||||
currentConversationId,
|
||||
handleStartChat,
|
||||
allInputsHidden,
|
||||
themeBuilder,
|
||||
inputsForms,
|
||||
} = useChatWithHistoryContext()
|
||||
|
||||
if (allInputsHidden || inputsForms.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}>
|
||||
<div className={cn(
|
||||
'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md',
|
||||
collapsed && 'border border-components-card-border bg-components-card-bg shadow-none',
|
||||
)}>
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 rounded-t-2xl px-6 py-4',
|
||||
!collapsed && 'border-b border-divider-subtle',
|
||||
isMobile && 'px-4 py-3',
|
||||
)}>
|
||||
<Message3Fill className='h-6 w-6 shrink-0' />
|
||||
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
|
||||
{collapsed && (
|
||||
<Button className='uppercase text-text-tertiary' size='small' variant='ghost' onClick={() => setCollapsed(false)}>{t('common.operation.edit')}</Button>
|
||||
)}
|
||||
{!collapsed && currentConversationId && (
|
||||
<Button className='uppercase text-text-tertiary' size='small' variant='ghost' onClick={() => setCollapsed(true)}>{t('common.operation.close')}</Button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className={cn('p-6', isMobile && 'p-4')}>
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && !currentConversationId && (
|
||||
<div className={cn('p-6', isMobile && 'p-4')}>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='w-full'
|
||||
onClick={() => handleStartChat(() => setCollapsed(true))}
|
||||
style={
|
||||
themeBuilder?.theme
|
||||
? {
|
||||
backgroundColor: themeBuilder?.theme.primaryColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>{t('share.chat.startChat')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{collapsed && (
|
||||
<div className='flex w-full max-w-[720px] items-center py-4'>
|
||||
<Divider bgStyle='gradient' className='h-px basis-1/2 rotate-180' />
|
||||
<Divider bgStyle='gradient' className='h-px basis-1/2' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputsFormNode
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiChatSettingsLine,
|
||||
} from '@remixicon/react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
|
||||
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
|
||||
|
||||
const ViewFormDropdown = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<ActionButton size='l' state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
|
||||
<RiChatSettingsLine className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div className='w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm'>
|
||||
<div className='flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4'>
|
||||
<Message3Fill className='h-6 w-6 shrink-0' />
|
||||
<div className='system-xl-semibold grow text-text-secondary'>{t('share.chat.chatSettingsTitle')}</div>
|
||||
</div>
|
||||
<div className='p-6'>
|
||||
<InputsFormContent />
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default ViewFormDropdown
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiEditBoxLine,
|
||||
RiExpandRightLine,
|
||||
RiLayoutLeft2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import List from '@/app/components/base/chat/chat-with-history/sidebar/list'
|
||||
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
type Props = {
|
||||
isPanel?: boolean
|
||||
panelVisible?: boolean
|
||||
}
|
||||
|
||||
const Sidebar = ({ isPanel, panelVisible }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isInstalledApp,
|
||||
appData,
|
||||
handleNewConversation,
|
||||
pinnedConversationList,
|
||||
conversationList,
|
||||
currentConversationId,
|
||||
handleChangeConversation,
|
||||
handlePinConversation,
|
||||
handleUnpinConversation,
|
||||
conversationRenaming,
|
||||
handleRenameConversation,
|
||||
handleDeleteConversation,
|
||||
sidebarCollapseState,
|
||||
handleSidebarCollapse,
|
||||
isMobile,
|
||||
isResponding,
|
||||
} = useChatWithHistoryContext()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
|
||||
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
|
||||
|
||||
const handleOperate = useCallback((type: string, item: ConversationItem) => {
|
||||
if (type === 'pin')
|
||||
handlePinConversation(item.id)
|
||||
|
||||
if (type === 'unpin')
|
||||
handleUnpinConversation(item.id)
|
||||
|
||||
if (type === 'delete')
|
||||
setShowConfirm(item)
|
||||
|
||||
if (type === 'rename')
|
||||
setShowRename(item)
|
||||
}, [handlePinConversation, handleUnpinConversation])
|
||||
const handleCancelConfirm = useCallback(() => {
|
||||
setShowConfirm(null)
|
||||
}, [])
|
||||
const handleDelete = useCallback(() => {
|
||||
if (showConfirm)
|
||||
handleDeleteConversation(showConfirm.id, { onSuccess: handleCancelConfirm })
|
||||
}, [showConfirm, handleDeleteConversation, handleCancelConfirm])
|
||||
const handleCancelRename = useCallback(() => {
|
||||
setShowRename(null)
|
||||
}, [])
|
||||
const handleRename = useCallback((newName: string) => {
|
||||
if (showRename)
|
||||
handleRenameConversation(showRename.id, newName, { onSuccess: handleCancelRename })
|
||||
}, [showRename, handleRenameConversation, handleCancelRename])
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex w-full grow flex-col',
|
||||
isPanel && 'rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-bg shadow-lg',
|
||||
)}>
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-3 p-3 pr-2',
|
||||
)}>
|
||||
<div className='shrink-0'>
|
||||
<AppIcon
|
||||
size='large'
|
||||
iconType={appData?.site.icon_type}
|
||||
icon={appData?.site.icon}
|
||||
background={appData?.site.icon_background}
|
||||
imageUrl={appData?.site.icon_url}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('system-md-semibold grow truncate text-text-secondary')}>{appData?.site.title}</div>
|
||||
{!isMobile && isSidebarCollapsed && (
|
||||
<ActionButton size='l' onClick={() => handleSidebarCollapse(false)}>
|
||||
<RiExpandRightLine className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{!isMobile && !isSidebarCollapsed && (
|
||||
<ActionButton size='l' onClick={() => handleSidebarCollapse(true)}>
|
||||
<RiLayoutLeft2Line className='h-[18px] w-[18px]' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
<div className='shrink-0 px-3 py-4'>
|
||||
<Button variant='secondary-accent' disabled={isResponding} className='w-full justify-center' onClick={handleNewConversation}>
|
||||
<RiEditBoxLine className='mr-1 h-4 w-4' />
|
||||
{t('share.chat.newChat')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className='h-0 grow space-y-2 overflow-y-auto px-3 pt-4'>
|
||||
{/* pinned list */}
|
||||
{!!pinnedConversationList.length && (
|
||||
<div className='mb-4'>
|
||||
<List
|
||||
isPin
|
||||
title={t('share.chat.pinnedTitle') || ''}
|
||||
list={pinnedConversationList}
|
||||
onChangeConversation={handleChangeConversation}
|
||||
onOperate={handleOperate}
|
||||
currentConversationId={currentConversationId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!!conversationList.length && (
|
||||
<List
|
||||
title={(pinnedConversationList.length && t('share.chat.unpinnedTitle')) || ''}
|
||||
list={conversationList}
|
||||
onChangeConversation={handleChangeConversation}
|
||||
onOperate={handleOperate}
|
||||
currentConversationId={currentConversationId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center justify-between p-3'>
|
||||
<MenuDropdown
|
||||
hideLogout={isInstalledApp}
|
||||
placement='top-start'
|
||||
data={appData?.site}
|
||||
forceClose={isPanel && !panelVisible}
|
||||
/>
|
||||
{/* powered by */}
|
||||
<div className='shrink-0'>
|
||||
{!appData?.custom_config?.remove_webapp_brand && (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
|
||||
: appData?.custom_config?.replace_webapp_logo
|
||||
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
|
||||
: <DifyLogo size='small' />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!showConfirm && (
|
||||
<Confirm
|
||||
title={t('share.chat.deleteConversation.title')}
|
||||
content={t('share.chat.deleteConversation.content') || ''}
|
||||
isShow
|
||||
onCancel={handleCancelConfirm}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useHover } from 'ahooks'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import Operation from '@/app/components/base/chat/chat-with-history/sidebar/operation'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ItemProps = {
|
||||
isPin?: boolean
|
||||
item: ConversationItem
|
||||
onOperate: (type: string, item: ConversationItem) => void
|
||||
onChangeConversation: (conversationId: string) => void
|
||||
currentConversationId: string
|
||||
}
|
||||
const Item: FC<ItemProps> = ({
|
||||
isPin,
|
||||
item,
|
||||
onOperate,
|
||||
onChangeConversation,
|
||||
currentConversationId,
|
||||
}) => {
|
||||
const ref = useRef(null)
|
||||
const isHovering = useHover(ref)
|
||||
const isSelected = currentConversationId === item.id
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'system-sm-medium group flex cursor-pointer rounded-lg p-1 pl-3 text-components-menu-item-text hover:bg-state-base-hover',
|
||||
isSelected && 'bg-state-accent-active text-text-accent hover:bg-state-accent-active',
|
||||
)}
|
||||
onClick={() => onChangeConversation(item.id)}
|
||||
>
|
||||
<div className='grow truncate p-1 pl-0' title={item.name}>{item.name}</div>
|
||||
{item.id !== '' && (
|
||||
<div className='shrink-0' onClick={e => e.stopPropagation()}>
|
||||
<Operation
|
||||
isActive={isSelected}
|
||||
isPinned={!!isPin}
|
||||
isItemHovering={isHovering}
|
||||
togglePin={() => onOperate(isPin ? 'unpin' : 'pin', item)}
|
||||
isShowDelete
|
||||
isShowRenameConversation
|
||||
onRenameConversation={() => onOperate('rename', item)}
|
||||
onDelete={() => onOperate('delete', item)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Item)
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC } from 'react'
|
||||
import Item from './item'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
|
||||
type ListProps = {
|
||||
isPin?: boolean
|
||||
title?: string
|
||||
list: ConversationItem[]
|
||||
onOperate: (type: string, item: ConversationItem) => void
|
||||
onChangeConversation: (conversationId: string) => void
|
||||
currentConversationId: string
|
||||
}
|
||||
const List: FC<ListProps> = ({
|
||||
isPin,
|
||||
title,
|
||||
list,
|
||||
onOperate,
|
||||
onChangeConversation,
|
||||
currentConversationId,
|
||||
}) => {
|
||||
return (
|
||||
<div className='space-y-0.5'>
|
||||
{title && (
|
||||
<div className='system-xs-medium-uppercase px-3 pb-1 pt-2 text-text-tertiary'>{title}</div>
|
||||
)}
|
||||
{list.map(item => (
|
||||
<Item
|
||||
key={item.id}
|
||||
isPin={isPin}
|
||||
item={item}
|
||||
onOperate={onOperate}
|
||||
onChangeConversation={onChangeConversation}
|
||||
currentConversationId={currentConversationId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default List
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiMoreFill,
|
||||
RiPushpinLine,
|
||||
RiUnpinLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
isActive?: boolean
|
||||
isItemHovering?: boolean
|
||||
isPinned: boolean
|
||||
isShowRenameConversation?: boolean
|
||||
onRenameConversation?: () => void
|
||||
isShowDelete: boolean
|
||||
togglePin: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const Operation: FC<Props> = ({
|
||||
isActive,
|
||||
isItemHovering,
|
||||
isPinned,
|
||||
togglePin,
|
||||
isShowRenameConversation,
|
||||
onRenameConversation,
|
||||
isShowDelete,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const [isHovering, { setTrue: setIsHovering, setFalse: setNotHovering }] = useBoolean(false)
|
||||
useEffect(() => {
|
||||
if (!isItemHovering && !isHovering)
|
||||
setOpen(false)
|
||||
}, [isItemHovering, isHovering])
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => setOpen(v => !v)}
|
||||
>
|
||||
<ActionButton
|
||||
className={cn((isItemHovering || open) ? 'opacity-100' : 'opacity-0')}
|
||||
state={
|
||||
isActive
|
||||
? ActionButtonState.Active
|
||||
: open
|
||||
? ActionButtonState.Hover
|
||||
: ActionButtonState.Default
|
||||
}
|
||||
>
|
||||
<RiMoreFill className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-50">
|
||||
<div
|
||||
ref={ref}
|
||||
className={'min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm'}
|
||||
onMouseEnter={setIsHovering}
|
||||
onMouseLeave={setNotHovering}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<div className={cn('system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover')} onClick={togglePin}>
|
||||
{isPinned && <RiUnpinLine className='h-4 w-4 shrink-0 text-text-tertiary' />}
|
||||
{!isPinned && <RiPushpinLine className='h-4 w-4 shrink-0 text-text-tertiary' />}
|
||||
<span className='grow'>{isPinned ? t('explore.sidebar.action.unpin') : t('explore.sidebar.action.pin')}</span>
|
||||
</div>
|
||||
{isShowRenameConversation && (
|
||||
<div className={cn('system-md-regular flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-base-hover')} onClick={onRenameConversation}>
|
||||
<RiEditLine className='h-4 w-4 shrink-0 text-text-tertiary' />
|
||||
<span className='grow'>{t('explore.sidebar.action.rename')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isShowDelete && (
|
||||
<div className={cn('system-md-regular group flex cursor-pointer items-center space-x-1 rounded-lg px-2 py-1.5 text-text-secondary hover:bg-state-destructive-hover hover:text-text-destructive')} onClick={onDelete} >
|
||||
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover:text-text-destructive')} />
|
||||
<span className='grow'>{t('explore.sidebar.action.delete')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(Operation)
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
||||
export type IRenameModalProps = {
|
||||
isShow: boolean
|
||||
saveLoading: boolean
|
||||
name: string
|
||||
onClose: () => void
|
||||
onSave: (name: string) => void
|
||||
}
|
||||
|
||||
const RenameModal: FC<IRenameModalProps> = ({
|
||||
isShow,
|
||||
saveLoading,
|
||||
name,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [tempName, setTempName] = useState(name)
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('common.chat.renameConversation')}
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={'mt-6 text-sm font-medium leading-[21px] text-text-primary'}>{t('common.chat.conversationName')}</div>
|
||||
<Input className='mt-2 h-10 w-full'
|
||||
value={tempName}
|
||||
onChange={e => setTempName(e.target.value)}
|
||||
placeholder={t('common.chat.conversationNamePlaceholder') || ''}
|
||||
/>
|
||||
|
||||
<div className='mt-10 flex justify-end'>
|
||||
<Button className='mr-2 shrink-0' onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' className='shrink-0' onClick={() => onSave(tempName)} loading={saveLoading}>{t('common.operation.save')}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default React.memo(RenameModal)
|
||||
@@ -0,0 +1,61 @@
|
||||
export const markdownContent = `
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
### Heading 3
|
||||
|
||||
#### Heading 4
|
||||
|
||||
##### Heading 5
|
||||
|
||||
###### Heading 6
|
||||
|
||||
# Basic markdown content.
|
||||
|
||||
Should support **bold**, *italic*, and ~~strikethrough~~.
|
||||
Should support [links](https://www.google.com).
|
||||
Should support inline \`code\` blocks.
|
||||
|
||||
# Number list
|
||||
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
|
||||
# Bullet list
|
||||
|
||||
- First item
|
||||
- Second item
|
||||
- Third item
|
||||
|
||||
# Link
|
||||
|
||||
[Google](https://www.google.com)
|
||||
|
||||
# Image
|
||||
|
||||

|
||||
|
||||
# Table
|
||||
|
||||
| Column 1 | Column 2 | Column 3 |
|
||||
| -------- | -------- | -------- |
|
||||
| Cell 1 | Cell 2 | Cell 3 |
|
||||
| Cell 4 | Cell 5 | Cell 6 |
|
||||
| Cell 7 | Cell 8 | Cell 9 |
|
||||
|
||||
# Code
|
||||
|
||||
\`\`\`JavaScript
|
||||
const code = "code"
|
||||
\`\`\`
|
||||
|
||||
# Blockquote
|
||||
|
||||
> This is a blockquote.
|
||||
|
||||
# Horizontal rule
|
||||
|
||||
---
|
||||
`
|
||||
@@ -0,0 +1,27 @@
|
||||
export const markdownContentSVG = `
|
||||
\`\`\`svg
|
||||
<svg width="400" height="600" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#F0F8FF"/>
|
||||
|
||||
<text x="50%" y="60" font-family="楷体" font-size="32" fill="#4682B4" text-anchor="middle">创意 Logo 设计</text>
|
||||
|
||||
<line x1="50" y1="80" x2="350" y2="80" stroke="#B0C4DE" stroke-width="2"/>
|
||||
|
||||
<text x="50%" y="120" font-family="Arial" font-size="24" fill="#708090" text-anchor="middle">科研</text>
|
||||
<text x="50%" y="150" font-family="MS Mincho" font-size="20" fill="#778899" text-anchor="middle">科学研究</text>
|
||||
|
||||
<text x="50%" y="200" font-family="汇文明朝体" font-size="18" fill="#696969" text-anchor="middle">
|
||||
<tspan x="50%" dy="25">探索未知的灯塔,</tspan>
|
||||
<tspan x="50%" dy="25">照亮人类前进的道路。</tspan>
|
||||
<tspan x="50%" dy="25">科研,是永不熄灭的好奇心,</tspan>
|
||||
<tspan x="50%" dy="25">也是推动世界进步的引擎。</tspan>
|
||||
</text>
|
||||
|
||||
<circle cx="200" cy="400" r="80" fill="none" stroke="#4169E1" stroke-width="3"/>
|
||||
<line x1="200" y1="320" x2="200" y2="480" stroke="#4169E1" stroke-width="3"/>
|
||||
<line x1="120" y1="400" x2="280" y2="400" stroke="#4169E1" stroke-width="3"/>
|
||||
|
||||
<text x="50%" y="550" font-family="微软雅黑" font-size="16" fill="#1E90FF" text-anchor="middle">探索 • 创新 • 进步</text>
|
||||
</svg>
|
||||
\`\`\`
|
||||
`
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
|
||||
export const mockedWorkflowProcess = {
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
resultText: 'Hello, how can I assist you today?',
|
||||
tracing: [
|
||||
{
|
||||
extras: {},
|
||||
id: 'f6337dc9-e280-4915-965f-10b0552dd917',
|
||||
node_id: '1724232060789',
|
||||
node_type: 'start',
|
||||
title: 'Start',
|
||||
index: 1,
|
||||
predecessor_node_id: null,
|
||||
inputs: {
|
||||
'sys.query': 'hi',
|
||||
'sys.files': [],
|
||||
'sys.conversation_id': '92ce0a3e-8f15-43d1-b31d-32716c4b10a7',
|
||||
'sys.user_id': 'fbff43f9-d5a4-4e85-b63b-d3a91d806c6f',
|
||||
'sys.dialogue_count': 1,
|
||||
'sys.app_id': 'b2e8906a-aad3-43a0-9ace-0e44cc7315e1',
|
||||
'sys.workflow_id': '70004abe-561f-418b-b9e8-8c957ce55140',
|
||||
'sys.workflow_run_id': '69db9267-aaee-42e1-9581-dbfb67e8eeb5',
|
||||
},
|
||||
process_data: null,
|
||||
outputs: {
|
||||
'sys.query': 'hi',
|
||||
'sys.files': [],
|
||||
'sys.conversation_id': '92ce0a3e-8f15-43d1-b31d-32716c4b10a7',
|
||||
'sys.user_id': 'fbff43f9-d5a4-4e85-b63b-d3a91d806c6f',
|
||||
'sys.dialogue_count': 1,
|
||||
'sys.app_id': 'b2e8906a-aad3-43a0-9ace-0e44cc7315e1',
|
||||
'sys.workflow_id': '70004abe-561f-418b-b9e8-8c957ce55140',
|
||||
'sys.workflow_run_id': '69db9267-aaee-42e1-9581-dbfb67e8eeb5',
|
||||
},
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
elapsed_time: 0.035744,
|
||||
execution_metadata: null,
|
||||
created_at: 1728980002,
|
||||
finished_at: 1728980002,
|
||||
files: [],
|
||||
parallel_id: null,
|
||||
parallel_start_node_id: null,
|
||||
parent_parallel_id: null,
|
||||
parent_parallel_start_node_id: null,
|
||||
iteration_id: null,
|
||||
loop_id: null,
|
||||
},
|
||||
{
|
||||
extras: {},
|
||||
id: '92204d8d-4198-4c46-aa02-c2754b11dec9',
|
||||
node_id: 'llm',
|
||||
node_type: 'llm',
|
||||
title: 'LLM',
|
||||
index: 2,
|
||||
predecessor_node_id: '1724232060789',
|
||||
inputs: null,
|
||||
process_data: {
|
||||
model_mode: 'chat',
|
||||
prompts: [
|
||||
{
|
||||
role: 'system',
|
||||
text: 'hi',
|
||||
files: [],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
text: 'hi',
|
||||
files: [],
|
||||
},
|
||||
],
|
||||
model_provider: 'openai',
|
||||
model_name: 'gpt-4o-mini',
|
||||
},
|
||||
outputs: {
|
||||
text: 'Hello! How can I assist you today?',
|
||||
usage: {
|
||||
prompt_tokens: 13,
|
||||
prompt_unit_price: '0.15',
|
||||
prompt_price_unit: '0.000001',
|
||||
prompt_price: '0.0000020',
|
||||
completion_tokens: 9,
|
||||
completion_unit_price: '0.60',
|
||||
completion_price_unit: '0.000001',
|
||||
completion_price: '0.0000054',
|
||||
total_tokens: 22,
|
||||
total_price: '0.0000074',
|
||||
currency: 'USD',
|
||||
latency: 1.8902503330027685,
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
elapsed_time: 5.089409,
|
||||
execution_metadata: {
|
||||
total_tokens: 22,
|
||||
total_price: '0.0000074',
|
||||
currency: 'USD',
|
||||
},
|
||||
created_at: 1728980002,
|
||||
finished_at: 1728980007,
|
||||
files: [],
|
||||
parallel_id: null,
|
||||
parallel_start_node_id: null,
|
||||
parent_parallel_id: null,
|
||||
parent_parallel_start_node_id: null,
|
||||
iteration_id: null,
|
||||
loop_id: null,
|
||||
},
|
||||
{
|
||||
extras: {},
|
||||
id: '7149bac6-60f9-4e06-a5ed-1d9d3764c06b',
|
||||
node_id: 'answer',
|
||||
node_type: 'answer',
|
||||
title: 'Answer',
|
||||
index: 3,
|
||||
predecessor_node_id: 'llm',
|
||||
inputs: null,
|
||||
process_data: null,
|
||||
outputs: {
|
||||
answer: 'Hello! How can I assist you today?',
|
||||
},
|
||||
status: 'succeeded',
|
||||
error: null,
|
||||
elapsed_time: 0.015339,
|
||||
execution_metadata: null,
|
||||
created_at: 1728980007,
|
||||
finished_at: 1728980007,
|
||||
parallel_id: null,
|
||||
parallel_start_node_id: null,
|
||||
parent_parallel_id: null,
|
||||
parent_parallel_start_node_id: null,
|
||||
},
|
||||
],
|
||||
} as unknown as WorkflowProcess
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import type {
|
||||
ChatItem,
|
||||
} from '../../types'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Thought from '@/app/components/base/chat/chat/thought'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
|
||||
type AgentContentProps = {
|
||||
item: ChatItem
|
||||
responding?: boolean
|
||||
content?: string
|
||||
}
|
||||
const AgentContent: FC<AgentContentProps> = ({
|
||||
item,
|
||||
responding,
|
||||
content,
|
||||
}) => {
|
||||
const {
|
||||
annotation,
|
||||
agent_thoughts,
|
||||
} = item
|
||||
|
||||
if (annotation?.logAnnotation)
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} />
|
||||
|
||||
return (
|
||||
<div>
|
||||
{content ? <Markdown content={content} /> : agent_thoughts?.map((thought, index) => (
|
||||
<div key={index} className='px-2 py-1'>
|
||||
{thought.thought && (
|
||||
<Markdown content={thought.thought} />
|
||||
)}
|
||||
{/* {item.tool} */}
|
||||
{/* perhaps not use tool */}
|
||||
{!!thought.tool && (
|
||||
<Thought
|
||||
thought={thought}
|
||||
isFinished={!!thought.observation || !responding}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
!!thought.message_files?.length && (
|
||||
<FileList
|
||||
files={getProcessedFilesFromResponse(thought.message_files.map((item: any) => ({ ...item, related_id: item.id })))}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction={true}
|
||||
canPreview={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AgentContent)
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import type { ChatItem } from '../../types'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type BasicContentProps = {
|
||||
item: ChatItem
|
||||
}
|
||||
const BasicContent: FC<BasicContentProps> = ({
|
||||
item,
|
||||
}) => {
|
||||
const {
|
||||
annotation,
|
||||
content,
|
||||
} = item
|
||||
|
||||
if (annotation?.logAnnotation)
|
||||
return <Markdown content={annotation?.logAnnotation.content || ''} />
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
className={cn(
|
||||
item.isError && '!text-[#F04438]',
|
||||
)}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(BasicContent)
|
||||
100
dify/web/app/components/base/chat/chat/answer/index.stories.tsx
Normal file
100
dify/web/app/components/base/chat/chat/answer/index.stories.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import type { ChatItem } from '../../types'
|
||||
import { markdownContent } from './__mocks__/markdownContent'
|
||||
import { markdownContentSVG } from './__mocks__/markdownContentSVG'
|
||||
import Answer from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Other/Chat Answer',
|
||||
component: Answer,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
noChatInput: { control: 'boolean', description: 'If set to true, some buttons that are supposed to be shown on hover will not be displayed.' },
|
||||
responding: { control: 'boolean', description: 'Indicates if the answer is being generated.' },
|
||||
showPromptLog: { control: 'boolean', description: 'If set to true, the prompt log button will be shown on hover.' },
|
||||
},
|
||||
args: {
|
||||
noChatInput: false,
|
||||
responding: false,
|
||||
showPromptLog: false,
|
||||
},
|
||||
} satisfies Meta<typeof Answer>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const mockedBaseChatItem = {
|
||||
id: '1',
|
||||
isAnswer: true,
|
||||
content: 'Hello, how can I assist you today?',
|
||||
} satisfies ChatItem
|
||||
|
||||
const mockedWorkflowProcess = {
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
tracing: [],
|
||||
}
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
item: mockedBaseChatItem,
|
||||
question: mockedBaseChatItem.content,
|
||||
index: 0,
|
||||
},
|
||||
render: (args) => {
|
||||
return <div className="w-full px-10 py-5">
|
||||
<Answer {...args} />
|
||||
</div>
|
||||
},
|
||||
}
|
||||
|
||||
export const WithWorkflowProcess: Story = {
|
||||
args: {
|
||||
item: {
|
||||
...mockedBaseChatItem,
|
||||
workflowProcess: mockedWorkflowProcess,
|
||||
},
|
||||
question: mockedBaseChatItem.content,
|
||||
index: 0,
|
||||
},
|
||||
render: (args) => {
|
||||
return <div className="w-full px-10 py-5">
|
||||
<Answer {...args} />
|
||||
</div>
|
||||
},
|
||||
}
|
||||
|
||||
export const WithMarkdownContent: Story = {
|
||||
args: {
|
||||
item: {
|
||||
...mockedBaseChatItem,
|
||||
content: markdownContent,
|
||||
},
|
||||
question: mockedBaseChatItem.content,
|
||||
index: 0,
|
||||
},
|
||||
render: (args) => {
|
||||
return <div className="w-full px-10 py-5">
|
||||
<Answer {...args} />
|
||||
</div>
|
||||
},
|
||||
}
|
||||
|
||||
export const WithMarkdownSVG: Story = {
|
||||
args: {
|
||||
item: {
|
||||
...mockedBaseChatItem,
|
||||
content: markdownContentSVG,
|
||||
},
|
||||
question: mockedBaseChatItem.content,
|
||||
index: 0,
|
||||
},
|
||||
render: (args) => {
|
||||
return <div className="w-full px-10 py-5">
|
||||
<Answer {...args} />
|
||||
</div>
|
||||
},
|
||||
}
|
||||
233
dify/web/app/components/base/chat/chat/answer/index.tsx
Normal file
233
dify/web/app/components/base/chat/chat/answer/index.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import type {
|
||||
FC,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
} from '../../types'
|
||||
import Operation from './operation'
|
||||
import AgentContent from './agent-content'
|
||||
import BasicContent from './basic-content'
|
||||
import SuggestedQuestions from './suggested-questions'
|
||||
import More from './more'
|
||||
import WorkflowProcessItem from './workflow-process'
|
||||
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
||||
import Citation from '@/app/components/base/chat/chat/citation'
|
||||
import { EditTitle } from '@/app/components/app/annotation/edit-annotation-modal/edit-item'
|
||||
import type { AppData } from '@/models/share'
|
||||
import AnswerIcon from '@/app/components/base/answer-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import ContentSwitch from '../content-switch'
|
||||
|
||||
type AnswerProps = {
|
||||
item: ChatItem
|
||||
question: string
|
||||
index: number
|
||||
config?: ChatConfig
|
||||
answerIcon?: ReactNode
|
||||
responding?: boolean
|
||||
showPromptLog?: boolean
|
||||
chatAnswerContainerInner?: string
|
||||
hideProcessDetail?: boolean
|
||||
appData?: AppData
|
||||
noChatInput?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
}
|
||||
const Answer: FC<AnswerProps> = ({
|
||||
item,
|
||||
question,
|
||||
index,
|
||||
config,
|
||||
answerIcon,
|
||||
responding,
|
||||
showPromptLog,
|
||||
chatAnswerContainerInner,
|
||||
hideProcessDetail,
|
||||
appData,
|
||||
noChatInput,
|
||||
switchSibling,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
content,
|
||||
citation,
|
||||
agent_thoughts,
|
||||
more,
|
||||
annotation,
|
||||
workflowProcess,
|
||||
allFiles,
|
||||
message_files,
|
||||
} = item
|
||||
const hasAgentThoughts = !!agent_thoughts?.length
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [contentWidth, setContentWidth] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getContainerWidth = () => {
|
||||
if (containerRef.current)
|
||||
setContainerWidth(containerRef.current?.clientWidth + 16)
|
||||
}
|
||||
useEffect(() => {
|
||||
getContainerWidth()
|
||||
}, [])
|
||||
|
||||
const getContentWidth = () => {
|
||||
if (contentRef.current)
|
||||
setContentWidth(contentRef.current?.clientWidth)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!responding)
|
||||
getContentWidth()
|
||||
}, [responding])
|
||||
|
||||
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current)
|
||||
return
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
getContentWidth()
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSwitchSibling = useCallback((direction: 'prev' | 'next') => {
|
||||
if (direction === 'prev') {
|
||||
if (item.prevSibling)
|
||||
switchSibling?.(item.prevSibling)
|
||||
}
|
||||
else {
|
||||
if (item.nextSibling)
|
||||
switchSibling?.(item.nextSibling)
|
||||
}
|
||||
}, [switchSibling, item.prevSibling, item.nextSibling])
|
||||
|
||||
const contentIsEmpty = content.trim() === ''
|
||||
|
||||
return (
|
||||
<div className='mb-2 flex last:mb-0'>
|
||||
<div className='relative h-10 w-10 shrink-0'>
|
||||
{answerIcon || <AnswerIcon />}
|
||||
{responding && (
|
||||
<div className='absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs'>
|
||||
<LoadingAnim type='avatar' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='chat-answer-container group ml-4 w-0 grow pb-4' ref={containerRef}>
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className='flex h-5 w-6 items-center justify-center'>
|
||||
<LoadingAnim type='text' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
(hasAgentThoughts) && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className='my-1'
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className='my-1'
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className='mt-1'
|
||||
title={t('appAnnotation.editBy', { author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined && (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<More more={more} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Answer)
|
||||
46
dify/web/app/components/base/chat/chat/answer/more.tsx
Normal file
46
dify/web/app/components/base/chat/chat/answer/more.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ChatItem } from '../../types'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
type MoreProps = {
|
||||
more: ChatItem['more']
|
||||
}
|
||||
const More: FC<MoreProps> = ({
|
||||
more,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='system-xs-regular mt-1 flex items-center text-text-quaternary opacity-0 group-hover:opacity-100'>
|
||||
{
|
||||
more && (
|
||||
<>
|
||||
<div
|
||||
className='mr-2 max-w-[33.3%] shrink-0 truncate'
|
||||
title={`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
|
||||
>
|
||||
{`${t('appLog.detail.timeConsuming')} ${more.latency}${t('appLog.detail.second')}`}
|
||||
</div>
|
||||
<div
|
||||
className='max-w-[33.3%] shrink-0 truncate'
|
||||
title={`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
|
||||
>
|
||||
{`${t('appLog.detail.tokenCost')} ${formatNumber(more.tokens)}`}
|
||||
</div>
|
||||
<div className='mx-2 shrink-0'>·</div>
|
||||
<div
|
||||
className='max-w-[33.3%] shrink-0 truncate'
|
||||
title={more.time}
|
||||
>
|
||||
{more.time}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(More)
|
||||
284
dify/web/app/components/base/chat/chat/answer/operation.tsx
Normal file
284
dify/web/app/components/base/chat/chat/answer/operation.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiClipboardLine,
|
||||
RiResetLeftLine,
|
||||
RiThumbDownLine,
|
||||
RiThumbUpLine,
|
||||
} from '@remixicon/react'
|
||||
import type { ChatItem } from '../../types'
|
||||
import { useChatContext } from '../context'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button'
|
||||
import EditReplyModal from '@/app/components/app/annotation/edit-annotation-modal'
|
||||
import Log from '@/app/components/base/chat/chat/log'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperationProps = {
|
||||
item: ChatItem
|
||||
question: string
|
||||
index: number
|
||||
showPromptLog?: boolean
|
||||
maxSize: number
|
||||
contentWidth: number
|
||||
hasWorkflowProcess: boolean
|
||||
noChatInput?: boolean
|
||||
}
|
||||
|
||||
const Operation: FC<OperationProps> = ({
|
||||
item,
|
||||
question,
|
||||
index,
|
||||
showPromptLog,
|
||||
maxSize,
|
||||
contentWidth,
|
||||
hasWorkflowProcess,
|
||||
noChatInput,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
config,
|
||||
onAnnotationAdded,
|
||||
onAnnotationEdited,
|
||||
onAnnotationRemoved,
|
||||
onFeedback,
|
||||
onRegenerate,
|
||||
} = useChatContext()
|
||||
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
||||
const [isShowFeedbackModal, setIsShowFeedbackModal] = useState(false)
|
||||
const [feedbackContent, setFeedbackContent] = useState('')
|
||||
const {
|
||||
id,
|
||||
isOpeningStatement,
|
||||
content: messageContent,
|
||||
annotation,
|
||||
feedback,
|
||||
adminFeedback,
|
||||
agent_thoughts,
|
||||
} = item
|
||||
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback)
|
||||
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
|
||||
|
||||
// Separate feedback types for display
|
||||
const userFeedback = feedback
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (agent_thoughts?.length)
|
||||
return agent_thoughts.reduce((acc, cur) => acc + cur.thought, '')
|
||||
|
||||
return messageContent
|
||||
}, [agent_thoughts, messageContent])
|
||||
|
||||
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string) => {
|
||||
if (!config?.supportFeedback || !onFeedback)
|
||||
return
|
||||
|
||||
await onFeedback?.(id, { rating, content })
|
||||
setLocalFeedback({ rating })
|
||||
|
||||
// Update admin feedback state separately if annotation is supported
|
||||
if (config?.supportAnnotation)
|
||||
setAdminLocalFeedback(rating ? { rating } : undefined)
|
||||
}
|
||||
|
||||
const handleThumbsDown = () => {
|
||||
setIsShowFeedbackModal(true)
|
||||
}
|
||||
|
||||
const handleFeedbackSubmit = async () => {
|
||||
await handleFeedback('dislike', feedbackContent)
|
||||
setFeedbackContent('')
|
||||
setIsShowFeedbackModal(false)
|
||||
}
|
||||
|
||||
const handleFeedbackCancel = () => {
|
||||
setFeedbackContent('')
|
||||
setIsShowFeedbackModal(false)
|
||||
}
|
||||
|
||||
const operationWidth = useMemo(() => {
|
||||
let width = 0
|
||||
if (!isOpeningStatement)
|
||||
width += 26
|
||||
if (!isOpeningStatement && showPromptLog)
|
||||
width += 28 + 8
|
||||
if (!isOpeningStatement && config?.text_to_speech?.enabled)
|
||||
width += 26
|
||||
if (!isOpeningStatement && config?.supportAnnotation && config?.annotation_reply?.enabled)
|
||||
width += 26
|
||||
if (config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement)
|
||||
width += 60 + 8
|
||||
if (config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement)
|
||||
width += 28 + 8
|
||||
return width
|
||||
}, [isOpeningStatement, showPromptLog, config?.text_to_speech?.enabled, config?.supportAnnotation, config?.annotation_reply?.enabled, config?.supportFeedback, localFeedback?.rating, onFeedback])
|
||||
|
||||
const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute flex justify-end gap-1',
|
||||
hasWorkflowProcess && '-bottom-4 right-2',
|
||||
!positionRight && '-bottom-4 right-2',
|
||||
!hasWorkflowProcess && positionRight && '!top-[9px]',
|
||||
)}
|
||||
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
|
||||
>
|
||||
{showPromptLog && !isOpeningStatement && (
|
||||
<div className='hidden group-hover:block'>
|
||||
<Log logItem={item} />
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div className='ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex'>
|
||||
{(config?.text_to_speech?.enabled) && (
|
||||
<NewAudioButton
|
||||
id={id}
|
||||
value={content}
|
||||
voice={config?.text_to_speech?.voice}
|
||||
/>
|
||||
)}
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
|
||||
}}>
|
||||
<RiClipboardLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
{!noChatInput && (
|
||||
<ActionButton onClick={() => onRegenerate?.(item)}>
|
||||
<RiResetLeftLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
|
||||
<AnnotationCtrlButton
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
cached={!!annotation?.id}
|
||||
query={question}
|
||||
answer={content}
|
||||
onAdded={(id, authorName) => onAnnotationAdded?.(id, authorName, question, content, index)}
|
||||
onEdit={() => setIsShowReplyModal(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && config?.supportFeedback && !localFeedback?.rating && onFeedback && (
|
||||
<div className='ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex'>
|
||||
{!localFeedback?.rating && (
|
||||
<>
|
||||
<ActionButton onClick={() => handleFeedback('like')}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleThumbsDown}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isOpeningStatement && config?.supportFeedback && onFeedback && (
|
||||
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
|
||||
{/* User Feedback Display */}
|
||||
{userFeedback?.rating && (
|
||||
<div className='flex items-center'>
|
||||
<span className='mr-1 text-xs text-text-tertiary'>User</span>
|
||||
{userFeedback.rating === 'like' ? (
|
||||
<ActionButton state={ActionButtonState.Active} title={userFeedback.content ? `User liked this response: ${userFeedback.content}` : 'User liked this response'}>
|
||||
<RiThumbUpLine className='h-3 w-3' />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton state={ActionButtonState.Destructive} title={userFeedback.content ? `User disliked this response: ${userFeedback.content}` : 'User disliked this response'}>
|
||||
<RiThumbDownLine className='h-3 w-3' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin Feedback Controls */}
|
||||
{config?.supportAnnotation && (
|
||||
<div className='flex items-center'>
|
||||
{userFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
|
||||
{!adminLocalFeedback?.rating ? (
|
||||
<>
|
||||
<ActionButton onClick={() => handleFeedback('like')}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={handleThumbsDown}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{adminLocalFeedback.rating === 'like' ? (
|
||||
<ActionButton state={ActionButtonState.Active} onClick={() => handleFeedback(null)}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
) : (
|
||||
<ActionButton state={ActionButtonState.Destructive} onClick={() => handleFeedback(null)}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EditReplyModal
|
||||
isShow={isShowReplyModal}
|
||||
onHide={() => setIsShowReplyModal(false)}
|
||||
query={question}
|
||||
answer={content}
|
||||
onEdited={(editedQuery, editedAnswer) => onAnnotationEdited?.(editedQuery, editedAnswer, index)}
|
||||
onAdded={(annotationId, authorName, editedQuery, editedAnswer) => onAnnotationAdded?.(annotationId, authorName, editedQuery, editedAnswer, index)}
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
annotationId={annotation?.id || ''}
|
||||
createdAt={annotation?.created_at}
|
||||
onRemove={() => onAnnotationRemoved?.(index)}
|
||||
/>
|
||||
{isShowFeedbackModal && (
|
||||
<Modal
|
||||
title={t('common.feedback.title') || 'Provide Feedback'}
|
||||
subTitle={t('common.feedback.subtitle') || 'Please tell us what went wrong with this response'}
|
||||
onClose={handleFeedbackCancel}
|
||||
onConfirm={handleFeedbackSubmit}
|
||||
onCancel={handleFeedbackCancel}
|
||||
confirmButtonText={t('common.operation.submit') || 'Submit'}
|
||||
cancelButtonText={t('common.operation.cancel') || 'Cancel'}
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<label className='system-sm-semibold mb-2 block text-text-secondary'>
|
||||
{t('common.feedback.content') || 'Feedback Content'}
|
||||
</label>
|
||||
<Textarea
|
||||
value={feedbackContent}
|
||||
onChange={e => setFeedbackContent(e.target.value)}
|
||||
placeholder={t('common.feedback.placeholder') || 'Please describe what went wrong or how we can improve...'}
|
||||
rows={4}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Operation)
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import type { ChatItem } from '../../types'
|
||||
import { useChatContext } from '../context'
|
||||
|
||||
type SuggestedQuestionsProps = {
|
||||
item: ChatItem
|
||||
}
|
||||
const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
|
||||
item,
|
||||
}) => {
|
||||
const { onSend } = useChatContext()
|
||||
|
||||
const {
|
||||
isOpeningStatement,
|
||||
suggestedQuestions,
|
||||
} = item
|
||||
|
||||
if (!isOpeningStatement || !suggestedQuestions?.length)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='flex flex-wrap'>
|
||||
{suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover'
|
||||
onClick={() => onSend?.(question)}
|
||||
>
|
||||
{question}
|
||||
</div>),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SuggestedQuestions)
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiHammerFill,
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import type { ToolInfoInThought } from '../type'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ToolDetailProps = {
|
||||
payload: ToolInfoInThought
|
||||
}
|
||||
const ToolDetail = ({
|
||||
payload,
|
||||
}: ToolDetailProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { name, label, input, isFinished, output } = payload
|
||||
const toolLabel = name.startsWith('dataset_') ? t('dataset.knowledge') : label
|
||||
const [expand, setExpand] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl',
|
||||
!expand && 'border-l-[0.25px] border-components-panel-border bg-workflow-process-bg',
|
||||
expand && 'border-[0.5px] border-components-panel-border-subtle bg-background-section-burn',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-medium flex cursor-pointer items-center px-2.5 py-2 text-text-tertiary',
|
||||
expand && 'pb-1.5',
|
||||
)}
|
||||
onClick={() => setExpand(!expand)}
|
||||
>
|
||||
{isFinished && <RiHammerFill className='mr-1 h-3.5 w-3.5' />}
|
||||
{!isFinished && <RiLoader2Line className='mr-1 h-3.5 w-3.5 animate-spin' />}
|
||||
{t(`tools.thought.${isFinished ? 'used' : 'using'}`)}
|
||||
<div className='mx-1 text-text-secondary'>{toolLabel}</div>
|
||||
{!expand && <RiArrowRightSLine className='h-4 w-4' />}
|
||||
{expand && <RiArrowDownSLine className='ml-auto h-4 w-4' />}
|
||||
</div>
|
||||
{
|
||||
expand && (
|
||||
<>
|
||||
<div className='mx-1 mb-0.5 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
|
||||
<div className='system-xs-semibold-uppercase flex h-7 items-center justify-between px-2 pt-1'>
|
||||
{t('tools.thought.requestTitle')}
|
||||
</div>
|
||||
<div className='code-xs-regular break-words px-3 pb-2 pt-1'>
|
||||
{input}
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-1 mb-1 rounded-[10px] bg-components-panel-on-panel-item-bg text-text-secondary'>
|
||||
<div className='system-xs-semibold-uppercase flex h-7 items-center justify-between px-2 pt-1'>
|
||||
{t('tools.thought.responseTitle')}
|
||||
</div>
|
||||
<div className='code-xs-regular break-words px-3 pb-2 pt-1'>
|
||||
{output}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolDetail
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiArrowRightSLine,
|
||||
RiErrorWarningFill,
|
||||
RiLoader2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ChatItem, WorkflowProcess } from '../../types'
|
||||
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
|
||||
import cn from '@/utils/classnames'
|
||||
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
|
||||
type WorkflowProcessProps = {
|
||||
data: WorkflowProcess
|
||||
item?: ChatItem
|
||||
expand?: boolean
|
||||
hideInfo?: boolean
|
||||
hideProcessDetail?: boolean
|
||||
readonly?: boolean
|
||||
}
|
||||
const WorkflowProcessItem = ({
|
||||
data,
|
||||
expand = false,
|
||||
hideInfo = false,
|
||||
hideProcessDetail = false,
|
||||
readonly = false,
|
||||
}: WorkflowProcessProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [collapse, setCollapse] = useState(!expand)
|
||||
const running = data.status === WorkflowRunningStatus.Running
|
||||
const succeeded = data.status === WorkflowRunningStatus.Succeeded
|
||||
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
|
||||
|
||||
useEffect(() => {
|
||||
setCollapse(!expand)
|
||||
}, [expand])
|
||||
|
||||
if (readonly) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'-mx-1 rounded-xl px-2.5',
|
||||
collapse ? 'border-l-[0.25px] border-components-panel-border py-[7px]' : 'border-[0.5px] border-components-panel-border-subtle px-1 pb-1 pt-[7px]',
|
||||
running && !collapse && 'bg-background-section-burn',
|
||||
succeeded && !collapse && 'bg-state-success-hover',
|
||||
failed && !collapse && 'bg-state-destructive-hover',
|
||||
collapse && 'bg-workflow-process-bg',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('flex cursor-pointer items-center', !collapse && 'px-1.5')}
|
||||
onClick={() => setCollapse(!collapse)}
|
||||
>
|
||||
{
|
||||
running && (
|
||||
<RiLoader2Line className='mr-1 h-3.5 w-3.5 shrink-0 animate-spin text-text-tertiary' />
|
||||
)
|
||||
}
|
||||
{
|
||||
succeeded && (
|
||||
<CheckCircle className='mr-1 h-3.5 w-3.5 shrink-0 text-text-success' />
|
||||
)
|
||||
}
|
||||
{
|
||||
failed && (
|
||||
<RiErrorWarningFill className='mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive' />
|
||||
)
|
||||
}
|
||||
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
|
||||
{t('workflow.common.workflowProcess')}
|
||||
</div>
|
||||
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
</div>
|
||||
{
|
||||
!collapse && (
|
||||
<div className='mt-1.5'>
|
||||
{
|
||||
<TracingPanel
|
||||
list={data.tracing}
|
||||
hideNodeInfo={hideInfo}
|
||||
hideNodeProcessDetail={hideProcessDetail}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorkflowProcessItem
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
export const useTextAreaHeight = () => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | undefined>(undefined)
|
||||
const textValueRef = useRef<HTMLDivElement>(null)
|
||||
const holdSpaceRef = useRef<HTMLDivElement>(null)
|
||||
const [isMultipleLine, setIsMultipleLine] = useState(false)
|
||||
|
||||
const handleComputeHeight = useCallback(() => {
|
||||
const textareaElement = textareaRef.current
|
||||
|
||||
if (wrapperRef.current && textareaElement && textValueRef.current && holdSpaceRef.current) {
|
||||
const { width: wrapperWidth } = wrapperRef.current.getBoundingClientRect()
|
||||
const { height: textareaHeight } = textareaElement.getBoundingClientRect()
|
||||
const { width: textValueWidth } = textValueRef.current.getBoundingClientRect()
|
||||
const { width: holdSpaceWidth } = holdSpaceRef.current.getBoundingClientRect()
|
||||
if (textareaHeight > 32) {
|
||||
setIsMultipleLine(true)
|
||||
}
|
||||
else {
|
||||
if (textValueWidth + holdSpaceWidth >= wrapperWidth)
|
||||
setIsMultipleLine(true)
|
||||
else
|
||||
setIsMultipleLine(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTextareaResize = useCallback(() => {
|
||||
handleComputeHeight()
|
||||
}, [handleComputeHeight])
|
||||
|
||||
return {
|
||||
wrapperRef,
|
||||
textareaRef,
|
||||
textValueRef,
|
||||
holdSpaceRef,
|
||||
handleTextareaResize,
|
||||
isMultipleLine,
|
||||
}
|
||||
}
|
||||
254
dify/web/app/components/base/chat/chat/chat-input-area/index.tsx
Normal file
254
dify/web/app/components/base/chat/chat/chat-input-area/index.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Recorder from 'js-audio-recorder'
|
||||
import { decode } from 'html-entities'
|
||||
import type {
|
||||
EnableType,
|
||||
OnSend,
|
||||
} from '../../types'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import type { InputForm } from '../type'
|
||||
import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
import { useTextAreaHeight } from './hooks'
|
||||
import Operation from './operation'
|
||||
import cn from '@/utils/classnames'
|
||||
import { FileListInChatInput } from '@/app/components/base/file-uploader'
|
||||
import { useFile } from '@/app/components/base/file-uploader/hooks'
|
||||
import {
|
||||
FileContextProvider,
|
||||
useFileStore,
|
||||
} from '@/app/components/base/file-uploader/store'
|
||||
import VoiceInput from '@/app/components/base/voice-input'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
botName?: string
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
featureBarDisabled?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
visionConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputsForm?: InputForm[]
|
||||
theme?: Theme | null
|
||||
isResponding?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
const ChatInputArea = ({
|
||||
botName,
|
||||
showFeatureBar,
|
||||
showFileUpload,
|
||||
featureBarDisabled,
|
||||
onFeatureBarClick,
|
||||
visionConfig,
|
||||
speechToTextConfig = { enabled: true },
|
||||
onSend,
|
||||
inputs = {},
|
||||
inputsForm = [],
|
||||
theme,
|
||||
isResponding,
|
||||
disabled,
|
||||
}: ChatInputAreaProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const {
|
||||
wrapperRef,
|
||||
textareaRef,
|
||||
textValueRef,
|
||||
holdSpaceRef,
|
||||
handleTextareaResize,
|
||||
isMultipleLine,
|
||||
} = useTextAreaHeight()
|
||||
const [query, setQuery] = useState('')
|
||||
const [showVoiceInput, setShowVoiceInput] = useState(false)
|
||||
const filesStore = useFileStore()
|
||||
const {
|
||||
handleDragFileEnter,
|
||||
handleDragFileLeave,
|
||||
handleDragFileOver,
|
||||
handleDropFile,
|
||||
handleClipboardPasteFile,
|
||||
isDragActive,
|
||||
} = useFile(visionConfig!)
|
||||
const { checkInputsForm } = useCheckInputsForms()
|
||||
const historyRef = useRef([''])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const isComposingRef = useRef(false)
|
||||
|
||||
const handleQueryChange = useCallback(
|
||||
(value: string) => {
|
||||
setQuery(value)
|
||||
setTimeout(handleTextareaResize, 0)
|
||||
},
|
||||
[handleTextareaResize],
|
||||
)
|
||||
|
||||
const handleSend = () => {
|
||||
if (isResponding) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
|
||||
return
|
||||
}
|
||||
|
||||
if (onSend) {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
if (!query || !query.trim()) {
|
||||
notify({ type: 'info', message: t('appAnnotation.errorMessage.queryRequired') })
|
||||
return
|
||||
}
|
||||
if (checkInputsForm(inputs, inputsForm)) {
|
||||
onSend(query, files)
|
||||
handleQueryChange('')
|
||||
setFiles([])
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleCompositionStart = () => {
|
||||
// e: React.CompositionEvent<HTMLTextAreaElement>
|
||||
isComposingRef.current = true
|
||||
}
|
||||
const handleCompositionEnd = () => {
|
||||
// safari or some browsers will trigger compositionend before keydown.
|
||||
// delay 50ms for safari.
|
||||
setTimeout(() => {
|
||||
isComposingRef.current = false
|
||||
}, 50)
|
||||
}
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
// if isComposing, exit
|
||||
if (isComposingRef.current) return
|
||||
e.preventDefault()
|
||||
setQuery(query.replace(/\n$/, ''))
|
||||
historyRef.current.push(query)
|
||||
setCurrentIndex(historyRef.current.length)
|
||||
handleSend()
|
||||
}
|
||||
else if (e.key === 'ArrowUp' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) {
|
||||
// When the cmd + up key is pressed, output the previous element
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
handleQueryChange(historyRef.current[currentIndex - 1])
|
||||
}
|
||||
}
|
||||
else if (e.key === 'ArrowDown' && !e.shiftKey && !e.nativeEvent.isComposing && e.metaKey) {
|
||||
// When the cmd + down key is pressed, output the next element
|
||||
if (currentIndex < historyRef.current.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
handleQueryChange(historyRef.current[currentIndex + 1])
|
||||
}
|
||||
else if (currentIndex === historyRef.current.length - 1) {
|
||||
// If it is the last element, clear the input box
|
||||
setCurrentIndex(historyRef.current.length)
|
||||
handleQueryChange('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowVoiceInput = useCallback(() => {
|
||||
(Recorder as any).getPermission().then(() => {
|
||||
setShowVoiceInput(true)
|
||||
}, () => {
|
||||
notify({ type: 'error', message: t('common.voiceInput.notAllow') })
|
||||
})
|
||||
}, [t, notify])
|
||||
|
||||
const operation = (
|
||||
<Operation
|
||||
ref={holdSpaceRef}
|
||||
fileConfig={visionConfig}
|
||||
speechToTextConfig={speechToTextConfig}
|
||||
onShowVoiceInput={handleShowVoiceInput}
|
||||
onSend={handleSend}
|
||||
theme={theme}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 overflow-hidden rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md',
|
||||
isDragActive && 'border border-dashed border-components-option-card-option-selected-border',
|
||||
disabled && 'pointer-events-none border-components-panel-border opacity-50 shadow-none',
|
||||
)}
|
||||
>
|
||||
<div className='relative max-h-[158px] overflow-y-auto overflow-x-hidden px-[9px] pt-[9px]'>
|
||||
<FileListInChatInput fileConfig={visionConfig!} />
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<div className='relative flex w-full grow items-center'>
|
||||
<div
|
||||
ref={textValueRef}
|
||||
className='body-lg-regular pointer-events-none invisible absolute h-auto w-auto whitespace-pre p-1 leading-6'
|
||||
>
|
||||
{query}
|
||||
</div>
|
||||
<Textarea
|
||||
ref={ref => textareaRef.current = ref as any}
|
||||
className={cn(
|
||||
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none',
|
||||
)}
|
||||
placeholder={decode(t('common.chat.inputPlaceholder', { botName }) || '')}
|
||||
autoFocus
|
||||
minRows={1}
|
||||
value={query}
|
||||
onChange={e => handleQueryChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onPaste={handleClipboardPasteFile}
|
||||
onDragEnter={handleDragFileEnter}
|
||||
onDragLeave={handleDragFileLeave}
|
||||
onDragOver={handleDragFileOver}
|
||||
onDrop={handleDropFile}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
!isMultipleLine && operation
|
||||
}
|
||||
</div>
|
||||
{
|
||||
showVoiceInput && (
|
||||
<VoiceInput
|
||||
onCancel={() => setShowVoiceInput(false)}
|
||||
onConverted={text => handleQueryChange(text)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isMultipleLine && (
|
||||
<div className='px-[9px]'>{operation}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{showFeatureBar && <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={onFeatureBarClick} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ChatInputAreaWrapper = (props: ChatInputAreaProps) => {
|
||||
return (
|
||||
<FileContextProvider>
|
||||
<ChatInputArea {...props} />
|
||||
</FileContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInputAreaWrapper
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { FC, Ref } from 'react'
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
RiMicLine,
|
||||
RiSendPlane2Fill,
|
||||
} from '@remixicon/react'
|
||||
import type {
|
||||
EnableType,
|
||||
} from '../../types'
|
||||
import type { Theme } from '../../embedded-chatbot/theme/theme-context'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperationProps = {
|
||||
fileConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onShowVoiceInput?: () => void
|
||||
onSend: () => void
|
||||
theme?: Theme | null,
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
const Operation: FC<OperationProps> = ({
|
||||
ref,
|
||||
fileConfig,
|
||||
speechToTextConfig,
|
||||
onShowVoiceInput,
|
||||
onSend,
|
||||
theme,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-end',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex items-center pl-1'
|
||||
ref={ref}
|
||||
>
|
||||
<div className='flex items-center space-x-1'>
|
||||
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />}
|
||||
{
|
||||
speechToTextConfig?.enabled && (
|
||||
<ActionButton
|
||||
size='l'
|
||||
onClick={onShowVoiceInput}
|
||||
>
|
||||
<RiMicLine className='h-5 w-5' />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
className='ml-3 w-8 px-0'
|
||||
variant='primary'
|
||||
onClick={onSend}
|
||||
style={
|
||||
theme
|
||||
? {
|
||||
backgroundColor: theme.primaryColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<RiSendPlane2Fill className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Operation.displayName = 'Operation'
|
||||
|
||||
export default memo(Operation)
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { InputForm } from './type'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
export const useCheckInputsForms = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const checkInputsForm = useCallback((inputs: Record<string, any>, inputsForm: InputForm[]) => {
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForm.filter(({ required, type }) => required && type !== InputVarType.checkbox) // boolean can be not checked
|
||||
|
||||
if (requiredVars?.length) {
|
||||
requiredVars.forEach(({ variable, label, type }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
if (fileIsUploading)
|
||||
return
|
||||
|
||||
if (!inputs[variable])
|
||||
hasEmptyInput = label as string
|
||||
|
||||
if ((type === InputVarType.singleFile || type === InputVarType.multiFiles) && inputs[variable]) {
|
||||
const files = inputs[variable]
|
||||
if (Array.isArray(files))
|
||||
fileIsUploading = files.find(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)
|
||||
else
|
||||
fileIsUploading = files.transferMethod === TransferMethod.local_file && !files.uploadedId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEmptyInput) {
|
||||
notify({ type: 'error', message: t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }) })
|
||||
return false
|
||||
}
|
||||
|
||||
if (fileIsUploading) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
}, [notify, t])
|
||||
|
||||
return {
|
||||
checkInputsForm,
|
||||
}
|
||||
}
|
||||
125
dify/web/app/components/base/chat/chat/citation/index.tsx
Normal file
125
dify/web/app/components/base/chat/chat/citation/index.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import type { CitationItem } from '../type'
|
||||
import Popup from './popup'
|
||||
|
||||
export type Resources = {
|
||||
documentId: string
|
||||
documentName: string
|
||||
dataSourceType: string
|
||||
sources: CitationItem[]
|
||||
}
|
||||
|
||||
type CitationProps = {
|
||||
data: CitationItem[]
|
||||
showHitInfo?: boolean
|
||||
containerClassName?: string
|
||||
}
|
||||
const Citation: FC<CitationProps> = ({
|
||||
data,
|
||||
showHitInfo,
|
||||
containerClassName = 'chat-answer-container',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const elesRef = useRef<HTMLDivElement[]>([])
|
||||
const [limitNumberInOneLine, setLimitNumberInOneLine] = useState(0)
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const resources = useMemo(() => data.reduce((prev: Resources[], next) => {
|
||||
const documentId = next.document_id
|
||||
const documentName = next.document_name
|
||||
const dataSourceType = next.data_source_type
|
||||
const documentIndex = prev.findIndex(i => i.documentId === documentId)
|
||||
|
||||
if (documentIndex > -1) {
|
||||
prev[documentIndex].sources.push(next)
|
||||
}
|
||||
else {
|
||||
prev.push({
|
||||
documentId,
|
||||
documentName,
|
||||
dataSourceType,
|
||||
sources: [next],
|
||||
})
|
||||
}
|
||||
|
||||
return prev
|
||||
}, []), [data])
|
||||
|
||||
const handleAdjustResourcesLayout = () => {
|
||||
const containerWidth = document.querySelector(`.${containerClassName}`)!.clientWidth - 40
|
||||
let totalWidth = 0
|
||||
for (let i = 0; i < resources.length; i++) {
|
||||
totalWidth += elesRef.current[i].clientWidth
|
||||
|
||||
if (totalWidth + i * 4 > containerWidth!) {
|
||||
totalWidth -= elesRef.current[i].clientWidth
|
||||
|
||||
if (totalWidth + 34 > containerWidth!)
|
||||
setLimitNumberInOneLine(i - 1)
|
||||
else
|
||||
setLimitNumberInOneLine(i)
|
||||
|
||||
break
|
||||
}
|
||||
else {
|
||||
setLimitNumberInOneLine(i + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
handleAdjustResourcesLayout()
|
||||
}, [])
|
||||
|
||||
const resourcesLength = resources.length
|
||||
|
||||
return (
|
||||
<div className='-mb-1 mt-3'>
|
||||
<div className='system-xs-medium mb-2 flex items-center text-text-tertiary'>
|
||||
{t('common.chat.citation.title')}
|
||||
<div className='ml-2 h-px grow bg-divider-regular' />
|
||||
</div>
|
||||
<div className='relative flex flex-wrap'>
|
||||
{
|
||||
resources.map((res, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='absolute left-0 top-0 -z-10 mb-1 mr-1 h-7 w-auto max-w-[240px] whitespace-nowrap pl-7 pr-2 text-xs opacity-0'
|
||||
ref={(ele: any) => (elesRef.current[index] = ele!)}
|
||||
>
|
||||
{res.documentName}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{
|
||||
resources.slice(0, showMore ? resourcesLength : limitNumberInOneLine).map((res, index) => (
|
||||
<div key={index} className='mb-1 mr-1 cursor-pointer'>
|
||||
<Popup
|
||||
data={res}
|
||||
showHitInfo={showHitInfo}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{
|
||||
limitNumberInOneLine < resourcesLength && (
|
||||
<div
|
||||
className='system-xs-medium flex h-7 cursor-pointer items-center rounded-lg bg-components-panel-bg px-2 text-text-tertiary'
|
||||
onClick={() => setShowMore(v => !v)}
|
||||
>
|
||||
{
|
||||
!showMore
|
||||
? `+ ${resourcesLength - limitNumberInOneLine}`
|
||||
: <RiArrowDownSLine className='h-4 w-4 rotate-180 text-text-tertiary' />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Citation
|
||||
131
dify/web/app/components/base/chat/chat/citation/popup.tsx
Normal file
131
dify/web/app/components/base/chat/chat/citation/popup.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Fragment, useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from './tooltip'
|
||||
import ProgressTooltip from './progress-tooltip'
|
||||
import type { Resources } from './index'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import FileIcon from '@/app/components/base/file-icon'
|
||||
import {
|
||||
Hash02,
|
||||
Target04,
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import {
|
||||
BezierCurve03,
|
||||
TypeSquare,
|
||||
} from '@/app/components/base/icons/src/vender/line/editor'
|
||||
|
||||
type PopupProps = {
|
||||
data: Resources
|
||||
showHitInfo?: boolean
|
||||
}
|
||||
|
||||
const Popup: FC<PopupProps> = ({
|
||||
data,
|
||||
showHitInfo = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const fileType = data.dataSourceType !== 'notion'
|
||||
? (/\.([^.]*)$/g.exec(data.documentName)?.[1] || '')
|
||||
: 'notion'
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='top-start'
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className='flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2'>
|
||||
<FileIcon type={fileType} className='mr-1 h-4 w-4 shrink-0' />
|
||||
<div className='truncate text-xs text-text-tertiary'>{data.documentName}</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<div className='max-w-[360px] rounded-xl bg-background-section-burn shadow-lg'>
|
||||
<div className='px-4 pb-2 pt-3'>
|
||||
<div className='flex h-[18px] items-center'>
|
||||
<FileIcon type={fileType} className='mr-1 h-4 w-4 shrink-0' />
|
||||
<div className='system-xs-medium truncate text-text-tertiary'>{data.documentName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5'>
|
||||
<div className='w-full'>
|
||||
{
|
||||
data.sources.map((source, index) => (
|
||||
<Fragment key={index}>
|
||||
<div className='group py-3'>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<div className='flex h-5 items-center rounded-md border border-divider-subtle px-1.5'>
|
||||
<Hash02 className='mr-0.5 h-3 w-3 text-text-quaternary' />
|
||||
<div className='text-[11px] font-medium text-text-tertiary'>
|
||||
{source.segment_position || index + 1}
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
showHitInfo && (
|
||||
<Link
|
||||
href={`/datasets/${source.dataset_id}/documents/${source.document_id}`}
|
||||
className='hidden h-[18px] items-center text-xs text-text-accent group-hover:flex'>
|
||||
{t('common.chat.citation.linkToDataset')}
|
||||
<ArrowUpRight className='ml-1 h-3 w-3' />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='break-words text-[13px] text-text-secondary'>{source.content}</div>
|
||||
{
|
||||
showHitInfo && (
|
||||
<div className='system-xs-medium mt-2 flex flex-wrap items-center text-text-quaternary'>
|
||||
<Tooltip
|
||||
text={t('common.chat.citation.characters')}
|
||||
data={source.word_count}
|
||||
icon={<TypeSquare className='mr-1 h-3 w-3' />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('common.chat.citation.hitCount')}
|
||||
data={source.hit_count}
|
||||
icon={<Target04 className='mr-1 h-3 w-3' />}
|
||||
/>
|
||||
<Tooltip
|
||||
text={t('common.chat.citation.vectorHash')}
|
||||
data={source.index_node_hash?.substring(0, 7)}
|
||||
icon={<BezierCurve03 className='mr-1 h-3 w-3' />}
|
||||
/>
|
||||
{
|
||||
source.score && (
|
||||
<ProgressTooltip data={Number(source.score.toFixed(2))} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{
|
||||
index !== data.sources.length - 1 && (
|
||||
<div className='my-1 h-px bg-divider-regular' />
|
||||
)
|
||||
}
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Popup
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type ProgressTooltipProps = {
|
||||
data: number
|
||||
}
|
||||
|
||||
const ProgressTooltip: FC<ProgressTooltipProps> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='top-start'
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className='flex grow items-center'>
|
||||
<div className='mr-1 h-1.5 w-16 overflow-hidden rounded-[3px] border border-components-progress-gray-border'>
|
||||
<div className='h-full bg-components-progress-gray-progress' style={{ width: `${data * 100}%` }}></div>
|
||||
</div>
|
||||
{data}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
|
||||
<div className='system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg'>
|
||||
{t('common.chat.citation.hitScore')} {data}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgressTooltip
|
||||
46
dify/web/app/components/base/chat/chat/citation/tooltip.tsx
Normal file
46
dify/web/app/components/base/chat/chat/citation/tooltip.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
type TooltipProps = {
|
||||
data: number | string
|
||||
text: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
const Tooltip: FC<TooltipProps> = ({
|
||||
data,
|
||||
text,
|
||||
icon,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='top-start'
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className='mr-6 flex items-center'>
|
||||
{icon}
|
||||
{data}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1001 }}>
|
||||
<div className='system-xs-medium rounded-lg bg-components-tooltip-bg p-3 text-text-quaternary shadow-lg'>
|
||||
{text} {data}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
39
dify/web/app/components/base/chat/chat/content-switch.tsx
Normal file
39
dify/web/app/components/base/chat/chat/content-switch.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ChevronRight } from '../../icons/src/vender/line/arrows'
|
||||
|
||||
export default function ContentSwitch({
|
||||
count,
|
||||
currentIndex,
|
||||
prevDisabled,
|
||||
nextDisabled,
|
||||
switchSibling,
|
||||
}: {
|
||||
count?: number
|
||||
currentIndex?: number
|
||||
prevDisabled: boolean
|
||||
nextDisabled: boolean
|
||||
switchSibling: (direction: 'prev' | 'next') => void
|
||||
}) {
|
||||
return (
|
||||
count && count > 1 && currentIndex !== undefined && (
|
||||
<div className="flex items-center justify-center pt-3.5 text-sm">
|
||||
<button type="button"
|
||||
className={`${prevDisabled ? 'opacity-30' : 'opacity-100'}`}
|
||||
disabled={prevDisabled}
|
||||
onClick={() => !prevDisabled && switchSibling('prev')}
|
||||
>
|
||||
<ChevronRight className="h-[14px] w-[14px] rotate-180 text-text-primary" />
|
||||
</button>
|
||||
<span className="px-2 text-xs text-text-primary">
|
||||
{currentIndex + 1} / {count}
|
||||
</span>
|
||||
<button type="button"
|
||||
className={`${nextDisabled ? 'opacity-30' : 'opacity-100'}`}
|
||||
disabled={nextDisabled}
|
||||
onClick={() => !nextDisabled && switchSibling('next')}
|
||||
>
|
||||
<ChevronRight className="h-[14px] w-[14px] text-text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
66
dify/web/app/components/base/chat/chat/context.tsx
Normal file
66
dify/web/app/components/base/chat/chat/context.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { createContext, useContext } from 'use-context-selector'
|
||||
import type { ChatProps } from './index'
|
||||
|
||||
export type ChatContextValue = Pick<ChatProps, 'config'
|
||||
| 'isResponding'
|
||||
| 'chatList'
|
||||
| 'showPromptLog'
|
||||
| 'questionIcon'
|
||||
| 'answerIcon'
|
||||
| 'onSend'
|
||||
| 'onRegenerate'
|
||||
| 'onAnnotationEdited'
|
||||
| 'onAnnotationAdded'
|
||||
| 'onAnnotationRemoved'
|
||||
| 'onFeedback'
|
||||
>
|
||||
|
||||
const ChatContext = createContext<ChatContextValue>({
|
||||
chatList: [],
|
||||
})
|
||||
|
||||
type ChatContextProviderProps = {
|
||||
children: ReactNode
|
||||
} & ChatContextValue
|
||||
|
||||
export const ChatContextProvider = ({
|
||||
children,
|
||||
config,
|
||||
isResponding,
|
||||
chatList,
|
||||
showPromptLog,
|
||||
questionIcon,
|
||||
answerIcon,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
onAnnotationEdited,
|
||||
onAnnotationAdded,
|
||||
onAnnotationRemoved,
|
||||
onFeedback,
|
||||
}: ChatContextProviderProps) => {
|
||||
return (
|
||||
<ChatContext.Provider value={{
|
||||
config,
|
||||
isResponding,
|
||||
chatList: chatList || [],
|
||||
showPromptLog,
|
||||
questionIcon,
|
||||
answerIcon,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
onAnnotationEdited,
|
||||
onAnnotationAdded,
|
||||
onAnnotationRemoved,
|
||||
onFeedback,
|
||||
}}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useChatContext = () => useContext(ChatContext)
|
||||
|
||||
export default ChatContext
|
||||
724
dify/web/app/components/base/chat/chat/hooks.ts
Normal file
724
dify/web/app/components/base/chat/chat/hooks.ts
Normal file
@@ -0,0 +1,724 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { produce, setAutoFreeze } from 'immer'
|
||||
import { uniqBy } from 'lodash-es'
|
||||
import { useParams, usePathname } from 'next/navigation'
|
||||
import { v4 as uuidV4 } from 'uuid'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
ChatItemInTree,
|
||||
Inputs,
|
||||
} from '../types'
|
||||
import { getThreadMessages } from '../utils'
|
||||
import type { InputForm } from './type'
|
||||
import {
|
||||
getProcessedInputs,
|
||||
processOpeningStatement,
|
||||
} from './utils'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { ssePost } from '@/service/base'
|
||||
import type { Annotation } from '@/models/log'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import {
|
||||
getProcessedFiles,
|
||||
getProcessedFilesFromResponse,
|
||||
} from '@/app/components/base/file-uploader/utils'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type GetAbortController = (abortController: AbortController) => void
|
||||
type SendCallback = {
|
||||
onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
|
||||
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
export const useChat = (
|
||||
config?: ChatConfig,
|
||||
formSettings?: {
|
||||
inputs: Inputs
|
||||
inputsForm: InputForm[]
|
||||
},
|
||||
prevChatTree?: ChatItemInTree[],
|
||||
stopChat?: (taskId: string) => void,
|
||||
clearChatList?: boolean,
|
||||
clearChatListCallback?: (state: boolean) => void,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { notify } = useToastContext()
|
||||
const conversationId = useRef('')
|
||||
const hasStopResponded = useRef(false)
|
||||
const [isResponding, setIsResponding] = useState(false)
|
||||
const isRespondingRef = useRef(false)
|
||||
const taskIdRef = useRef('')
|
||||
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
|
||||
const conversationMessagesAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
|
||||
const params = useParams()
|
||||
const pathname = usePathname()
|
||||
|
||||
const [chatTree, setChatTree] = useState<ChatItemInTree[]>(prevChatTree || [])
|
||||
const chatTreeRef = useRef<ChatItemInTree[]>(chatTree)
|
||||
const [targetMessageId, setTargetMessageId] = useState<string>()
|
||||
const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId])
|
||||
|
||||
const getIntroduction = useCallback((str: string) => {
|
||||
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
|
||||
}, [formSettings?.inputs, formSettings?.inputsForm])
|
||||
|
||||
/** Final chat list that will be rendered */
|
||||
const chatList = useMemo(() => {
|
||||
const ret = [...threadMessages]
|
||||
if (config?.opening_statement) {
|
||||
const index = threadMessages.findIndex(item => item.isOpeningStatement)
|
||||
if (index > -1) {
|
||||
ret[index] = {
|
||||
...ret[index],
|
||||
content: getIntroduction(config.opening_statement),
|
||||
suggestedQuestions: config.suggested_questions?.map(item => getIntroduction(item)),
|
||||
}
|
||||
}
|
||||
else {
|
||||
ret.unshift({
|
||||
id: 'opening-statement',
|
||||
content: getIntroduction(config.opening_statement),
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: config.suggested_questions?.map(item => getIntroduction(item)),
|
||||
})
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
|
||||
|
||||
useEffect(() => {
|
||||
setAutoFreeze(false)
|
||||
return () => {
|
||||
setAutoFreeze(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** Find the target node by bfs and then operate on it */
|
||||
const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => {
|
||||
return produce(chatTreeRef.current, (draft) => {
|
||||
const queue: ChatItemInTree[] = [...draft]
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
if (current.id === targetId) {
|
||||
operation(current)
|
||||
break
|
||||
}
|
||||
if (current.children)
|
||||
queue.push(...current.children)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
type UpdateChatTreeNode = {
|
||||
(id: string, fields: Partial<ChatItemInTree>): void
|
||||
(id: string, update: (node: ChatItemInTree) => void): void
|
||||
}
|
||||
|
||||
const updateChatTreeNode: UpdateChatTreeNode = useCallback((
|
||||
id: string,
|
||||
fieldsOrUpdate: Partial<ChatItemInTree> | ((node: ChatItemInTree) => void),
|
||||
) => {
|
||||
const nextState = produceChatTreeNode(id, (node) => {
|
||||
if (typeof fieldsOrUpdate === 'function') {
|
||||
fieldsOrUpdate(node)
|
||||
}
|
||||
else {
|
||||
Object.keys(fieldsOrUpdate).forEach((key) => {
|
||||
(node as any)[key] = (fieldsOrUpdate as any)[key]
|
||||
})
|
||||
}
|
||||
})
|
||||
setChatTree(nextState)
|
||||
chatTreeRef.current = nextState
|
||||
}, [produceChatTreeNode])
|
||||
|
||||
const handleResponding = useCallback((isResponding: boolean) => {
|
||||
setIsResponding(isResponding)
|
||||
isRespondingRef.current = isResponding
|
||||
}, [])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
hasStopResponded.current = true
|
||||
handleResponding(false)
|
||||
if (stopChat && taskIdRef.current)
|
||||
stopChat(taskIdRef.current)
|
||||
if (conversationMessagesAbortControllerRef.current)
|
||||
conversationMessagesAbortControllerRef.current.abort()
|
||||
if (suggestedQuestionsAbortControllerRef.current)
|
||||
suggestedQuestionsAbortControllerRef.current.abort()
|
||||
}, [stopChat, handleResponding])
|
||||
|
||||
const handleRestart = useCallback((cb?: any) => {
|
||||
conversationId.current = ''
|
||||
taskIdRef.current = ''
|
||||
handleStop()
|
||||
setChatTree([])
|
||||
setSuggestQuestions([])
|
||||
cb?.()
|
||||
}, [handleStop])
|
||||
|
||||
const updateCurrentQAOnTree = useCallback(({
|
||||
parentId,
|
||||
responseItem,
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
}: {
|
||||
parentId?: string
|
||||
responseItem: ChatItem
|
||||
placeholderQuestionId: string
|
||||
questionItem: ChatItem
|
||||
}) => {
|
||||
let nextState: ChatItemInTree[]
|
||||
const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] }
|
||||
if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) {
|
||||
// QA whose parent is not provided is considered as a first message of the conversation,
|
||||
// and it should be a root node of the chat tree
|
||||
nextState = produce(chatTree, (draft) => {
|
||||
draft.push(currentQA)
|
||||
})
|
||||
}
|
||||
else {
|
||||
// find the target QA in the tree and update it; if not found, insert it to its parent node
|
||||
nextState = produceChatTreeNode(parentId!, (parentNode) => {
|
||||
const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id))
|
||||
if (questionNodeIndex === -1)
|
||||
parentNode.children!.push(currentQA)
|
||||
else
|
||||
parentNode.children![questionNodeIndex] = currentQA
|
||||
})
|
||||
}
|
||||
setChatTree(nextState)
|
||||
chatTreeRef.current = nextState
|
||||
}, [chatTree, produceChatTreeNode])
|
||||
|
||||
const handleSend = useCallback(async (
|
||||
url: string,
|
||||
data: {
|
||||
query: string
|
||||
files?: FileEntity[]
|
||||
parent_message_id?: string
|
||||
[key: string]: any
|
||||
},
|
||||
{
|
||||
onGetConversationMessages,
|
||||
onGetSuggestedQuestions,
|
||||
onConversationComplete,
|
||||
isPublicAPI,
|
||||
}: SendCallback,
|
||||
) => {
|
||||
setSuggestQuestions([])
|
||||
|
||||
if (isRespondingRef.current) {
|
||||
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
|
||||
return false
|
||||
}
|
||||
|
||||
const parentMessage = threadMessages.find(item => item.id === data.parent_message_id)
|
||||
|
||||
const placeholderQuestionId = `question-${Date.now()}`
|
||||
const questionItem = {
|
||||
id: placeholderQuestionId,
|
||||
content: data.query,
|
||||
isAnswer: false,
|
||||
message_files: data.files,
|
||||
parentMessageId: data.parent_message_id,
|
||||
}
|
||||
|
||||
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
|
||||
const placeholderAnswerItem = {
|
||||
id: placeholderAnswerId,
|
||||
content: '',
|
||||
isAnswer: true,
|
||||
parentMessageId: questionItem.id,
|
||||
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
|
||||
}
|
||||
|
||||
setTargetMessageId(parentMessage?.id)
|
||||
updateCurrentQAOnTree({
|
||||
parentId: data.parent_message_id,
|
||||
responseItem: placeholderAnswerItem,
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
})
|
||||
|
||||
// answer
|
||||
const responseItem: ChatItemInTree = {
|
||||
id: placeholderAnswerId,
|
||||
content: '',
|
||||
agent_thoughts: [],
|
||||
message_files: [],
|
||||
isAnswer: true,
|
||||
parentMessageId: questionItem.id,
|
||||
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
|
||||
}
|
||||
|
||||
handleResponding(true)
|
||||
hasStopResponded.current = false
|
||||
|
||||
const { query, files, inputs, ...restData } = data
|
||||
const bodyParams = {
|
||||
response_mode: 'streaming',
|
||||
conversation_id: conversationId.current,
|
||||
files: getProcessedFiles(files || []),
|
||||
query,
|
||||
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
|
||||
...restData,
|
||||
}
|
||||
if (bodyParams?.files?.length) {
|
||||
bodyParams.files = bodyParams.files.map((item) => {
|
||||
if (item.transfer_method === TransferMethod.local_file) {
|
||||
return {
|
||||
...item,
|
||||
url: '',
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
let isAgentMode = false
|
||||
let hasSetResponseId = false
|
||||
|
||||
let ttsUrl = ''
|
||||
let ttsIsPublic = false
|
||||
if (params.token) {
|
||||
ttsUrl = '/text-to-audio'
|
||||
ttsIsPublic = true
|
||||
}
|
||||
else if (params.appId) {
|
||||
if (pathname.search('explore/installed') > -1)
|
||||
ttsUrl = `/installed-apps/${params.appId}/text-to-audio`
|
||||
else
|
||||
ttsUrl = `/apps/${params.appId}/text-to-audio`
|
||||
}
|
||||
// Lazy initialization: Only create AudioPlayer when TTS is actually needed
|
||||
// This prevents opening audio channel unnecessarily
|
||||
let player: AudioPlayer | null = null
|
||||
const getOrCreatePlayer = () => {
|
||||
if (!player)
|
||||
player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop)
|
||||
|
||||
return player
|
||||
}
|
||||
ssePost(
|
||||
url,
|
||||
{
|
||||
body: bodyParams,
|
||||
},
|
||||
{
|
||||
isPublicAPI,
|
||||
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
|
||||
if (!isAgentMode) {
|
||||
responseItem.content = responseItem.content + message
|
||||
}
|
||||
else {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought)
|
||||
lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
|
||||
}
|
||||
|
||||
if (messageId && !hasSetResponseId) {
|
||||
questionItem.id = `question-${messageId}`
|
||||
responseItem.id = messageId
|
||||
responseItem.parentMessageId = questionItem.id
|
||||
hasSetResponseId = true
|
||||
}
|
||||
|
||||
if (isFirstMessage && newConversationId)
|
||||
conversationId.current = newConversationId
|
||||
|
||||
taskIdRef.current = taskId
|
||||
if (messageId)
|
||||
responseItem.id = messageId
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
async onCompleted(hasError?: boolean) {
|
||||
handleResponding(false)
|
||||
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationId.current)
|
||||
|
||||
if (conversationId.current && !hasStopResponded.current && onGetConversationMessages) {
|
||||
const { data }: any = await onGetConversationMessages(
|
||||
conversationId.current,
|
||||
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
|
||||
if (!newResponseItem)
|
||||
return
|
||||
|
||||
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
|
||||
updateChatTreeNode(responseItem.id, {
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
text: newResponseItem.answer,
|
||||
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
more: {
|
||||
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
|
||||
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
|
||||
latency: newResponseItem.provider_response_latency.toFixed(2),
|
||||
},
|
||||
// for agent log
|
||||
conversationId: conversationId.current,
|
||||
input: {
|
||||
inputs: newResponseItem.inputs,
|
||||
query: newResponseItem.query,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
responseItem.id,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestQuestions(data)
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setSuggestQuestions([])
|
||||
}
|
||||
}
|
||||
},
|
||||
onFile(file) {
|
||||
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
|
||||
if (lastThought)
|
||||
responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, file]
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onThought(thought) {
|
||||
isAgentMode = true
|
||||
const response = responseItem as any
|
||||
if (thought.message_id && !hasSetResponseId)
|
||||
response.id = thought.message_id
|
||||
if (thought.conversation_id)
|
||||
response.conversationId = thought.conversation_id
|
||||
|
||||
if (response.agent_thoughts.length === 0) {
|
||||
response.agent_thoughts.push(thought)
|
||||
}
|
||||
else {
|
||||
const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
|
||||
// thought changed but still the same thought, so update.
|
||||
if (lastThought.id === thought.id) {
|
||||
thought.thought = lastThought.thought
|
||||
thought.message_files = lastThought.message_files
|
||||
responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought
|
||||
}
|
||||
else {
|
||||
responseItem.agent_thoughts!.push(thought)
|
||||
}
|
||||
}
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onMessageEnd: (messageEnd) => {
|
||||
if (messageEnd.metadata?.annotation_reply) {
|
||||
responseItem.id = messageEnd.id
|
||||
responseItem.annotation = ({
|
||||
id: messageEnd.metadata.annotation_reply.id,
|
||||
authorName: messageEnd.metadata.annotation_reply.account.name,
|
||||
})
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
return
|
||||
}
|
||||
responseItem.citation = messageEnd.metadata?.retriever_resources || []
|
||||
const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
|
||||
responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onMessageReplace: (messageReplace) => {
|
||||
responseItem.content = messageReplace.answer
|
||||
},
|
||||
onError() {
|
||||
handleResponding(false)
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
taskIdRef.current = task_id
|
||||
responseItem.workflow_run_id = workflow_run_id
|
||||
responseItem.workflowProcess = {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
tracing: [],
|
||||
}
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onWorkflowFinished: ({ data: workflowFinishedData }) => {
|
||||
responseItem.workflowProcess!.status = workflowFinishedData.status as WorkflowRunningStatus
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onIterationStart: ({ data: iterationStartedData }) => {
|
||||
responseItem.workflowProcess!.tracing!.push({
|
||||
...iterationStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onIterationFinish: ({ data: iterationFinishedData }) => {
|
||||
const tracing = responseItem.workflowProcess!.tracing!
|
||||
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
...iterationFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onNodeStarted: ({ data: nodeStartedData }) => {
|
||||
if (nodeStartedData.iteration_id)
|
||||
return
|
||||
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
responseItem.workflowProcess!.tracing!.push({
|
||||
...nodeStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data: nodeFinishedData }) => {
|
||||
if (nodeFinishedData.iteration_id)
|
||||
return
|
||||
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
|
||||
if (!item.execution_metadata?.parallel_id)
|
||||
return item.node_id === nodeFinishedData.node_id
|
||||
|
||||
return item.node_id === nodeFinishedData.node_id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id)
|
||||
})
|
||||
responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onTTSChunk: (messageId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
const audioPlayer = getOrCreatePlayer()
|
||||
if (audioPlayer) {
|
||||
audioPlayer.playAudioWithAudio(audio, true)
|
||||
AudioPlayerManager.getInstance().resetMsgId(messageId)
|
||||
}
|
||||
},
|
||||
onTTSEnd: (messageId: string, audio: string) => {
|
||||
const audioPlayer = getOrCreatePlayer()
|
||||
if (audioPlayer)
|
||||
audioPlayer.playAudioWithAudio(audio, false)
|
||||
},
|
||||
onLoopStart: ({ data: loopStartedData }) => {
|
||||
responseItem.workflowProcess!.tracing!.push({
|
||||
...loopStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
onLoopFinish: ({ data: loopFinishedData }) => {
|
||||
const tracing = responseItem.workflowProcess!.tracing!
|
||||
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
...loopFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
responseItem,
|
||||
parentId: data.parent_message_id,
|
||||
})
|
||||
},
|
||||
})
|
||||
return true
|
||||
}, [
|
||||
t,
|
||||
chatTree.length,
|
||||
threadMessages,
|
||||
config?.suggested_questions_after_answer,
|
||||
updateCurrentQAOnTree,
|
||||
updateChatTreeNode,
|
||||
notify,
|
||||
handleResponding,
|
||||
formatTime,
|
||||
params.token,
|
||||
params.appId,
|
||||
pathname,
|
||||
formSettings,
|
||||
])
|
||||
|
||||
const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => {
|
||||
const targetQuestionId = chatList[index - 1].id
|
||||
const targetAnswerId = chatList[index].id
|
||||
|
||||
updateChatTreeNode(targetQuestionId, {
|
||||
content: query,
|
||||
})
|
||||
updateChatTreeNode(targetAnswerId, {
|
||||
content: answer,
|
||||
annotation: {
|
||||
...chatList[index].annotation,
|
||||
logAnnotation: undefined,
|
||||
} as any,
|
||||
})
|
||||
}, [chatList, updateChatTreeNode])
|
||||
|
||||
const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => {
|
||||
const targetQuestionId = chatList[index - 1].id
|
||||
const targetAnswerId = chatList[index].id
|
||||
|
||||
updateChatTreeNode(targetQuestionId, {
|
||||
content: query,
|
||||
})
|
||||
|
||||
updateChatTreeNode(targetAnswerId, {
|
||||
content: chatList[index].content,
|
||||
annotation: {
|
||||
id: annotationId,
|
||||
authorName,
|
||||
logAnnotation: {
|
||||
content: answer,
|
||||
account: {
|
||||
id: '',
|
||||
name: authorName,
|
||||
email: '',
|
||||
},
|
||||
},
|
||||
} as Annotation,
|
||||
})
|
||||
}, [chatList, updateChatTreeNode])
|
||||
|
||||
const handleAnnotationRemoved = useCallback((index: number) => {
|
||||
const targetAnswerId = chatList[index].id
|
||||
|
||||
updateChatTreeNode(targetAnswerId, {
|
||||
content: chatList[index].content,
|
||||
annotation: {
|
||||
...chatList[index].annotation,
|
||||
id: '',
|
||||
} as Annotation,
|
||||
})
|
||||
}, [chatList, updateChatTreeNode])
|
||||
|
||||
useEffect(() => {
|
||||
if (clearChatList)
|
||||
handleRestart(() => clearChatListCallback?.(false))
|
||||
}, [clearChatList, clearChatListCallback, handleRestart])
|
||||
|
||||
return {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
conversationId: conversationId.current,
|
||||
isResponding,
|
||||
setIsResponding,
|
||||
handleSend,
|
||||
suggestedQuestions,
|
||||
handleRestart,
|
||||
handleStop,
|
||||
handleAnnotationEdited,
|
||||
handleAnnotationAdded,
|
||||
handleAnnotationRemoved,
|
||||
}
|
||||
}
|
||||
354
dify/web/app/components/base/chat/chat/index.tsx
Normal file
354
dify/web/app/components/base/chat/chat/index.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import type {
|
||||
FC,
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { debounce } from 'lodash-es'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
Feedback,
|
||||
OnRegenerate,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context'
|
||||
import Question from './question'
|
||||
import Answer from './answer'
|
||||
import ChatInputArea from './chat-input-area'
|
||||
import TryToAsk from './try-to-ask'
|
||||
import { ChatContextProvider } from './context'
|
||||
import type { InputForm } from './type'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import AgentLogModal from '@/app/components/base/agent-log-modal'
|
||||
import PromptLogModal from '@/app/components/base/prompt-log-modal'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { AppData } from '@/models/share'
|
||||
|
||||
export type ChatProps = {
|
||||
appData?: AppData
|
||||
chatList: ChatItem[]
|
||||
config?: ChatConfig
|
||||
isResponding?: boolean
|
||||
noStopResponding?: boolean
|
||||
onStopResponding?: () => void
|
||||
noChatInput?: boolean
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputsForm?: InputForm[]
|
||||
onRegenerate?: OnRegenerate
|
||||
chatContainerClassName?: string
|
||||
chatContainerInnerClassName?: string
|
||||
chatFooterClassName?: string
|
||||
chatFooterInnerClassName?: string
|
||||
suggestedQuestions?: string[]
|
||||
showPromptLog?: boolean
|
||||
questionIcon?: ReactNode
|
||||
answerIcon?: ReactNode
|
||||
allToolIcons?: Record<string, string | Emoji>
|
||||
onAnnotationEdited?: (question: string, answer: string, index: number) => void
|
||||
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
|
||||
onAnnotationRemoved?: (index: number) => void
|
||||
chatNode?: ReactNode
|
||||
onFeedback?: (messageId: string, feedback: Feedback) => void
|
||||
chatAnswerContainerInner?: string
|
||||
hideProcessDetail?: boolean
|
||||
hideLogModal?: boolean
|
||||
themeBuilder?: ThemeBuilder
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
noSpacing?: boolean
|
||||
inputDisabled?: boolean
|
||||
isMobile?: boolean
|
||||
sidebarCollapseState?: boolean
|
||||
}
|
||||
|
||||
const Chat: FC<ChatProps> = ({
|
||||
appData,
|
||||
config,
|
||||
onSend,
|
||||
inputs,
|
||||
inputsForm,
|
||||
onRegenerate,
|
||||
chatList,
|
||||
isResponding,
|
||||
noStopResponding,
|
||||
onStopResponding,
|
||||
noChatInput,
|
||||
chatContainerClassName,
|
||||
chatContainerInnerClassName,
|
||||
chatFooterClassName,
|
||||
chatFooterInnerClassName,
|
||||
suggestedQuestions,
|
||||
showPromptLog,
|
||||
questionIcon,
|
||||
answerIcon,
|
||||
onAnnotationAdded,
|
||||
onAnnotationEdited,
|
||||
onAnnotationRemoved,
|
||||
chatNode,
|
||||
onFeedback,
|
||||
chatAnswerContainerInner,
|
||||
hideProcessDetail,
|
||||
hideLogModal,
|
||||
themeBuilder,
|
||||
switchSibling,
|
||||
showFeatureBar,
|
||||
showFileUpload,
|
||||
onFeatureBarClick,
|
||||
noSpacing,
|
||||
inputDisabled,
|
||||
isMobile,
|
||||
sidebarCollapseState,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
|
||||
currentLogItem: state.currentLogItem,
|
||||
setCurrentLogItem: state.setCurrentLogItem,
|
||||
showPromptLogModal: state.showPromptLogModal,
|
||||
setShowPromptLogModal: state.setShowPromptLogModal,
|
||||
showAgentLogModal: state.showAgentLogModal,
|
||||
setShowAgentLogModal: state.setShowAgentLogModal,
|
||||
})))
|
||||
const [width, setWidth] = useState(0)
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||
const chatContainerInnerRef = useRef<HTMLDivElement>(null)
|
||||
const chatFooterRef = useRef<HTMLDivElement>(null)
|
||||
const chatFooterInnerRef = useRef<HTMLDivElement>(null)
|
||||
const userScrolledRef = useRef(false)
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
if (chatList.length > 1 && chatContainerRef.current && !userScrolledRef.current)
|
||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight
|
||||
}, [chatList.length])
|
||||
|
||||
const handleWindowResize = useCallback(() => {
|
||||
if (chatContainerRef.current)
|
||||
setWidth(document.body.clientWidth - (chatContainerRef.current?.clientWidth + 16) - 8)
|
||||
|
||||
if (chatContainerRef.current && chatFooterRef.current)
|
||||
chatFooterRef.current.style.width = `${chatContainerRef.current.clientWidth}px`
|
||||
|
||||
if (chatContainerInnerRef.current && chatFooterInnerRef.current)
|
||||
chatFooterInnerRef.current.style.width = `${chatContainerInnerRef.current.clientWidth}px`
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
handleScrollToBottom()
|
||||
handleWindowResize()
|
||||
}, [handleScrollToBottom, handleWindowResize])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatContainerRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
handleScrollToBottom()
|
||||
handleWindowResize()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const debouncedHandler = debounce(handleWindowResize, 200)
|
||||
window.addEventListener('resize', debouncedHandler)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', debouncedHandler)
|
||||
debouncedHandler.cancel()
|
||||
}
|
||||
}, [handleWindowResize])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatFooterRef.current && chatContainerRef.current) {
|
||||
// container padding bottom
|
||||
const resizeContainerObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { blockSize } = entry.borderBoxSize[0]
|
||||
chatContainerRef.current!.style.paddingBottom = `${blockSize}px`
|
||||
handleScrollToBottom()
|
||||
}
|
||||
})
|
||||
resizeContainerObserver.observe(chatFooterRef.current)
|
||||
|
||||
// footer width
|
||||
const resizeFooterObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { inlineSize } = entry.borderBoxSize[0]
|
||||
chatFooterRef.current!.style.width = `${inlineSize}px`
|
||||
}
|
||||
})
|
||||
resizeFooterObserver.observe(chatContainerRef.current)
|
||||
|
||||
return () => {
|
||||
resizeContainerObserver.disconnect()
|
||||
resizeFooterObserver.disconnect()
|
||||
}
|
||||
}
|
||||
}, [handleScrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
const chatContainer = chatContainerRef.current
|
||||
if (chatContainer) {
|
||||
const setUserScrolled = () => {
|
||||
// eslint-disable-next-line sonarjs/no-gratuitous-expressions
|
||||
if (chatContainer) // its in event callback, chatContainer may be null
|
||||
userScrolledRef.current = chatContainer.scrollHeight - chatContainer.scrollTop > chatContainer.clientHeight
|
||||
}
|
||||
chatContainer.addEventListener('scroll', setUserScrolled)
|
||||
return () => chatContainer.removeEventListener('scroll', setUserScrolled)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarCollapseState)
|
||||
setTimeout(() => handleWindowResize(), 200)
|
||||
}, [handleWindowResize, sidebarCollapseState])
|
||||
|
||||
const hasTryToAsk = config?.suggested_questions_after_answer?.enabled && !!suggestedQuestions?.length && onSend
|
||||
|
||||
return (
|
||||
<ChatContextProvider
|
||||
config={config}
|
||||
chatList={chatList}
|
||||
isResponding={isResponding}
|
||||
showPromptLog={showPromptLog}
|
||||
questionIcon={questionIcon}
|
||||
answerIcon={answerIcon}
|
||||
onSend={onSend}
|
||||
onRegenerate={onRegenerate}
|
||||
onAnnotationAdded={onAnnotationAdded}
|
||||
onAnnotationEdited={onAnnotationEdited}
|
||||
onAnnotationRemoved={onAnnotationRemoved}
|
||||
onFeedback={onFeedback}
|
||||
>
|
||||
<div className='relative h-full'>
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className={cn('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)}
|
||||
>
|
||||
{chatNode}
|
||||
<div
|
||||
ref={chatContainerInnerRef}
|
||||
className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)}
|
||||
>
|
||||
{
|
||||
chatList.map((item, index) => {
|
||||
if (item.isAnswer) {
|
||||
const isLast = item.id === chatList[chatList.length - 1]?.id
|
||||
return (
|
||||
<Answer
|
||||
appData={appData}
|
||||
key={item.id}
|
||||
item={item}
|
||||
question={chatList[index - 1]?.content}
|
||||
index={index}
|
||||
config={config}
|
||||
answerIcon={answerIcon}
|
||||
responding={isLast && isResponding}
|
||||
showPromptLog={showPromptLog}
|
||||
chatAnswerContainerInner={chatAnswerContainerInner}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
noChatInput={noChatInput}
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Question
|
||||
key={item.id}
|
||||
item={item}
|
||||
questionIcon={questionIcon}
|
||||
theme={themeBuilder?.theme}
|
||||
enableEdit={config?.questionEditEnable}
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`absolute bottom-0 z-10 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
|
||||
ref={chatFooterRef}
|
||||
>
|
||||
<div
|
||||
ref={chatFooterInnerRef}
|
||||
className={cn('relative', chatFooterInnerClassName)}
|
||||
>
|
||||
{
|
||||
!noStopResponding && isResponding && (
|
||||
<div className='mb-2 flex justify-center'>
|
||||
<Button className='border-components-panel-border bg-components-panel-bg text-components-button-secondary-text' onClick={onStopResponding}>
|
||||
<StopCircle className='mr-[5px] h-3.5 w-3.5' />
|
||||
<span className='text-xs font-normal'>{t('appDebug.operation.stopResponding')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
hasTryToAsk && (
|
||||
<TryToAsk
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
onSend={onSend}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!noChatInput && (
|
||||
<ChatInputArea
|
||||
botName={appData?.site.title || 'Bot'}
|
||||
disabled={inputDisabled}
|
||||
showFeatureBar={showFeatureBar}
|
||||
showFileUpload={showFileUpload}
|
||||
featureBarDisabled={isResponding}
|
||||
onFeatureBarClick={onFeatureBarClick}
|
||||
visionConfig={config?.file_upload}
|
||||
speechToTextConfig={config?.speech_to_text}
|
||||
onSend={onSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
theme={themeBuilder?.theme}
|
||||
isResponding={isResponding}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{showPromptLogModal && !hideLogModal && (
|
||||
<PromptLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowPromptLogModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAgentLogModal && !hideLogModal && (
|
||||
<AgentLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowAgentLogModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ChatContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Chat)
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import s from './style.module.css'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type ILoadingAnimProps = {
|
||||
type: 'text' | 'avatar'
|
||||
}
|
||||
|
||||
const LoadingAnim: FC<ILoadingAnimProps> = ({
|
||||
type,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(s['dot-flashing'], s[type])} />
|
||||
)
|
||||
}
|
||||
export default React.memo(LoadingAnim)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user