fix: the input of agent chat would be replaced if conversation history updated (#39289)

This commit is contained in:
Joel 2026-07-20 16:07:12 +08:00 committed by GitHub
parent eee8d6cf7b
commit c8ccfba960
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1254 additions and 909 deletions

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ export type ChatContextValue = Pick<
| 'answerIcon'
| 'onSend'
| 'onRegenerate'
| 'showRegenerate'
| 'onAnnotationEdited'
| 'onAnnotationAdded'
| 'onAnnotationRemoved'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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