mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
fix: the input of agent chat would be replaced if conversation history updated (#39289)
This commit is contained in:
parent
eee8d6cf7b
commit
c8ccfba960
@ -177,6 +177,7 @@ const mockContextValue: ChatContextValue = {
|
||||
config: makeChatConfig({ supportFeedback: true }),
|
||||
onFeedback: vi.fn().mockResolvedValue(undefined),
|
||||
onRegenerate: vi.fn(),
|
||||
showRegenerate: false,
|
||||
onAnnotationAdded: vi.fn(),
|
||||
onAnnotationEdited: vi.fn(),
|
||||
onAnnotationRemoved: vi.fn(),
|
||||
@ -263,6 +264,7 @@ describe('Operation', () => {
|
||||
mockContextValue.onAnnotationEdited = vi.fn()
|
||||
mockContextValue.onAnnotationRemoved = vi.fn()
|
||||
mockContextValue.readonly = false
|
||||
mockContextValue.showRegenerate = false
|
||||
mockProviderContext.plan.usage.annotatedResponse = 0
|
||||
mockProviderContext.enableBilling = false
|
||||
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
|
||||
@ -286,6 +288,12 @@ describe('Operation', () => {
|
||||
expect(screen.queryByRole('button', { name: 'operation.regenerate' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show regenerate button when explicitly enabled without a chat input', () => {
|
||||
mockContextValue.showRegenerate = true
|
||||
renderOperation({ ...baseProps, noChatInput: true })
|
||||
expect(screen.getByRole('button', { name: 'operation.regenerate' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show TTS button when text_to_speech is enabled', () => {
|
||||
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true } })
|
||||
renderOperation()
|
||||
|
||||
@ -86,6 +86,7 @@ function Operation({
|
||||
onAnnotationRemoved,
|
||||
onFeedback,
|
||||
onRegenerate,
|
||||
showRegenerate,
|
||||
readonly,
|
||||
} = useChatContext()
|
||||
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
||||
@ -389,7 +390,7 @@ function Operation({
|
||||
<span aria-hidden="true" className="i-ri-clipboard-line size-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{!noChatInput && (
|
||||
{(!noChatInput || showRegenerate) && (
|
||||
<ActionButton aria-label={regenerateLabel} onClick={() => onRegenerate?.(item)}>
|
||||
<span aria-hidden="true" className="i-ri-reset-left-line size-4" />
|
||||
</ActionButton>
|
||||
|
||||
@ -19,6 +19,7 @@ export const ChatContextProvider = ({
|
||||
answerIcon,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
showRegenerate,
|
||||
onAnnotationEdited,
|
||||
onAnnotationAdded,
|
||||
onAnnotationRemoved,
|
||||
@ -38,6 +39,7 @@ export const ChatContextProvider = ({
|
||||
answerIcon,
|
||||
onSend,
|
||||
onRegenerate,
|
||||
showRegenerate,
|
||||
onAnnotationEdited,
|
||||
onAnnotationAdded,
|
||||
onAnnotationRemoved,
|
||||
|
||||
@ -13,6 +13,7 @@ export type ChatContextValue = Pick<
|
||||
| 'answerIcon'
|
||||
| 'onSend'
|
||||
| 'onRegenerate'
|
||||
| 'showRegenerate'
|
||||
| 'onAnnotationEdited'
|
||||
| 'onAnnotationAdded'
|
||||
| 'onAnnotationRemoved'
|
||||
|
||||
@ -31,6 +31,7 @@ export type ChatProps = {
|
||||
noStopResponding?: boolean
|
||||
onStopResponding?: () => void
|
||||
noChatInput?: boolean
|
||||
showRegenerate?: boolean
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, unknown>
|
||||
inputsForm?: InputForm[]
|
||||
@ -101,6 +102,7 @@ const Chat: FC<ChatProps> = ({
|
||||
noStopResponding,
|
||||
onStopResponding,
|
||||
noChatInput,
|
||||
showRegenerate,
|
||||
chatContainerClassName,
|
||||
chatContainerInnerClassName,
|
||||
chatFooterClassName,
|
||||
@ -179,6 +181,7 @@ const Chat: FC<ChatProps> = ({
|
||||
answerIcon={answerIcon}
|
||||
onSend={onSend}
|
||||
onRegenerate={onRegenerate}
|
||||
showRegenerate={showRegenerate}
|
||||
onAnnotationAdded={onAnnotationAdded}
|
||||
onAnnotationEdited={onAnnotationEdited}
|
||||
onAnnotationRemoved={onAnnotationRemoved}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from 'react'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
@ -38,10 +39,22 @@ vi.mock('@/next/dynamic', async () => {
|
||||
showPromptLog?: boolean
|
||||
footerNotice?: string
|
||||
chatNode?: ReactNode
|
||||
chatList?: Array<{
|
||||
id: string
|
||||
content: string
|
||||
children?: Array<{ id: string; content: string }>
|
||||
}>
|
||||
noChatInput?: boolean
|
||||
showRegenerate?: boolean
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
}) {
|
||||
const [sent, setSent] = useState(false)
|
||||
const [initialChatList] = useState(props.chatList ?? [])
|
||||
const initialVisibleMessages = initialChatList.flatMap((item) => [
|
||||
item,
|
||||
...(item.children ?? []),
|
||||
])
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -51,6 +64,8 @@ vi.mock('@/next/dynamic', async () => {
|
||||
data-send-button-loading={String(!!props.sendButtonLoading)}
|
||||
data-show-prompt-log={String(!!props.showPromptLog)}
|
||||
data-footer-notice={props.footerNotice ?? ''}
|
||||
data-no-chat-input={String(!!props.noChatInput)}
|
||||
data-show-regenerate={String(!!props.showRegenerate)}
|
||||
data-speech-agent-id={
|
||||
props.speechToTextTarget?.type === 'agent' ? props.speechToTextTarget.agentId : ''
|
||||
}
|
||||
@ -59,6 +74,9 @@ vi.mock('@/next/dynamic', async () => {
|
||||
}
|
||||
>
|
||||
{props.chatNode}
|
||||
{initialVisibleMessages.map((item) => (
|
||||
<span key={item.id}>{item.content}</span>
|
||||
))}
|
||||
<span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
@ -82,24 +100,35 @@ vi.mock('@/next/dynamic', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({
|
||||
default: ({
|
||||
default: function MockChatInputArea({
|
||||
footerNotice,
|
||||
speechToTextTarget,
|
||||
}: {
|
||||
footerNotice?: ReactNode
|
||||
speechToTextTarget?: SpeechToTextTarget
|
||||
}) => (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="voice input"
|
||||
data-speech-agent-id={speechToTextTarget?.type === 'agent' ? speechToTextTarget.agentId : ''}
|
||||
data-speech-draft-type={
|
||||
speechToTextTarget?.type === 'agent' ? speechToTextTarget.draftType : ''
|
||||
}
|
||||
>
|
||||
{footerNotice}
|
||||
</div>
|
||||
),
|
||||
}) {
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="voice input"
|
||||
data-speech-agent-id={
|
||||
speechToTextTarget?.type === 'agent' ? speechToTextTarget.agentId : ''
|
||||
}
|
||||
data-speech-draft-type={
|
||||
speechToTextTarget?.type === 'agent' ? speechToTextTarget.draftType : ''
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
aria-label="chat draft"
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
/>
|
||||
{footerNotice}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
|
||||
@ -328,7 +357,7 @@ describe('AgentPreviewChat', () => {
|
||||
|
||||
it('should bind Agent preview voice input to the normal Agent draft', () => {
|
||||
renderPreviewChat({
|
||||
renderEmptyState: ({ inputNode }) => inputNode,
|
||||
renderEmptyState: () => null,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('group', { name: 'voice input' })).toHaveAttribute(
|
||||
@ -352,7 +381,7 @@ describe('AgentPreviewChat', () => {
|
||||
it('should bind Agent build voice input to the account build draft', () => {
|
||||
renderPreviewChat({
|
||||
draftType: 'debug_build',
|
||||
renderEmptyState: ({ inputNode }) => inputNode,
|
||||
renderEmptyState: () => null,
|
||||
})
|
||||
|
||||
expect(screen.getByRole('group', { name: 'voice input' })).toHaveAttribute(
|
||||
@ -365,6 +394,19 @@ describe('AgentPreviewChat', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep answer regeneration available when the chat input is external', () => {
|
||||
renderPreviewChat()
|
||||
|
||||
expect(screen.getByRole('region', { name: 'chat' })).toHaveAttribute(
|
||||
'data-no-chat-input',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByRole('region', { name: 'chat' })).toHaveAttribute(
|
||||
'data-show-regenerate',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('should expose the owning save-before-transcribe callback', () => {
|
||||
const onBeforeSpeechToText = vi.fn().mockResolvedValue(undefined)
|
||||
renderPreviewChat({ onBeforeSpeechToText })
|
||||
@ -433,6 +475,135 @@ describe('AgentPreviewChat', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should preserve the input draft when cached conversation history refreshes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const conversationInput = {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
query: {
|
||||
conversation_id: 'debug-conversation-1',
|
||||
},
|
||||
}
|
||||
const { queryClient } = renderPreviewChat({
|
||||
conversationId: 'debug-conversation-1',
|
||||
renderEmptyState: () => null,
|
||||
})
|
||||
const input = await screen.findByRole('textbox', { name: 'chat draft' })
|
||||
const conversationQueryKey = consoleQuery.agent.byAgentId.chatMessages.get.queryKey({
|
||||
input: conversationInput,
|
||||
})
|
||||
|
||||
await user.type(input, 'Keep this draft')
|
||||
const updatedAt = queryClient.getQueryState(conversationQueryKey)?.dataUpdatedAt ?? 0
|
||||
act(() => {
|
||||
queryClient.setQueryData(
|
||||
conversationQueryKey,
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
conversation_id: 'debug-conversation-1',
|
||||
query: 'previous question',
|
||||
answer: 'refreshed answer',
|
||||
inputs: {},
|
||||
message: [],
|
||||
message_files: [],
|
||||
agent_thoughts: [],
|
||||
feedbacks: [],
|
||||
answer_tokens: 3,
|
||||
message_tokens: 2,
|
||||
metadata: {},
|
||||
provider_response_latency: 1,
|
||||
status: 'success',
|
||||
from_source: 'console',
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
limit: 20,
|
||||
},
|
||||
{
|
||||
updatedAt: updatedAt + 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryState(conversationQueryKey)?.dataUpdatedAt).toBe(updatedAt + 1)
|
||||
expect(await screen.findByText('refreshed answer')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'chat draft' })).toHaveValue('Keep this draft')
|
||||
})
|
||||
|
||||
it('should preserve the footer input draft when cached conversation history refreshes', async () => {
|
||||
chatMessagesGetMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
conversation_id: 'debug-conversation-1',
|
||||
query: 'previous question',
|
||||
answer: 'previous answer',
|
||||
inputs: {},
|
||||
message: [],
|
||||
message_files: [],
|
||||
agent_thoughts: [],
|
||||
feedbacks: [],
|
||||
answer_tokens: 3,
|
||||
message_tokens: 2,
|
||||
provider_response_latency: 1,
|
||||
status: 'success',
|
||||
from_source: 'console',
|
||||
},
|
||||
],
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
const conversationInput = {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
query: {
|
||||
conversation_id: 'debug-conversation-1',
|
||||
},
|
||||
}
|
||||
const { queryClient } = renderPreviewChat({
|
||||
conversationId: 'debug-conversation-1',
|
||||
})
|
||||
const input = await screen.findByRole('textbox', { name: 'chat draft' })
|
||||
const conversationQueryKey = consoleQuery.agent.byAgentId.chatMessages.get.queryKey({
|
||||
input: conversationInput,
|
||||
})
|
||||
|
||||
await user.type(input, 'Keep this footer draft')
|
||||
const updatedAt = queryClient.getQueryState(conversationQueryKey)?.dataUpdatedAt ?? 0
|
||||
act(() => {
|
||||
queryClient.setQueryData(
|
||||
conversationQueryKey,
|
||||
(currentData) => {
|
||||
if (!currentData) return currentData
|
||||
|
||||
return {
|
||||
...currentData,
|
||||
data: currentData.data.map((message) => ({
|
||||
...message,
|
||||
answer: 'refreshed answer',
|
||||
})),
|
||||
}
|
||||
},
|
||||
{
|
||||
updatedAt: updatedAt + 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryState(conversationQueryKey)?.dataUpdatedAt).toBe(updatedAt + 1)
|
||||
expect(await screen.findByText('refreshed answer')).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'chat' })).toHaveAttribute(
|
||||
'data-no-chat-input',
|
||||
'true',
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: 'chat draft' })).toHaveValue(
|
||||
'Keep this footer draft',
|
||||
)
|
||||
})
|
||||
|
||||
it('should save draft before sending preview chat through the agent chat endpoints', async () => {
|
||||
const saveDraftBeforeRun = vi.fn().mockResolvedValue(undefined)
|
||||
renderPreviewChat({
|
||||
@ -529,7 +700,7 @@ describe('AgentPreviewChat', () => {
|
||||
)
|
||||
renderPreviewChat({
|
||||
sendButtonLabel: 'Start build',
|
||||
renderEmptyState: ({ inputNode }) => <div>{inputNode}</div>,
|
||||
renderEmptyState: () => null,
|
||||
onSaveDraftBeforeRun: saveDraftBeforeRun,
|
||||
})
|
||||
|
||||
@ -605,39 +776,40 @@ describe('AgentPreviewChat', () => {
|
||||
})
|
||||
|
||||
it('should use the default send button after the first build message', async () => {
|
||||
useChatMock.mockImplementationOnce(
|
||||
(
|
||||
_config: unknown,
|
||||
_formSettings: unknown,
|
||||
_chatList: unknown[],
|
||||
stopCallback: (taskId: string) => void,
|
||||
) => {
|
||||
stopCallbackRef.current = stopCallback
|
||||
const useChatWithExistingMessage = (
|
||||
_config: unknown,
|
||||
_formSettings: unknown,
|
||||
_chatList: unknown[],
|
||||
stopCallback: (taskId: string) => void,
|
||||
) => {
|
||||
stopCallbackRef.current = stopCallback
|
||||
|
||||
return {
|
||||
chatList: [
|
||||
{
|
||||
id: 'question-1',
|
||||
content: 'Build an agent',
|
||||
isAnswer: false,
|
||||
},
|
||||
{
|
||||
id: 'answer-1',
|
||||
content: 'Done',
|
||||
isAnswer: true,
|
||||
},
|
||||
],
|
||||
setTargetMessageId: vi.fn(),
|
||||
isResponding: false,
|
||||
handleSend: handleSendMock,
|
||||
suggestedQuestions: [],
|
||||
handleStop: () => stopCallback('task-1'),
|
||||
handleAnnotationAdded: vi.fn(),
|
||||
handleAnnotationEdited: vi.fn(),
|
||||
handleAnnotationRemoved: vi.fn(),
|
||||
}
|
||||
},
|
||||
)
|
||||
return {
|
||||
chatList: [
|
||||
{
|
||||
id: 'question-1',
|
||||
content: 'Build an agent',
|
||||
isAnswer: false,
|
||||
},
|
||||
{
|
||||
id: 'answer-1',
|
||||
content: 'Done',
|
||||
isAnswer: true,
|
||||
},
|
||||
],
|
||||
setTargetMessageId: vi.fn(),
|
||||
isResponding: false,
|
||||
handleSend: handleSendMock,
|
||||
suggestedQuestions: [],
|
||||
handleStop: () => stopCallback('task-1'),
|
||||
handleAnnotationAdded: vi.fn(),
|
||||
handleAnnotationEdited: vi.fn(),
|
||||
handleAnnotationRemoved: vi.fn(),
|
||||
}
|
||||
}
|
||||
useChatMock
|
||||
.mockImplementationOnce(useChatWithExistingMessage)
|
||||
.mockImplementationOnce(useChatWithExistingMessage)
|
||||
renderPreviewChat({
|
||||
sendButtonLabel: 'Start build',
|
||||
})
|
||||
@ -907,7 +1079,7 @@ describe('AgentPreviewChat', () => {
|
||||
|
||||
it('should hide the sandbox notice after the first send starts', async () => {
|
||||
renderPreviewChat({
|
||||
renderEmptyState: ({ inputNode }) => <div>{inputNode}</div>,
|
||||
renderEmptyState: () => null,
|
||||
})
|
||||
|
||||
expect(
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentChatRuntimeEmptyStateProps, AgentChatRuntimeProps } from './chat-runtime'
|
||||
import type { AgentChatRuntimeProps } from './chat-runtime'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentChatRuntime } from './chat-runtime'
|
||||
@ -26,66 +26,63 @@ type AgentBuildChatProps = Omit<
|
||||
'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel'
|
||||
>
|
||||
|
||||
function AgentBuildChatEmptyState({ inputNode }: AgentChatRuntimeEmptyStateProps) {
|
||||
function AgentBuildChatEmptyState() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const communityEditionBuildModeTip = t(
|
||||
($) => $['agentDetail.configure.build.empty.communityEditionTip'],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex w-full max-w-150 flex-col items-start p-3 text-left">
|
||||
<div className="dify-blue-glass-surface relative flex h-[50px] w-12 items-center justify-center rounded-xl p-2">
|
||||
<div className="absolute inset-x-px inset-y-0.5 grid grid-cols-[repeat(8,4px)] grid-rows-[repeat(8,4px)] gap-0.5 opacity-25">
|
||||
{buildIconGridCells.map((cell) => (
|
||||
<span
|
||||
key={cell.id}
|
||||
className={cell.opacity > 0 ? 'rounded-[1px] bg-[#98A2B2]' : 'invisible'}
|
||||
style={{ opacity: cell.opacity }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute i-ri-hammer-line size-5 text-saas-dify-blue-inverted"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex max-w-full items-center gap-1.5">
|
||||
<div className="min-w-0 truncate system-md-medium text-text-secondary">
|
||||
{t(($) => $['agentDetail.configure.build.empty.title'])}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={communityEditionBuildModeTip}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
<>
|
||||
<div className="dify-blue-glass-surface relative flex h-[50px] w-12 items-center justify-center rounded-xl p-2">
|
||||
<div className="absolute inset-x-px inset-y-0.5 grid grid-cols-[repeat(8,4px)] grid-rows-[repeat(8,4px)] gap-0.5 opacity-25">
|
||||
{buildIconGridCells.map((cell) => (
|
||||
<span
|
||||
key={cell.id}
|
||||
className={cell.opacity > 0 ? 'rounded-[1px] bg-[#98A2B2]' : 'invisible'}
|
||||
style={{ opacity: cell.opacity }}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
popupClassName="max-w-[340px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{communityEditionBuildModeTip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.build.empty.description'])}
|
||||
</p>
|
||||
{inputNode}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute i-ri-hammer-line size-5 text-saas-dify-blue-inverted"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex max-w-full items-center gap-1.5">
|
||||
<div className="min-w-0 truncate system-md-medium text-text-secondary">
|
||||
{t(($) => $['agentDetail.configure.build.empty.title'])}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={communityEditionBuildModeTip}
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
popupClassName="max-w-[340px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{communityEditionBuildModeTip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.build.empty.description'])}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -98,9 +95,7 @@ export function AgentBuildChat(props: AgentBuildChatProps) {
|
||||
inputPlaceholder={t(($) => $['agentDetail.configure.build.inputPlaceholder'])}
|
||||
inputAutoFocus={false}
|
||||
sendButtonLabel={t(($) => $['agentDetail.configure.build.startBuild'])}
|
||||
renderEmptyState={(emptyStateProps: AgentChatRuntimeEmptyStateProps) => (
|
||||
<AgentBuildChatEmptyState {...emptyStateProps} />
|
||||
)}
|
||||
renderEmptyState={() => <AgentBuildChatEmptyState />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,263 @@
|
||||
import type {
|
||||
AgentCliToolConfig,
|
||||
AgentSoulConfig,
|
||||
AgentSoulDifyToolConfig,
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { InputForm } from '@/app/components/base/chat/chat/type'
|
||||
import type { ChatConfig } from '@/app/components/base/chat/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import { AgentStrategy, ModelModeType, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
|
||||
|
||||
export type AgentPreviewChatConfig = ChatConfig & {
|
||||
model: {
|
||||
provider: string
|
||||
name: string
|
||||
mode: ModelModeType
|
||||
completion_params: {
|
||||
max_tokens: number
|
||||
temperature: number
|
||||
top_p: number
|
||||
echo: boolean
|
||||
stop: string[]
|
||||
presence_penalty: number
|
||||
frequency_penalty: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSystemParameters: ChatConfig['system_parameters'] = {
|
||||
audio_file_size_limit: 0,
|
||||
file_size_limit: 0,
|
||||
image_file_size_limit: 0,
|
||||
video_file_size_limit: 0,
|
||||
workflow_file_upload_limit: 0,
|
||||
}
|
||||
|
||||
const disabledFileUploadConfig = {
|
||||
enabled: false,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: [],
|
||||
fileUploadConfig: defaultSystemParameters,
|
||||
image: {
|
||||
enabled: false,
|
||||
transfer_methods: [],
|
||||
number_limits: 0,
|
||||
image_file_size_limit: 0,
|
||||
detail: 'low',
|
||||
},
|
||||
max_length: 0,
|
||||
number_limits: 0,
|
||||
} as ChatConfig['file_upload'] & {
|
||||
fileUploadConfig: ChatConfig['system_parameters']
|
||||
}
|
||||
|
||||
const defaultFileUploadMethods = [TransferMethod.local_file, TransferMethod.remote_url]
|
||||
|
||||
const toPreviewFileUploadConfig = (fileUpload: FileUpload | undefined) => {
|
||||
if (!fileUpload?.enabled) return disabledFileUploadConfig
|
||||
|
||||
const allowedFileUploadMethods = fileUpload.allowed_file_upload_methods?.length
|
||||
? fileUpload.allowed_file_upload_methods
|
||||
: defaultFileUploadMethods
|
||||
const numberLimits = fileUpload.number_limits ?? fileUpload.image?.number_limits ?? 3
|
||||
|
||||
return {
|
||||
...disabledFileUploadConfig,
|
||||
...fileUpload,
|
||||
enabled: true,
|
||||
allowed_file_types: fileUpload.allowed_file_types?.length
|
||||
? fileUpload.allowed_file_types
|
||||
: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: allowedFileUploadMethods,
|
||||
number_limits: numberLimits,
|
||||
fileUploadConfig: fileUpload.fileUploadConfig ?? defaultSystemParameters,
|
||||
image: {
|
||||
...disabledFileUploadConfig.image,
|
||||
...fileUpload.image,
|
||||
enabled: fileUpload.image?.enabled ?? true,
|
||||
transfer_methods: fileUpload.image?.transfer_methods?.length
|
||||
? fileUpload.image.transfer_methods
|
||||
: allowedFileUploadMethods,
|
||||
number_limits: numberLimits,
|
||||
},
|
||||
} as ChatConfig['file_upload']
|
||||
}
|
||||
|
||||
const getModelSettings = (agentSoulConfig?: AgentSoulConfig) =>
|
||||
agentSoulConfig?.model?.model_settings ?? {}
|
||||
|
||||
const toEnabledConfig = (config?: { enabled?: boolean } | null) => ({
|
||||
...config,
|
||||
enabled: config?.enabled ?? false,
|
||||
})
|
||||
|
||||
const toInputType = (type: string): InputVarType => {
|
||||
if (type === InputVarType.paragraph) return InputVarType.paragraph
|
||||
if (type === InputVarType.select) return InputVarType.select
|
||||
if (type === InputVarType.number) return InputVarType.number
|
||||
if (type === InputVarType.json || type === InputVarType.jsonObject) return InputVarType.json
|
||||
|
||||
return InputVarType.textInput
|
||||
}
|
||||
|
||||
const toInputForm = (variable: NonNullable<AgentSoulConfig['app_variables']>[number]) => {
|
||||
const variableKey = String(variable.name)
|
||||
|
||||
return {
|
||||
...variable,
|
||||
key: variableKey,
|
||||
label: variableKey,
|
||||
variable: variableKey,
|
||||
type: toInputType(variable.type),
|
||||
required: variable.required ?? true,
|
||||
hide: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAgentSoulInputsForm = (agentSoulConfig?: AgentSoulConfig) =>
|
||||
(agentSoulConfig?.app_variables ?? []).map(toInputForm)
|
||||
|
||||
export const getAgentSoulInputs = (inputsForm: InputForm[]) => {
|
||||
return inputsForm.reduce<Inputs>((acc, input) => {
|
||||
acc[input.variable] = (input.default ?? '') as Inputs[string]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const toAgentTool = (tool: AgentSoulDifyToolConfig) => ({
|
||||
provider_id: tool.provider_id ?? tool.provider ?? tool.plugin_id ?? '',
|
||||
provider_type: tool.provider_type ?? 'builtin',
|
||||
provider_name: tool.provider ?? '',
|
||||
tool_name: tool.tool_name,
|
||||
tool_label: tool.name ?? tool.tool_name,
|
||||
tool_parameters: tool.runtime_parameters ?? {},
|
||||
enabled: tool.enabled ?? true,
|
||||
credential_id: tool.credential_ref?.id,
|
||||
})
|
||||
|
||||
const toCliTool = (tool: AgentCliToolConfig) => ({
|
||||
provider_id: 'agent-v2-cli',
|
||||
provider_type: 'builtin',
|
||||
provider_name: 'CLI',
|
||||
tool_name: tool.tool_name ?? tool.name ?? '',
|
||||
tool_label: tool.name ?? tool.tool_name ?? '',
|
||||
tool_parameters: {
|
||||
command: tool.command,
|
||||
},
|
||||
enabled: tool.enabled ?? true,
|
||||
})
|
||||
|
||||
const toLegacyPreviewDatasetConfigs = (
|
||||
knowledge?: AgentSoulConfig['knowledge'],
|
||||
): NonNullable<ChatConfig['dataset_configs']> => {
|
||||
// Temporary preview adapter: composer state is knowledge.sets, but this
|
||||
// legacy chat preview contract still accepts one flat dataset_configs block.
|
||||
// Preview currently flattens the first configured set only.
|
||||
const previewKnowledgeSet = knowledge?.sets?.[0]
|
||||
const datasets = previewKnowledgeSet?.datasets ?? []
|
||||
const retrieval = previewKnowledgeSet?.retrieval
|
||||
|
||||
return {
|
||||
retrieval_model: retrieval?.mode === 'single' ? RETRIEVE_TYPE.oneWay : RETRIEVE_TYPE.multiWay,
|
||||
reranking_model: {
|
||||
reranking_provider_name: retrieval?.reranking_model?.provider ?? '',
|
||||
reranking_model_name: retrieval?.reranking_model?.model ?? '',
|
||||
},
|
||||
top_k: retrieval?.top_k ?? 4,
|
||||
score_threshold_enabled:
|
||||
retrieval?.score_threshold !== undefined && retrieval?.score_threshold !== null,
|
||||
score_threshold: retrieval?.score_threshold ?? 0.8,
|
||||
datasets: {
|
||||
datasets: datasets
|
||||
.map((dataset) => ({
|
||||
enabled: true,
|
||||
id: dataset.id ?? '',
|
||||
}))
|
||||
.filter((dataset) => dataset.id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const buildChatConfig = ({
|
||||
agentSoulConfig,
|
||||
currentModel,
|
||||
prompt,
|
||||
}: {
|
||||
agentSoulConfig?: AgentSoulConfig
|
||||
currentModel?: AgentComposerModel
|
||||
prompt: string
|
||||
}): AgentPreviewChatConfig => {
|
||||
const modelSettings = currentModel?.model_settings ?? getModelSettings(agentSoulConfig)
|
||||
const appFeatures = agentSoulConfig?.app_features ?? {}
|
||||
const difyTools = agentSoulConfig?.tools?.dify_tools ?? []
|
||||
const cliTools = ENABLE_AGENT_CLI_TOOLS ? (agentSoulConfig?.tools?.cli_tools ?? []) : []
|
||||
|
||||
return {
|
||||
pre_prompt: prompt || agentSoulConfig?.prompt?.system_prompt || '',
|
||||
prompt_type: PromptMode.simple,
|
||||
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
user_input_form: (agentSoulConfig?.app_variables ?? []).map((variable) => ({
|
||||
'text-input': {
|
||||
default: String(variable.default ?? ''),
|
||||
label: variable.name,
|
||||
variable: variable.name,
|
||||
required: variable.required ?? true,
|
||||
max_length: 48,
|
||||
hide: false,
|
||||
},
|
||||
})),
|
||||
dataset_query_variable: '',
|
||||
opening_statement: appFeatures.opening_statement ?? '',
|
||||
suggested_questions: appFeatures.suggested_questions ?? [],
|
||||
suggested_questions_after_answer: toEnabledConfig(
|
||||
appFeatures.suggested_questions_after_answer,
|
||||
) as ChatConfig['suggested_questions_after_answer'],
|
||||
more_like_this: { enabled: false },
|
||||
text_to_speech: toEnabledConfig(appFeatures.text_to_speech) as ChatConfig['text_to_speech'],
|
||||
speech_to_text: toEnabledConfig(appFeatures.speech_to_text),
|
||||
retriever_resource: toEnabledConfig(appFeatures.retriever_resource),
|
||||
sensitive_word_avoidance: toEnabledConfig(appFeatures.sensitive_word_avoidance),
|
||||
annotation_reply: {
|
||||
id: '',
|
||||
enabled: false,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: '',
|
||||
},
|
||||
},
|
||||
agent_mode: {
|
||||
enabled: difyTools.length > 0 || cliTools.length > 0,
|
||||
strategy: AgentStrategy.functionCall,
|
||||
tools: [
|
||||
...difyTools.map(toAgentTool),
|
||||
...cliTools.map(toCliTool),
|
||||
] as ChatConfig['agent_mode']['tools'],
|
||||
},
|
||||
model: {
|
||||
provider: currentModel?.provider ?? agentSoulConfig?.model?.model_provider ?? '',
|
||||
name: currentModel?.model ?? agentSoulConfig?.model?.model ?? '',
|
||||
mode: ModelModeType.chat,
|
||||
completion_params: {
|
||||
temperature: modelSettings.temperature ?? 0.7,
|
||||
top_p: modelSettings.top_p ?? 1,
|
||||
max_tokens: modelSettings.max_tokens ?? 512,
|
||||
presence_penalty: modelSettings.presence_penalty ?? 0,
|
||||
frequency_penalty: modelSettings.frequency_penalty ?? 0,
|
||||
stop: modelSettings.stop ?? [],
|
||||
echo: false,
|
||||
},
|
||||
},
|
||||
dataset_configs: toLegacyPreviewDatasetConfigs(agentSoulConfig?.knowledge),
|
||||
file_upload: toPreviewFileUploadConfig(appFeatures.file_upload as FileUpload | undefined),
|
||||
system_parameters: defaultSystemParameters,
|
||||
supportCitationHitInfo: true,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,309 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { Ref } from 'react'
|
||||
import type { AgentPreviewChatConfig } from './chat-config'
|
||||
import type { InputForm } from '@/app/components/base/chat/chat/type'
|
||||
import type { ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { AgentRosterResponseContent } from '@/app/components/base/chat/chat/answer/agent-roster-response-content'
|
||||
import { useChat } from '@/app/components/base/chat/chat/hooks'
|
||||
import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
|
||||
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { userProfileAtom } from '@/context/account-state'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { buildChatConfig, getAgentSoulInputs, getAgentSoulInputsForm } from './chat-config'
|
||||
|
||||
const Chat = dynamic(() => import('@/app/components/base/chat/chat'), { ssr: false })
|
||||
|
||||
const stopAgentChatMessageResponding = (agentId: string, taskId: string) => {
|
||||
return consoleClient.agent.byAgentId.chatMessages.byTaskId.stop.post({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
task_id: taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const fetchAgentSuggestedQuestions = (agentId: string, messageId: string) => {
|
||||
return consoleClient.agent.byAgentId.chatMessages.byMessageId.suggestedQuestions.get({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
message_id: messageId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type AgentPreviewChatRuntimeState = {
|
||||
isEmptyChat: boolean
|
||||
isResponding: boolean
|
||||
isSendPending: boolean
|
||||
}
|
||||
|
||||
export type AgentPreviewChatController = {
|
||||
send: OnSend
|
||||
}
|
||||
|
||||
export function AgentPreviewChatConversation({
|
||||
ref,
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
clearChatList,
|
||||
config,
|
||||
conversationId,
|
||||
currentModel,
|
||||
draftType,
|
||||
initialChatTree,
|
||||
inputs,
|
||||
inputsForm,
|
||||
sendButtonLabel,
|
||||
speechToTextTarget,
|
||||
onBeforeSpeechToText,
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onRuntimeStateChange,
|
||||
onSaveDraftBeforeRun,
|
||||
onSendInterrupted,
|
||||
}: {
|
||||
ref: Ref<AgentPreviewChatController>
|
||||
agentId: string
|
||||
agentSoulConfig?: AgentSoulConfig
|
||||
clearChatList: boolean
|
||||
config: AgentPreviewChatConfig
|
||||
conversationId?: string | null
|
||||
currentModel?: AgentComposerModel
|
||||
draftType?: 'debug_build'
|
||||
initialChatTree: ChatItemInTree[]
|
||||
inputs: Inputs
|
||||
inputsForm: InputForm[]
|
||||
sendButtonLabel?: string
|
||||
speechToTextTarget: SpeechToTextTarget
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string, workflowRunId?: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onCurrentSessionConversationIdChange: (conversationId: string) => void
|
||||
onRuntimeStateChange: (state: AgentPreviewChatRuntimeState) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const sendInterruptedRef = useRef(false)
|
||||
const [isSendPending, setIsSendPending] = useState(false)
|
||||
const notifySendInterrupted = useCallback(() => {
|
||||
if (sendInterruptedRef.current) return
|
||||
|
||||
sendInterruptedRef.current = true
|
||||
onSendInterrupted?.()
|
||||
}, [onSendInterrupted])
|
||||
const { textGenerationModelList } =
|
||||
useTextGenerationCurrentProviderAndModelAndModelList(currentModel)
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
isResponding,
|
||||
handleSend,
|
||||
suggestedQuestions,
|
||||
handleStop,
|
||||
handleAnnotationAdded,
|
||||
handleAnnotationEdited,
|
||||
handleAnnotationRemoved,
|
||||
} = useChat(
|
||||
config,
|
||||
{
|
||||
inputs,
|
||||
inputsForm,
|
||||
},
|
||||
initialChatTree,
|
||||
(taskId) => {
|
||||
void stopAgentChatMessageResponding(agentId, taskId)
|
||||
},
|
||||
clearChatList,
|
||||
onClearChatListChange,
|
||||
conversationId ?? undefined,
|
||||
{ isNewAgent: true },
|
||||
)
|
||||
|
||||
const doSend: OnSend = useCallback(
|
||||
async (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
sendInterruptedRef.current = false
|
||||
setIsSendPending(true)
|
||||
let sendStarted = false
|
||||
|
||||
try {
|
||||
const preparedAgentSoulConfig = await onSaveDraftBeforeRun?.()
|
||||
const runtimeAgentSoulConfig = preparedAgentSoulConfig || agentSoulConfig
|
||||
const runtimeInputsForm = preparedAgentSoulConfig
|
||||
? getAgentSoulInputsForm(runtimeAgentSoulConfig)
|
||||
: inputsForm
|
||||
const runtimeInputs = preparedAgentSoulConfig
|
||||
? getAgentSoulInputs(runtimeInputsForm)
|
||||
: inputs
|
||||
const runtimeConfig = preparedAgentSoulConfig
|
||||
? buildChatConfig({
|
||||
agentSoulConfig: runtimeAgentSoulConfig,
|
||||
currentModel: undefined,
|
||||
prompt: runtimeAgentSoulConfig?.prompt?.system_prompt ?? '',
|
||||
})
|
||||
: config
|
||||
|
||||
const currentProvider = textGenerationModelList.find(
|
||||
(item) => item.provider === runtimeConfig.model.provider,
|
||||
)
|
||||
const selectedModel = currentProvider?.models.find(
|
||||
(model) => model.model === runtimeConfig.model.name,
|
||||
)
|
||||
const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
const data: Record<string, unknown> = {
|
||||
query: message,
|
||||
inputs: runtimeInputs,
|
||||
overrideInputsForm: runtimeInputsForm,
|
||||
parent_message_id:
|
||||
(isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
if (draftType) data.draft_type = draftType
|
||||
|
||||
if (files?.length && supportVision) data.files = files
|
||||
|
||||
handleSend(`agent/${agentId}/chat-messages`, data as Parameters<typeof handleSend>[1], {
|
||||
onGetConversationMessages: async (conversationId) => {
|
||||
return queryClient.fetchQuery({
|
||||
...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
staleTime: 0,
|
||||
})
|
||||
},
|
||||
onGetSuggestedQuestions: (responseItemId) =>
|
||||
fetchAgentSuggestedQuestions(agentId, responseItemId),
|
||||
onUnhandledEvent: (event) => {
|
||||
if (event.event !== 'error' || typeof event.message !== 'string') return
|
||||
|
||||
return {
|
||||
conversationId:
|
||||
typeof event.conversation_id === 'string' ? event.conversation_id : undefined,
|
||||
messageId: typeof event.message_id === 'string' ? event.message_id : undefined,
|
||||
errorMessage: event.message,
|
||||
errorCode: typeof event.code === 'string' ? event.code : undefined,
|
||||
}
|
||||
},
|
||||
onConversationComplete: (completedConversationId, workflowRunId) => {
|
||||
if (completedConversationId && completedConversationId !== conversationId)
|
||||
onCurrentSessionConversationIdChange(completedConversationId)
|
||||
onConversationIdChange?.(completedConversationId)
|
||||
onConversationComplete?.(completedConversationId, workflowRunId)
|
||||
},
|
||||
onSendSettled: (hasError) => {
|
||||
setIsSendPending(false)
|
||||
if (hasError) notifySendInterrupted()
|
||||
},
|
||||
})
|
||||
sendStarted = true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
if (!sendStarted) setIsSendPending(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
chatList,
|
||||
config,
|
||||
conversationId,
|
||||
draftType,
|
||||
handleSend,
|
||||
inputs,
|
||||
inputsForm,
|
||||
notifySendInterrupted,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onSaveDraftBeforeRun,
|
||||
queryClient,
|
||||
textGenerationModelList,
|
||||
],
|
||||
)
|
||||
|
||||
const doStopResponding = useCallback(() => {
|
||||
handleStop()
|
||||
notifySendInterrupted()
|
||||
}, [handleStop, notifySendInterrupted])
|
||||
|
||||
const doRegenerate = useCallback(
|
||||
(chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => {
|
||||
const question = editedQuestion
|
||||
? chatItem
|
||||
: chatList.find((item) => item.id === chatItem.parentMessageId)
|
||||
if (!question) return
|
||||
|
||||
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 isEmptyChat = chatList.length === 0
|
||||
const sendButtonLoading = isEmptyChat && !!sendButtonLabel && (isSendPending || isResponding)
|
||||
useImperativeHandle(ref, () => ({ send: doSend }), [doSend])
|
||||
useLayoutEffect(() => {
|
||||
onRuntimeStateChange({
|
||||
isEmptyChat,
|
||||
isResponding,
|
||||
isSendPending,
|
||||
})
|
||||
}, [isEmptyChat, isResponding, isSendPending, onRuntimeStateChange])
|
||||
|
||||
return (
|
||||
<Chat
|
||||
config={config}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
chatList={chatList}
|
||||
isResponding={isResponding}
|
||||
sendButtonLabel={isEmptyChat ? sendButtonLabel : undefined}
|
||||
sendButtonLoading={sendButtonLoading}
|
||||
chatContainerClassName={cn('pt-6', isEmptyChat ? 'px-12 pt-2 !pb-[88px]' : 'px-3')}
|
||||
chatFooterClassName={isEmptyChat ? 'hidden' : 'px-3 pb-0 pt-10'}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
onSend={doSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
onRegenerate={doRegenerate}
|
||||
switchSibling={(siblingMessageId) => setTargetMessageId(siblingMessageId)}
|
||||
onStopResponding={doStopResponding}
|
||||
noChatInput
|
||||
showRegenerate
|
||||
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
|
||||
onAnnotationEdited={handleAnnotationEdited}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationRemoved={handleAnnotationRemoved}
|
||||
renderAgentContent={AgentRosterResponseContent}
|
||||
noSpacing
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
import type {
|
||||
AgentThought,
|
||||
MessageDetailResponse,
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { FeedbackType, IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { ChatItemInTree } from '@/app/components/base/chat/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { MessageRating } from '@/models/log'
|
||||
import type { TransferMethod } from '@/types/app'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import { buildChatItemTree } from '@/app/components/base/chat/utils'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||
|
||||
const toFileResponse = (
|
||||
file: NonNullable<MessageDetailResponse['message_files']>[number],
|
||||
): FileResponse => ({
|
||||
related_id: file.id ?? file.upload_file_id,
|
||||
extension: '',
|
||||
filename: file.filename,
|
||||
size: file.size ?? 0,
|
||||
mime_type: file.mime_type ?? '',
|
||||
transfer_method: file.transfer_method as TransferMethod,
|
||||
type: file.type,
|
||||
url: file.url ?? '',
|
||||
upload_file_id: file.upload_file_id ?? '',
|
||||
remote_url: file.url ?? '',
|
||||
})
|
||||
|
||||
const toLogMessages = (
|
||||
message: MessageDetailResponse['message'],
|
||||
answer: string,
|
||||
files: MessageDetailResponse['message_files'],
|
||||
) => {
|
||||
if (!Array.isArray(message)) return []
|
||||
|
||||
const logMessages = message as IChatItem['log']
|
||||
if (logMessages?.at(-1)?.role === 'assistant') return logMessages
|
||||
|
||||
return [
|
||||
...(logMessages ?? []),
|
||||
{
|
||||
role: 'assistant',
|
||||
text: answer,
|
||||
files: getProcessedFilesFromResponse(
|
||||
(files?.filter((file) => file.belongs_to === 'assistant') || []).map(toFileResponse),
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function toToolLabels(value: AgentThought['tool_labels']): ThoughtItem['tool_labels'] {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
|
||||
const toolLabels: NonNullable<ThoughtItem['tool_labels']> = {}
|
||||
for (const [name, label] of Object.entries(value)) {
|
||||
if (!label || typeof label !== 'object' || Array.isArray(label)) continue
|
||||
|
||||
const enUS = 'en_US' in label ? label.en_US : undefined
|
||||
const zhHans = 'zh_Hans' in label ? label.zh_Hans : undefined
|
||||
if (typeof enUS !== 'string' || typeof zhHans !== 'string') continue
|
||||
|
||||
toolLabels[name] = { en_US: enUS, zh_Hans: zhHans }
|
||||
for (const [locale, localizedLabel] of Object.entries(label)) {
|
||||
if (typeof localizedLabel === 'string') toolLabels[name][locale] = localizedLabel
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(toolLabels).length ? toolLabels : undefined
|
||||
}
|
||||
|
||||
const toAgentThoughtItem = (thought: AgentThought, conversationId: string): ThoughtItem => ({
|
||||
id: thought.id,
|
||||
tool: thought.tool ?? '',
|
||||
thought: thought.thought ?? '',
|
||||
answer: thought.answer ?? '',
|
||||
tool_input: thought.tool_input ?? '',
|
||||
tool_labels: toToolLabels(thought.tool_labels),
|
||||
message_id: thought.message_id,
|
||||
conversation_id: conversationId,
|
||||
observation: thought.observation ?? '',
|
||||
position: thought.position,
|
||||
files: thought.files,
|
||||
})
|
||||
|
||||
const toFeedback = (
|
||||
feedback: NonNullable<MessageDetailResponse['feedbacks']>[number] | undefined,
|
||||
): FeedbackType | undefined => {
|
||||
if (!feedback) return undefined
|
||||
|
||||
const rating = feedback.rating as MessageRating
|
||||
if (rating !== 'like' && rating !== 'dislike' && rating !== null) return undefined
|
||||
|
||||
return {
|
||||
rating,
|
||||
content: feedback.content,
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastWorkflowRunId(messages: MessageDetailResponse[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const workflowRunId = messages[index]?.workflow_run_id
|
||||
if (workflowRunId) return workflowRunId
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function getFormattedAgentDebugChatTree(
|
||||
messages: MessageDetailResponse[],
|
||||
): ChatItemInTree[] {
|
||||
const chatList: IChatItem[] = []
|
||||
|
||||
messages.forEach((item) => {
|
||||
const answer = item.answer ?? ''
|
||||
const questionFiles = item.message_files?.filter((file) => file.belongs_to === 'user') || []
|
||||
const answerFiles = item.message_files?.filter((file) => file.belongs_to === 'assistant') || []
|
||||
const answerTokens = item.answer_tokens ?? 0
|
||||
const messageTokens = item.message_tokens ?? 0
|
||||
const latency = item.provider_response_latency ?? 0
|
||||
|
||||
chatList.push({
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
message_files: getProcessedFilesFromResponse(questionFiles.map(toFileResponse)),
|
||||
parentMessageId: item.parent_message_id || undefined,
|
||||
})
|
||||
chatList.push({
|
||||
id: item.id,
|
||||
content: answer,
|
||||
agent_thoughts: addFileInfos(
|
||||
sortAgentSorts(
|
||||
(item.agent_thoughts ?? []).map((thought) =>
|
||||
toAgentThoughtItem(thought, item.conversation_id),
|
||||
),
|
||||
),
|
||||
item.message_files as unknown as FileEntity[],
|
||||
),
|
||||
feedback: toFeedback(item.feedbacks?.find((feedback) => feedback.from_source === 'user')),
|
||||
isAnswer: true,
|
||||
log: toLogMessages(item.message, answer, item.message_files),
|
||||
message_files: getProcessedFilesFromResponse(answerFiles.map(toFileResponse)),
|
||||
parentMessageId: `question-${item.id}`,
|
||||
workflow_run_id: item.workflow_run_id ?? undefined,
|
||||
conversationId: item.conversation_id,
|
||||
input: {
|
||||
inputs: item.inputs,
|
||||
query: item.query,
|
||||
},
|
||||
more: {
|
||||
time: '',
|
||||
tokens: answerTokens + messageTokens,
|
||||
latency: latency.toFixed(2),
|
||||
tokens_per_second: latency > 0 ? (answerTokens / latency).toFixed(2) : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return buildChatItemTree(chatList)
|
||||
}
|
||||
@ -1,475 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AgentCliToolConfig,
|
||||
AgentIconType,
|
||||
AgentSoulConfig,
|
||||
AgentSoulDifyToolConfig,
|
||||
AgentThought,
|
||||
MessageDetailResponse,
|
||||
} from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
FeedbackType,
|
||||
IChatItem,
|
||||
InputForm,
|
||||
ThoughtItem,
|
||||
} from '@/app/components/base/chat/chat/type'
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import type { AgentComposerModel } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import type { MessageRating } from '@/models/log'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { skipToken, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentRosterResponseContent } from '@/app/components/base/chat/chat/answer/agent-roster-response-content'
|
||||
import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area'
|
||||
import { useChat } from '@/app/components/base/chat/chat/hooks'
|
||||
import {
|
||||
buildChatItemTree,
|
||||
getLastAnswer,
|
||||
isValidGeneratedAnswer,
|
||||
} from '@/app/components/base/chat/utils'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||
import { InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
|
||||
import { userProfileAtom } from '@/context/account-state'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
|
||||
import { PromptMode } from '@/models/debug'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { consoleClient, consoleQuery } from '@/service/client'
|
||||
import { AgentStrategy, ModelModeType, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
|
||||
|
||||
const Chat = dynamic(() => import('@/app/components/base/chat/chat'), { ssr: false })
|
||||
|
||||
type AgentPreviewChatConfig = ChatConfig & {
|
||||
model: {
|
||||
provider: string
|
||||
name: string
|
||||
mode: ModelModeType
|
||||
completion_params: {
|
||||
max_tokens: number
|
||||
temperature: number
|
||||
top_p: number
|
||||
echo: boolean
|
||||
stop: string[]
|
||||
presence_penalty: number
|
||||
frequency_penalty: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSystemParameters: ChatConfig['system_parameters'] = {
|
||||
audio_file_size_limit: 0,
|
||||
file_size_limit: 0,
|
||||
image_file_size_limit: 0,
|
||||
video_file_size_limit: 0,
|
||||
workflow_file_upload_limit: 0,
|
||||
}
|
||||
|
||||
const disabledFileUploadConfig = {
|
||||
enabled: false,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
allowed_file_types: [],
|
||||
fileUploadConfig: defaultSystemParameters,
|
||||
image: {
|
||||
enabled: false,
|
||||
transfer_methods: [],
|
||||
number_limits: 0,
|
||||
image_file_size_limit: 0,
|
||||
detail: 'low',
|
||||
},
|
||||
max_length: 0,
|
||||
number_limits: 0,
|
||||
} as ChatConfig['file_upload'] & {
|
||||
fileUploadConfig: ChatConfig['system_parameters']
|
||||
}
|
||||
|
||||
const defaultFileUploadMethods = [TransferMethod.local_file, TransferMethod.remote_url]
|
||||
|
||||
const toPreviewFileUploadConfig = (fileUpload: FileUpload | undefined) => {
|
||||
if (!fileUpload?.enabled) return disabledFileUploadConfig
|
||||
|
||||
const allowedFileUploadMethods = fileUpload.allowed_file_upload_methods?.length
|
||||
? fileUpload.allowed_file_upload_methods
|
||||
: defaultFileUploadMethods
|
||||
const numberLimits = fileUpload.number_limits ?? fileUpload.image?.number_limits ?? 3
|
||||
|
||||
return {
|
||||
...disabledFileUploadConfig,
|
||||
...fileUpload,
|
||||
enabled: true,
|
||||
allowed_file_types: fileUpload.allowed_file_types?.length
|
||||
? fileUpload.allowed_file_types
|
||||
: [SupportUploadFileTypes.image],
|
||||
allowed_file_upload_methods: allowedFileUploadMethods,
|
||||
number_limits: numberLimits,
|
||||
fileUploadConfig: fileUpload.fileUploadConfig ?? defaultSystemParameters,
|
||||
image: {
|
||||
...disabledFileUploadConfig.image,
|
||||
...fileUpload.image,
|
||||
enabled: fileUpload.image?.enabled ?? true,
|
||||
transfer_methods: fileUpload.image?.transfer_methods?.length
|
||||
? fileUpload.image.transfer_methods
|
||||
: allowedFileUploadMethods,
|
||||
number_limits: numberLimits,
|
||||
},
|
||||
} as ChatConfig['file_upload']
|
||||
}
|
||||
|
||||
const getModelSettings = (agentSoulConfig?: AgentSoulConfig) =>
|
||||
agentSoulConfig?.model?.model_settings ?? {}
|
||||
|
||||
const toEnabledConfig = (config?: { enabled?: boolean } | null) => ({
|
||||
...config,
|
||||
enabled: config?.enabled ?? false,
|
||||
})
|
||||
|
||||
const toInputType = (type: string): InputVarType => {
|
||||
if (type === InputVarType.paragraph) return InputVarType.paragraph
|
||||
if (type === InputVarType.select) return InputVarType.select
|
||||
if (type === InputVarType.number) return InputVarType.number
|
||||
if (type === InputVarType.json || type === InputVarType.jsonObject) return InputVarType.json
|
||||
|
||||
return InputVarType.textInput
|
||||
}
|
||||
|
||||
const toInputForm = (variable: NonNullable<AgentSoulConfig['app_variables']>[number]) => {
|
||||
const variableKey = String(variable.name)
|
||||
|
||||
return {
|
||||
...variable,
|
||||
key: variableKey,
|
||||
label: variableKey,
|
||||
variable: variableKey,
|
||||
type: toInputType(variable.type),
|
||||
required: variable.required ?? true,
|
||||
hide: false,
|
||||
}
|
||||
}
|
||||
|
||||
const getAgentSoulInputsForm = (agentSoulConfig?: AgentSoulConfig) =>
|
||||
(agentSoulConfig?.app_variables ?? []).map(toInputForm)
|
||||
|
||||
const getAgentSoulInputs = (inputsForm: InputForm[]) => {
|
||||
return inputsForm.reduce<Inputs>((acc, input) => {
|
||||
acc[input.variable] = (input.default ?? '') as Inputs[string]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const toAgentTool = (tool: AgentSoulDifyToolConfig) => ({
|
||||
provider_id: tool.provider_id ?? tool.provider ?? tool.plugin_id ?? '',
|
||||
provider_type: tool.provider_type ?? 'builtin',
|
||||
provider_name: tool.provider ?? '',
|
||||
tool_name: tool.tool_name,
|
||||
tool_label: tool.name ?? tool.tool_name,
|
||||
tool_parameters: tool.runtime_parameters ?? {},
|
||||
enabled: tool.enabled ?? true,
|
||||
credential_id: tool.credential_ref?.id,
|
||||
})
|
||||
|
||||
const toCliTool = (tool: AgentCliToolConfig) => ({
|
||||
provider_id: 'agent-v2-cli',
|
||||
provider_type: 'builtin',
|
||||
provider_name: 'CLI',
|
||||
tool_name: tool.tool_name ?? tool.name ?? '',
|
||||
tool_label: tool.name ?? tool.tool_name ?? '',
|
||||
tool_parameters: {
|
||||
command: tool.command,
|
||||
},
|
||||
enabled: tool.enabled ?? true,
|
||||
})
|
||||
|
||||
const toLegacyPreviewDatasetConfigs = (
|
||||
knowledge?: AgentSoulConfig['knowledge'],
|
||||
): NonNullable<ChatConfig['dataset_configs']> => {
|
||||
// Temporary preview adapter: composer state is knowledge.sets, but this
|
||||
// legacy chat preview contract still accepts one flat dataset_configs block.
|
||||
// Preview currently flattens the first configured set only.
|
||||
const previewKnowledgeSet = knowledge?.sets?.[0]
|
||||
const datasets = previewKnowledgeSet?.datasets ?? []
|
||||
const retrieval = previewKnowledgeSet?.retrieval
|
||||
|
||||
return {
|
||||
retrieval_model: retrieval?.mode === 'single' ? RETRIEVE_TYPE.oneWay : RETRIEVE_TYPE.multiWay,
|
||||
reranking_model: {
|
||||
reranking_provider_name: retrieval?.reranking_model?.provider ?? '',
|
||||
reranking_model_name: retrieval?.reranking_model?.model ?? '',
|
||||
},
|
||||
top_k: retrieval?.top_k ?? 4,
|
||||
score_threshold_enabled:
|
||||
retrieval?.score_threshold !== undefined && retrieval?.score_threshold !== null,
|
||||
score_threshold: retrieval?.score_threshold ?? 0.8,
|
||||
datasets: {
|
||||
datasets: datasets
|
||||
.map((dataset) => ({
|
||||
enabled: true,
|
||||
id: dataset.id ?? '',
|
||||
}))
|
||||
.filter((dataset) => dataset.id),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const stopAgentChatMessageResponding = (agentId: string, taskId: string) => {
|
||||
return consoleClient.agent.byAgentId.chatMessages.byTaskId.stop.post({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
task_id: taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const toFileResponse = (
|
||||
file: NonNullable<MessageDetailResponse['message_files']>[number],
|
||||
): FileResponse => ({
|
||||
related_id: file.id ?? file.upload_file_id,
|
||||
extension: '',
|
||||
filename: file.filename,
|
||||
size: file.size ?? 0,
|
||||
mime_type: file.mime_type ?? '',
|
||||
transfer_method: file.transfer_method as TransferMethod,
|
||||
type: file.type,
|
||||
url: file.url ?? '',
|
||||
upload_file_id: file.upload_file_id ?? '',
|
||||
remote_url: file.url ?? '',
|
||||
})
|
||||
|
||||
const toLogMessages = (
|
||||
message: MessageDetailResponse['message'],
|
||||
answer: string,
|
||||
files: MessageDetailResponse['message_files'],
|
||||
) => {
|
||||
if (!Array.isArray(message)) return []
|
||||
|
||||
const logMessages = message as IChatItem['log']
|
||||
if (logMessages?.at(-1)?.role === 'assistant') return logMessages
|
||||
|
||||
return [
|
||||
...(logMessages ?? []),
|
||||
{
|
||||
role: 'assistant',
|
||||
text: answer,
|
||||
files: getProcessedFilesFromResponse(
|
||||
(files?.filter((file) => file.belongs_to === 'assistant') || []).map(toFileResponse),
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function toToolLabels(value: AgentThought['tool_labels']): ThoughtItem['tool_labels'] {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
||||
|
||||
const toolLabels: NonNullable<ThoughtItem['tool_labels']> = {}
|
||||
for (const [name, label] of Object.entries(value)) {
|
||||
if (!label || typeof label !== 'object' || Array.isArray(label)) continue
|
||||
|
||||
const enUS = 'en_US' in label ? label.en_US : undefined
|
||||
const zhHans = 'zh_Hans' in label ? label.zh_Hans : undefined
|
||||
if (typeof enUS !== 'string' || typeof zhHans !== 'string') continue
|
||||
|
||||
toolLabels[name] = { en_US: enUS, zh_Hans: zhHans }
|
||||
for (const [locale, localizedLabel] of Object.entries(label)) {
|
||||
if (typeof localizedLabel === 'string') toolLabels[name][locale] = localizedLabel
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(toolLabels).length ? toolLabels : undefined
|
||||
}
|
||||
|
||||
const toAgentThoughtItem = (thought: AgentThought, conversationId: string): ThoughtItem => ({
|
||||
id: thought.id,
|
||||
tool: thought.tool ?? '',
|
||||
thought: thought.thought ?? '',
|
||||
answer: thought.answer ?? '',
|
||||
tool_input: thought.tool_input ?? '',
|
||||
tool_labels: toToolLabels(thought.tool_labels),
|
||||
message_id: thought.message_id,
|
||||
conversation_id: conversationId,
|
||||
observation: thought.observation ?? '',
|
||||
position: thought.position,
|
||||
files: thought.files,
|
||||
})
|
||||
|
||||
const toFeedback = (
|
||||
feedback: NonNullable<MessageDetailResponse['feedbacks']>[number] | undefined,
|
||||
): FeedbackType | undefined => {
|
||||
if (!feedback) return undefined
|
||||
|
||||
const rating = feedback.rating as MessageRating
|
||||
if (rating !== 'like' && rating !== 'dislike' && rating !== null) return undefined
|
||||
|
||||
return {
|
||||
rating,
|
||||
content: feedback.content,
|
||||
}
|
||||
}
|
||||
|
||||
const getAgentDebugMessageAnswer = (message: MessageDetailResponse) => {
|
||||
return message.answer ?? ''
|
||||
}
|
||||
|
||||
function getLastWorkflowRunId(messages: MessageDetailResponse[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const workflowRunId = messages[index]?.workflow_run_id
|
||||
if (workflowRunId) return workflowRunId
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getFormattedAgentDebugChatTree(messages: MessageDetailResponse[]): ChatItemInTree[] {
|
||||
const chatList: IChatItem[] = []
|
||||
|
||||
messages.forEach((item) => {
|
||||
const answer = getAgentDebugMessageAnswer(item)
|
||||
const questionFiles = item.message_files?.filter((file) => file.belongs_to === 'user') || []
|
||||
const answerFiles = item.message_files?.filter((file) => file.belongs_to === 'assistant') || []
|
||||
const answerTokens = item.answer_tokens ?? 0
|
||||
const messageTokens = item.message_tokens ?? 0
|
||||
const latency = item.provider_response_latency ?? 0
|
||||
|
||||
chatList.push({
|
||||
id: `question-${item.id}`,
|
||||
content: item.query,
|
||||
isAnswer: false,
|
||||
message_files: getProcessedFilesFromResponse(questionFiles.map(toFileResponse)),
|
||||
parentMessageId: item.parent_message_id || undefined,
|
||||
})
|
||||
chatList.push({
|
||||
id: item.id,
|
||||
content: answer,
|
||||
agent_thoughts: addFileInfos(
|
||||
sortAgentSorts(
|
||||
(item.agent_thoughts ?? []).map((thought) =>
|
||||
toAgentThoughtItem(thought, item.conversation_id),
|
||||
),
|
||||
),
|
||||
item.message_files as unknown as FileEntity[],
|
||||
),
|
||||
feedback: toFeedback(item.feedbacks?.find((feedback) => feedback.from_source === 'user')),
|
||||
isAnswer: true,
|
||||
log: toLogMessages(item.message, answer, item.message_files),
|
||||
message_files: getProcessedFilesFromResponse(answerFiles.map(toFileResponse)),
|
||||
parentMessageId: `question-${item.id}`,
|
||||
workflow_run_id: item.workflow_run_id ?? undefined,
|
||||
conversationId: item.conversation_id,
|
||||
input: {
|
||||
inputs: item.inputs,
|
||||
query: item.query,
|
||||
},
|
||||
more: {
|
||||
time: '',
|
||||
tokens: answerTokens + messageTokens,
|
||||
latency: latency.toFixed(2),
|
||||
tokens_per_second: latency > 0 ? (answerTokens / latency).toFixed(2) : undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return buildChatItemTree(chatList)
|
||||
}
|
||||
|
||||
const fetchAgentSuggestedQuestions = (agentId: string, messageId: string) => {
|
||||
return consoleClient.agent.byAgentId.chatMessages.byMessageId.suggestedQuestions.get({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
message_id: messageId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const buildChatConfig = ({
|
||||
agentSoulConfig,
|
||||
currentModel,
|
||||
prompt,
|
||||
}: {
|
||||
agentSoulConfig?: AgentSoulConfig
|
||||
currentModel?: AgentComposerModel
|
||||
prompt: string
|
||||
}): AgentPreviewChatConfig => {
|
||||
const modelSettings = currentModel?.model_settings ?? getModelSettings(agentSoulConfig)
|
||||
const appFeatures = agentSoulConfig?.app_features ?? {}
|
||||
const difyTools = agentSoulConfig?.tools?.dify_tools ?? []
|
||||
const cliTools = ENABLE_AGENT_CLI_TOOLS ? (agentSoulConfig?.tools?.cli_tools ?? []) : []
|
||||
|
||||
return {
|
||||
pre_prompt: prompt || agentSoulConfig?.prompt?.system_prompt || '',
|
||||
prompt_type: PromptMode.simple,
|
||||
chat_prompt_config: DEFAULT_CHAT_PROMPT_CONFIG,
|
||||
completion_prompt_config: DEFAULT_COMPLETION_PROMPT_CONFIG,
|
||||
user_input_form: (agentSoulConfig?.app_variables ?? []).map((variable) => ({
|
||||
'text-input': {
|
||||
default: String(variable.default ?? ''),
|
||||
label: variable.name,
|
||||
variable: variable.name,
|
||||
required: variable.required ?? true,
|
||||
max_length: 48,
|
||||
hide: false,
|
||||
},
|
||||
})),
|
||||
dataset_query_variable: '',
|
||||
opening_statement: appFeatures.opening_statement ?? '',
|
||||
suggested_questions: appFeatures.suggested_questions ?? [],
|
||||
suggested_questions_after_answer: toEnabledConfig(
|
||||
appFeatures.suggested_questions_after_answer,
|
||||
) as ChatConfig['suggested_questions_after_answer'],
|
||||
more_like_this: { enabled: false },
|
||||
text_to_speech: toEnabledConfig(appFeatures.text_to_speech) as ChatConfig['text_to_speech'],
|
||||
speech_to_text: toEnabledConfig(appFeatures.speech_to_text),
|
||||
retriever_resource: toEnabledConfig(appFeatures.retriever_resource),
|
||||
sensitive_word_avoidance: toEnabledConfig(appFeatures.sensitive_word_avoidance),
|
||||
annotation_reply: {
|
||||
id: '',
|
||||
enabled: false,
|
||||
score_threshold: 0.9,
|
||||
embedding_model: {
|
||||
embedding_provider_name: '',
|
||||
embedding_model_name: '',
|
||||
},
|
||||
},
|
||||
agent_mode: {
|
||||
enabled: difyTools.length > 0 || cliTools.length > 0,
|
||||
strategy: AgentStrategy.functionCall,
|
||||
tools: [
|
||||
...difyTools.map(toAgentTool),
|
||||
...cliTools.map(toCliTool),
|
||||
] as ChatConfig['agent_mode']['tools'],
|
||||
},
|
||||
model: {
|
||||
provider: currentModel?.provider ?? agentSoulConfig?.model?.model_provider ?? '',
|
||||
name: currentModel?.model ?? agentSoulConfig?.model?.model ?? '',
|
||||
mode: ModelModeType.chat,
|
||||
completion_params: {
|
||||
temperature: modelSettings.temperature ?? 0.7,
|
||||
top_p: modelSettings.top_p ?? 1,
|
||||
max_tokens: modelSettings.max_tokens ?? 512,
|
||||
presence_penalty: modelSettings.presence_penalty ?? 0,
|
||||
frequency_penalty: modelSettings.frequency_penalty ?? 0,
|
||||
stop: modelSettings.stop ?? [],
|
||||
echo: false,
|
||||
},
|
||||
},
|
||||
dataset_configs: toLegacyPreviewDatasetConfigs(agentSoulConfig?.knowledge),
|
||||
file_upload: toPreviewFileUploadConfig(appFeatures.file_upload as FileUpload | undefined),
|
||||
system_parameters: defaultSystemParameters,
|
||||
supportCitationHitInfo: true,
|
||||
}
|
||||
}
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getFormattedAgentDebugChatTree, getLastWorkflowRunId } from './chat-history'
|
||||
import { AgentPreviewChatSession } from './chat-session'
|
||||
|
||||
export type AgentChatRuntimeEmptyStateProps = {
|
||||
agentIcon?: string | null
|
||||
@ -477,7 +15,6 @@ export type AgentChatRuntimeEmptyStateProps = {
|
||||
agentIconType?: AgentIconType | null
|
||||
agentName?: string
|
||||
hasInstructions: boolean
|
||||
inputNode: ReactNode
|
||||
}
|
||||
|
||||
export type AgentChatRuntimeProps = {
|
||||
@ -568,14 +105,17 @@ export function AgentChatRuntime({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chatSessionKey =
|
||||
const inputSessionKey =
|
||||
!conversationId || conversationBelongsToCurrentSession ? 'current-session' : conversationId
|
||||
const conversationSessionKey =
|
||||
!conversationId || conversationBelongsToCurrentSession
|
||||
? 'current-session'
|
||||
: `${conversationId}-${historyQuery.dataUpdatedAt}`
|
||||
|
||||
return (
|
||||
<AgentPreviewChatSession
|
||||
key={chatSessionKey}
|
||||
key={inputSessionKey}
|
||||
conversationSessionKey={conversationSessionKey}
|
||||
agentId={agentId}
|
||||
agentIcon={agentIcon}
|
||||
agentIconBackground={agentIconBackground}
|
||||
@ -600,309 +140,3 @@ export function AgentChatRuntime({
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentPreviewChatSession({
|
||||
agentId,
|
||||
agentIcon,
|
||||
agentIconBackground,
|
||||
agentIconType,
|
||||
agentName,
|
||||
agentSoulConfig,
|
||||
clearChatList,
|
||||
conversationId,
|
||||
draftType,
|
||||
initialChatTree,
|
||||
inputPlaceholder,
|
||||
inputAutoFocus,
|
||||
sendButtonLabel,
|
||||
renderEmptyState,
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onBeforeSpeechToText,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: {
|
||||
agentId: string
|
||||
agentIcon?: string | null
|
||||
agentIconBackground?: string | null
|
||||
agentIconType?: AgentIconType | null
|
||||
agentName?: string
|
||||
agentSoulConfig?: AgentSoulConfig
|
||||
clearChatList: boolean
|
||||
conversationId?: string | null
|
||||
draftType?: 'debug_build'
|
||||
initialChatTree: ChatItemInTree[]
|
||||
inputPlaceholder: string
|
||||
inputAutoFocus?: boolean
|
||||
sendButtonLabel?: string
|
||||
renderEmptyState: (props: AgentChatRuntimeEmptyStateProps) => ReactNode
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string, workflowRunId?: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onCurrentSessionConversationIdChange: (conversationId: string) => void
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const queryClient = useQueryClient()
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const prompt = useAtomValue(agentComposerPromptAtom)
|
||||
const currentModel = useAtomValue(agentComposerModelAtom)
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildChatConfig({
|
||||
agentSoulConfig,
|
||||
currentModel,
|
||||
prompt,
|
||||
}),
|
||||
[agentSoulConfig, currentModel, prompt],
|
||||
)
|
||||
const inputsForm = useMemo(() => getAgentSoulInputsForm(agentSoulConfig), [agentSoulConfig])
|
||||
const inputs = useMemo(() => getAgentSoulInputs(inputsForm), [inputsForm])
|
||||
const sendInterruptedRef = useRef(false)
|
||||
const [isSendPending, setIsSendPending] = useState(false)
|
||||
const notifySendInterrupted = useCallback(() => {
|
||||
if (sendInterruptedRef.current) return
|
||||
|
||||
sendInterruptedRef.current = true
|
||||
onSendInterrupted?.()
|
||||
}, [onSendInterrupted])
|
||||
const { textGenerationModelList } =
|
||||
useTextGenerationCurrentProviderAndModelAndModelList(currentModel)
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
isResponding,
|
||||
handleSend,
|
||||
suggestedQuestions,
|
||||
handleStop,
|
||||
handleAnnotationAdded,
|
||||
handleAnnotationEdited,
|
||||
handleAnnotationRemoved,
|
||||
} = useChat(
|
||||
config,
|
||||
{
|
||||
inputs,
|
||||
inputsForm,
|
||||
},
|
||||
initialChatTree,
|
||||
(taskId) => {
|
||||
void stopAgentChatMessageResponding(agentId, taskId)
|
||||
},
|
||||
clearChatList,
|
||||
onClearChatListChange,
|
||||
conversationId ?? undefined,
|
||||
{ isNewAgent: true },
|
||||
)
|
||||
|
||||
const doSend: OnSend = useCallback(
|
||||
async (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
sendInterruptedRef.current = false
|
||||
setIsSendPending(true)
|
||||
let sendStarted = false
|
||||
|
||||
try {
|
||||
const preparedAgentSoulConfig = await onSaveDraftBeforeRun?.()
|
||||
const runtimeAgentSoulConfig = preparedAgentSoulConfig || agentSoulConfig
|
||||
const runtimeInputsForm = preparedAgentSoulConfig
|
||||
? getAgentSoulInputsForm(runtimeAgentSoulConfig)
|
||||
: inputsForm
|
||||
const runtimeInputs = preparedAgentSoulConfig
|
||||
? getAgentSoulInputs(runtimeInputsForm)
|
||||
: inputs
|
||||
const runtimeConfig = preparedAgentSoulConfig
|
||||
? buildChatConfig({
|
||||
agentSoulConfig: runtimeAgentSoulConfig,
|
||||
currentModel: undefined,
|
||||
prompt: runtimeAgentSoulConfig?.prompt?.system_prompt ?? '',
|
||||
})
|
||||
: config
|
||||
|
||||
const currentProvider = textGenerationModelList.find(
|
||||
(item) => item.provider === runtimeConfig.model.provider,
|
||||
)
|
||||
const selectedModel = currentProvider?.models.find(
|
||||
(model) => model.model === runtimeConfig.model.name,
|
||||
)
|
||||
const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
const data: Record<string, unknown> = {
|
||||
query: message,
|
||||
inputs: runtimeInputs,
|
||||
overrideInputsForm: runtimeInputsForm,
|
||||
parent_message_id:
|
||||
(isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
if (draftType) data.draft_type = draftType
|
||||
|
||||
if (files?.length && supportVision) data.files = files
|
||||
|
||||
handleSend(`agent/${agentId}/chat-messages`, data as Parameters<typeof handleSend>[1], {
|
||||
onGetConversationMessages: async (conversationId) => {
|
||||
return queryClient.fetchQuery({
|
||||
...consoleQuery.agent.byAgentId.chatMessages.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
staleTime: 0,
|
||||
})
|
||||
},
|
||||
onGetSuggestedQuestions: (responseItemId) =>
|
||||
fetchAgentSuggestedQuestions(agentId, responseItemId),
|
||||
onUnhandledEvent: (event) => {
|
||||
if (event.event !== 'error' || typeof event.message !== 'string') return
|
||||
|
||||
return {
|
||||
conversationId:
|
||||
typeof event.conversation_id === 'string' ? event.conversation_id : undefined,
|
||||
messageId: typeof event.message_id === 'string' ? event.message_id : undefined,
|
||||
errorMessage: event.message,
|
||||
errorCode: typeof event.code === 'string' ? event.code : undefined,
|
||||
}
|
||||
},
|
||||
onConversationComplete: (completedConversationId, workflowRunId) => {
|
||||
if (completedConversationId && completedConversationId !== conversationId)
|
||||
onCurrentSessionConversationIdChange(completedConversationId)
|
||||
onConversationIdChange?.(completedConversationId)
|
||||
onConversationComplete?.(completedConversationId, workflowRunId)
|
||||
},
|
||||
onSendSettled: (hasError) => {
|
||||
setIsSendPending(false)
|
||||
if (hasError) notifySendInterrupted()
|
||||
},
|
||||
})
|
||||
sendStarted = true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
if (!sendStarted) setIsSendPending(false)
|
||||
}
|
||||
},
|
||||
[
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
chatList,
|
||||
config,
|
||||
conversationId,
|
||||
draftType,
|
||||
handleSend,
|
||||
inputs,
|
||||
inputsForm,
|
||||
notifySendInterrupted,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onSaveDraftBeforeRun,
|
||||
queryClient,
|
||||
textGenerationModelList,
|
||||
],
|
||||
)
|
||||
|
||||
const doStopResponding = useCallback(() => {
|
||||
handleStop()
|
||||
notifySendInterrupted()
|
||||
}, [handleStop, notifySendInterrupted])
|
||||
|
||||
const doRegenerate = useCallback(
|
||||
(chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => {
|
||||
const question = editedQuestion
|
||||
? chatItem
|
||||
: chatList.find((item) => item.id === chatItem.parentMessageId)
|
||||
if (!question) return
|
||||
|
||||
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 isEmptyChat = chatList.length === 0
|
||||
const hasInstructions = !!config.pre_prompt.trim()
|
||||
const sendButtonLoading = isEmptyChat && !!sendButtonLabel && (isSendPending || isResponding)
|
||||
const sandboxNotice = t(($) => $['agentDetail.configure.preview.sandboxNotice'])
|
||||
const sandboxNoticeTooltip = t(($) => $['agentDetail.configure.preview.sandboxNoticeTooltip'])
|
||||
const showSandboxNotice = isEmptyChat && !isSendPending && !isResponding
|
||||
const speechToTextTarget: SpeechToTextTarget = {
|
||||
type: 'agent',
|
||||
agentId,
|
||||
draftType: draftType ?? 'draft',
|
||||
}
|
||||
const emptyChatInputNode = (
|
||||
<div className="pointer-events-auto mt-5 w-full">
|
||||
<ChatInputArea
|
||||
botName={agentName || 'Agent'}
|
||||
customPlaceholder={inputPlaceholder}
|
||||
disabled={isResponding}
|
||||
// Build chat opts out so it does not steal focus from the configure editor.
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={inputAutoFocus}
|
||||
sendButtonLoading={sendButtonLoading}
|
||||
showFileUpload={false}
|
||||
visionConfig={config.file_upload}
|
||||
speechToTextConfig={config.speech_to_text}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
onSend={doSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
sendButtonLabel={sendButtonLabel}
|
||||
footerNotice={showSandboxNotice ? sandboxNotice : undefined}
|
||||
footerNoticeTooltip={showSandboxNotice ? sandboxNoticeTooltip : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Chat
|
||||
config={config}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
chatList={chatList}
|
||||
isResponding={isResponding}
|
||||
sendButtonLoading={sendButtonLoading}
|
||||
chatNode={
|
||||
isEmptyChat
|
||||
? renderEmptyState({
|
||||
agentIcon,
|
||||
agentIconBackground,
|
||||
agentIconType,
|
||||
agentName,
|
||||
hasInstructions,
|
||||
inputNode: emptyChatInputNode,
|
||||
})
|
||||
: null
|
||||
}
|
||||
chatContainerClassName={cn('pt-6', isEmptyChat ? 'px-12 pt-2 !pb-[88px]' : 'px-3')}
|
||||
chatFooterClassName={cn('!bottom-2 pb-0', isEmptyChat ? 'hidden' : 'px-3 pt-10')}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
sendButtonLabel={isEmptyChat ? sendButtonLabel : undefined}
|
||||
showFileUpload={false}
|
||||
suggestedQuestions={suggestedQuestions}
|
||||
onSend={doSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
onRegenerate={doRegenerate}
|
||||
switchSibling={(siblingMessageId) => setTargetMessageId(siblingMessageId)}
|
||||
onStopResponding={doStopResponding}
|
||||
noChatInput={isEmptyChat}
|
||||
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
|
||||
onAnnotationEdited={handleAnnotationEdited}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationRemoved={handleAnnotationRemoved}
|
||||
renderAgentContent={AgentRosterResponseContent}
|
||||
noSpacing
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,200 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentPreviewChatController, AgentPreviewChatRuntimeState } from './chat-conversation'
|
||||
import type { AgentChatRuntimeEmptyStateProps } from './chat-runtime'
|
||||
import type { ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
import { buildChatConfig, getAgentSoulInputs, getAgentSoulInputsForm } from './chat-config'
|
||||
import { AgentPreviewChatConversation } from './chat-conversation'
|
||||
|
||||
export function AgentPreviewChatSession({
|
||||
conversationSessionKey,
|
||||
agentId,
|
||||
agentIcon,
|
||||
agentIconBackground,
|
||||
agentIconType,
|
||||
agentName,
|
||||
agentSoulConfig,
|
||||
clearChatList,
|
||||
conversationId,
|
||||
draftType,
|
||||
initialChatTree,
|
||||
inputPlaceholder,
|
||||
inputAutoFocus,
|
||||
sendButtonLabel,
|
||||
renderEmptyState,
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onBeforeSpeechToText,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: {
|
||||
conversationSessionKey: string
|
||||
agentId: string
|
||||
agentIcon?: string | null
|
||||
agentIconBackground?: string | null
|
||||
agentIconType?: AgentIconType | null
|
||||
agentName?: string
|
||||
agentSoulConfig?: AgentSoulConfig
|
||||
clearChatList: boolean
|
||||
conversationId?: string | null
|
||||
draftType?: 'debug_build'
|
||||
initialChatTree: ChatItemInTree[]
|
||||
inputPlaceholder: string
|
||||
inputAutoFocus?: boolean
|
||||
sendButtonLabel?: string
|
||||
renderEmptyState: (props: AgentChatRuntimeEmptyStateProps) => ReactNode
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string, workflowRunId?: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onCurrentSessionConversationIdChange: (conversationId: string) => void
|
||||
onBeforeSpeechToText?: () => Promise<unknown>
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const prompt = useAtomValue(agentComposerPromptAtom)
|
||||
const currentModel = useAtomValue(agentComposerModelAtom)
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildChatConfig({
|
||||
agentSoulConfig,
|
||||
currentModel,
|
||||
prompt,
|
||||
}),
|
||||
[agentSoulConfig, currentModel, prompt],
|
||||
)
|
||||
const inputsForm = useMemo(() => getAgentSoulInputsForm(agentSoulConfig), [agentSoulConfig])
|
||||
const inputs = useMemo(() => getAgentSoulInputs(inputsForm), [inputsForm])
|
||||
const conversationRef = useRef<AgentPreviewChatController>(null)
|
||||
const [runtimeState, setRuntimeState] = useState<AgentPreviewChatRuntimeState>(() => ({
|
||||
isEmptyChat: initialChatTree.length === 0 && !config.opening_statement,
|
||||
isResponding: false,
|
||||
isSendPending: false,
|
||||
}))
|
||||
const handleRuntimeStateChange = useCallback((nextState: AgentPreviewChatRuntimeState) => {
|
||||
setRuntimeState((currentState) => {
|
||||
if (
|
||||
currentState.isEmptyChat === nextState.isEmptyChat &&
|
||||
currentState.isResponding === nextState.isResponding &&
|
||||
currentState.isSendPending === nextState.isSendPending
|
||||
)
|
||||
return currentState
|
||||
|
||||
return nextState
|
||||
})
|
||||
}, [])
|
||||
const handleInputSend: OnSend = useCallback(
|
||||
(
|
||||
message: string,
|
||||
files?: FileEntity[],
|
||||
isRegenerate: boolean = false,
|
||||
parentAnswer: ChatItem | null = null,
|
||||
) => conversationRef.current?.send(message, files, isRegenerate, parentAnswer),
|
||||
[],
|
||||
)
|
||||
const { isEmptyChat, isResponding, isSendPending } = runtimeState
|
||||
const hasInstructions = !!config.pre_prompt.trim()
|
||||
const sendButtonLoading = isEmptyChat && !!sendButtonLabel && (isSendPending || isResponding)
|
||||
const sandboxNotice = t(($) => $['agentDetail.configure.preview.sandboxNotice'])
|
||||
const sandboxNoticeTooltip = t(($) => $['agentDetail.configure.preview.sandboxNoticeTooltip'])
|
||||
const showSandboxNotice = isEmptyChat && !isSendPending && !isResponding
|
||||
const speechToTextTarget: SpeechToTextTarget = {
|
||||
type: 'agent',
|
||||
agentId,
|
||||
draftType: draftType ?? 'draft',
|
||||
}
|
||||
const chatInputNode = (
|
||||
<ChatInputArea
|
||||
botName={agentName || 'Agent'}
|
||||
customPlaceholder={inputPlaceholder}
|
||||
disabled={isEmptyChat && isResponding}
|
||||
// Build chat opts out so it does not steal focus from the configure editor.
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={isEmptyChat ? inputAutoFocus : undefined}
|
||||
sendButtonLoading={sendButtonLoading}
|
||||
showFileUpload={false}
|
||||
visionConfig={config.file_upload}
|
||||
speechToTextConfig={config.speech_to_text}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
onSend={handleInputSend}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
isResponding={isEmptyChat ? undefined : isResponding}
|
||||
sendButtonLabel={isEmptyChat ? sendButtonLabel : undefined}
|
||||
footerNotice={showSandboxNotice ? sandboxNotice : undefined}
|
||||
footerNoticeTooltip={showSandboxNotice ? sandboxNoticeTooltip : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1">
|
||||
<AgentPreviewChatConversation
|
||||
key={conversationSessionKey}
|
||||
ref={conversationRef}
|
||||
agentId={agentId}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
clearChatList={clearChatList}
|
||||
config={config}
|
||||
conversationId={conversationId}
|
||||
currentModel={currentModel}
|
||||
draftType={draftType}
|
||||
initialChatTree={initialChatTree}
|
||||
inputs={inputs}
|
||||
inputsForm={inputsForm}
|
||||
sendButtonLabel={sendButtonLabel}
|
||||
speechToTextTarget={speechToTextTarget}
|
||||
onBeforeSpeechToText={onBeforeSpeechToText}
|
||||
onClearChatListChange={onClearChatListChange}
|
||||
onConversationComplete={onConversationComplete}
|
||||
onConversationIdChange={onConversationIdChange}
|
||||
onCurrentSessionConversationIdChange={onCurrentSessionConversationIdChange}
|
||||
onRuntimeStateChange={handleRuntimeStateChange}
|
||||
onSaveDraftBeforeRun={onSaveDraftBeforeRun}
|
||||
onSendInterrupted={onSendInterrupted}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-testid={isEmptyChat ? undefined : 'agent-chat-footer'}
|
||||
className={cn(
|
||||
'z-10 shrink-0',
|
||||
isEmptyChat ? 'absolute inset-0 flex items-center justify-center' : 'relative px-3 pb-2',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
isEmptyChat
|
||||
? 'flex w-full max-w-150 flex-col items-start p-3 text-left'
|
||||
: 'pointer-events-none relative w-full',
|
||||
)}
|
||||
>
|
||||
{isEmptyChat &&
|
||||
renderEmptyState({
|
||||
agentIcon,
|
||||
agentIconBackground,
|
||||
agentIconType,
|
||||
agentName,
|
||||
hasInstructions,
|
||||
})}
|
||||
<div className={cn(isEmptyChat && 'pointer-events-auto mt-5 w-full')}>
|
||||
{chatInputNode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -13,40 +13,36 @@ function AgentPreviewChatEmptyState({
|
||||
agentIconType,
|
||||
agentName,
|
||||
hasInstructions,
|
||||
inputNode,
|
||||
}: AgentChatRuntimeEmptyStateProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const imageUrl = agentIconType === 'image' || agentIconType === 'link' ? agentIcon : undefined
|
||||
const iconType = imageUrl ? 'image' : agentIconType
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex w-full max-w-150 flex-col items-start p-3 text-left">
|
||||
<AppIcon
|
||||
size="xxl"
|
||||
rounded
|
||||
iconType={iconType}
|
||||
icon={agentIcon ?? undefined}
|
||||
background={agentIconBackground}
|
||||
imageUrl={imageUrl}
|
||||
className="bg-background-default"
|
||||
/>
|
||||
<div className="mt-3 max-w-full truncate system-md-medium text-text-secondary">
|
||||
{t(($) => $['agentDetail.configure.preview.empty.title'], {
|
||||
name: agentName || t(($) => $['agentDetail.configure.preview.empty.defaultAgentName']),
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.preview.empty.description'])}
|
||||
</p>
|
||||
{!hasInstructions && (
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.preview.empty.noInstructionsDescription'])}
|
||||
</p>
|
||||
)}
|
||||
{inputNode}
|
||||
<>
|
||||
<AppIcon
|
||||
size="xxl"
|
||||
rounded
|
||||
iconType={iconType}
|
||||
icon={agentIcon ?? undefined}
|
||||
background={agentIconBackground}
|
||||
imageUrl={imageUrl}
|
||||
className="bg-background-default"
|
||||
/>
|
||||
<div className="mt-3 max-w-full truncate system-md-medium text-text-secondary">
|
||||
{t(($) => $['agentDetail.configure.preview.empty.title'], {
|
||||
name: agentName || t(($) => $['agentDetail.configure.preview.empty.defaultAgentName']),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.preview.empty.description'])}
|
||||
</p>
|
||||
{!hasInstructions && (
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t(($) => $['agentDetail.configure.preview.empty.noInstructionsDescription'])}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user