mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +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 }),
|
config: makeChatConfig({ supportFeedback: true }),
|
||||||
onFeedback: vi.fn().mockResolvedValue(undefined),
|
onFeedback: vi.fn().mockResolvedValue(undefined),
|
||||||
onRegenerate: vi.fn(),
|
onRegenerate: vi.fn(),
|
||||||
|
showRegenerate: false,
|
||||||
onAnnotationAdded: vi.fn(),
|
onAnnotationAdded: vi.fn(),
|
||||||
onAnnotationEdited: vi.fn(),
|
onAnnotationEdited: vi.fn(),
|
||||||
onAnnotationRemoved: vi.fn(),
|
onAnnotationRemoved: vi.fn(),
|
||||||
@ -263,6 +264,7 @@ describe('Operation', () => {
|
|||||||
mockContextValue.onAnnotationEdited = vi.fn()
|
mockContextValue.onAnnotationEdited = vi.fn()
|
||||||
mockContextValue.onAnnotationRemoved = vi.fn()
|
mockContextValue.onAnnotationRemoved = vi.fn()
|
||||||
mockContextValue.readonly = false
|
mockContextValue.readonly = false
|
||||||
|
mockContextValue.showRegenerate = false
|
||||||
mockProviderContext.plan.usage.annotatedResponse = 0
|
mockProviderContext.plan.usage.annotatedResponse = 0
|
||||||
mockProviderContext.enableBilling = false
|
mockProviderContext.enableBilling = false
|
||||||
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
|
mockAddAnnotation.mockResolvedValue({ id: 'ann-new', account: { name: 'Test User' } })
|
||||||
@ -286,6 +288,12 @@ describe('Operation', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'operation.regenerate' })).not.toBeInTheDocument()
|
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', () => {
|
it('should show TTS button when text_to_speech is enabled', () => {
|
||||||
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true } })
|
mockContextValue.config = makeChatConfig({ text_to_speech: { enabled: true } })
|
||||||
renderOperation()
|
renderOperation()
|
||||||
|
|||||||
@ -86,6 +86,7 @@ function Operation({
|
|||||||
onAnnotationRemoved,
|
onAnnotationRemoved,
|
||||||
onFeedback,
|
onFeedback,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
|
showRegenerate,
|
||||||
readonly,
|
readonly,
|
||||||
} = useChatContext()
|
} = useChatContext()
|
||||||
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
||||||
@ -389,7 +390,7 @@ function Operation({
|
|||||||
<span aria-hidden="true" className="i-ri-clipboard-line size-4" />
|
<span aria-hidden="true" className="i-ri-clipboard-line size-4" />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
{!noChatInput && (
|
{(!noChatInput || showRegenerate) && (
|
||||||
<ActionButton aria-label={regenerateLabel} onClick={() => onRegenerate?.(item)}>
|
<ActionButton aria-label={regenerateLabel} onClick={() => onRegenerate?.(item)}>
|
||||||
<span aria-hidden="true" className="i-ri-reset-left-line size-4" />
|
<span aria-hidden="true" className="i-ri-reset-left-line size-4" />
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const ChatContextProvider = ({
|
|||||||
answerIcon,
|
answerIcon,
|
||||||
onSend,
|
onSend,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
|
showRegenerate,
|
||||||
onAnnotationEdited,
|
onAnnotationEdited,
|
||||||
onAnnotationAdded,
|
onAnnotationAdded,
|
||||||
onAnnotationRemoved,
|
onAnnotationRemoved,
|
||||||
@ -38,6 +39,7 @@ export const ChatContextProvider = ({
|
|||||||
answerIcon,
|
answerIcon,
|
||||||
onSend,
|
onSend,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
|
showRegenerate,
|
||||||
onAnnotationEdited,
|
onAnnotationEdited,
|
||||||
onAnnotationAdded,
|
onAnnotationAdded,
|
||||||
onAnnotationRemoved,
|
onAnnotationRemoved,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export type ChatContextValue = Pick<
|
|||||||
| 'answerIcon'
|
| 'answerIcon'
|
||||||
| 'onSend'
|
| 'onSend'
|
||||||
| 'onRegenerate'
|
| 'onRegenerate'
|
||||||
|
| 'showRegenerate'
|
||||||
| 'onAnnotationEdited'
|
| 'onAnnotationEdited'
|
||||||
| 'onAnnotationAdded'
|
| 'onAnnotationAdded'
|
||||||
| 'onAnnotationRemoved'
|
| 'onAnnotationRemoved'
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export type ChatProps = {
|
|||||||
noStopResponding?: boolean
|
noStopResponding?: boolean
|
||||||
onStopResponding?: () => void
|
onStopResponding?: () => void
|
||||||
noChatInput?: boolean
|
noChatInput?: boolean
|
||||||
|
showRegenerate?: boolean
|
||||||
onSend?: OnSend
|
onSend?: OnSend
|
||||||
inputs?: Record<string, unknown>
|
inputs?: Record<string, unknown>
|
||||||
inputsForm?: InputForm[]
|
inputsForm?: InputForm[]
|
||||||
@ -101,6 +102,7 @@ const Chat: FC<ChatProps> = ({
|
|||||||
noStopResponding,
|
noStopResponding,
|
||||||
onStopResponding,
|
onStopResponding,
|
||||||
noChatInput,
|
noChatInput,
|
||||||
|
showRegenerate,
|
||||||
chatContainerClassName,
|
chatContainerClassName,
|
||||||
chatContainerInnerClassName,
|
chatContainerInnerClassName,
|
||||||
chatFooterClassName,
|
chatFooterClassName,
|
||||||
@ -179,6 +181,7 @@ const Chat: FC<ChatProps> = ({
|
|||||||
answerIcon={answerIcon}
|
answerIcon={answerIcon}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
|
showRegenerate={showRegenerate}
|
||||||
onAnnotationAdded={onAnnotationAdded}
|
onAnnotationAdded={onAnnotationAdded}
|
||||||
onAnnotationEdited={onAnnotationEdited}
|
onAnnotationEdited={onAnnotationEdited}
|
||||||
onAnnotationRemoved={onAnnotationRemoved}
|
onAnnotationRemoved={onAnnotationRemoved}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ComponentProps, ReactNode } from 'react'
|
|||||||
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
import type { SpeechToTextTarget } from '@/app/components/base/voice-input/types'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||||
@ -38,10 +39,22 @@ vi.mock('@/next/dynamic', async () => {
|
|||||||
showPromptLog?: boolean
|
showPromptLog?: boolean
|
||||||
footerNotice?: string
|
footerNotice?: string
|
||||||
chatNode?: ReactNode
|
chatNode?: ReactNode
|
||||||
|
chatList?: Array<{
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
children?: Array<{ id: string; content: string }>
|
||||||
|
}>
|
||||||
|
noChatInput?: boolean
|
||||||
|
showRegenerate?: boolean
|
||||||
speechToTextTarget?: SpeechToTextTarget
|
speechToTextTarget?: SpeechToTextTarget
|
||||||
onBeforeSpeechToText?: () => Promise<unknown>
|
onBeforeSpeechToText?: () => Promise<unknown>
|
||||||
}) {
|
}) {
|
||||||
const [sent, setSent] = useState(false)
|
const [sent, setSent] = useState(false)
|
||||||
|
const [initialChatList] = useState(props.chatList ?? [])
|
||||||
|
const initialVisibleMessages = initialChatList.flatMap((item) => [
|
||||||
|
item,
|
||||||
|
...(item.children ?? []),
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -51,6 +64,8 @@ vi.mock('@/next/dynamic', async () => {
|
|||||||
data-send-button-loading={String(!!props.sendButtonLoading)}
|
data-send-button-loading={String(!!props.sendButtonLoading)}
|
||||||
data-show-prompt-log={String(!!props.showPromptLog)}
|
data-show-prompt-log={String(!!props.showPromptLog)}
|
||||||
data-footer-notice={props.footerNotice ?? ''}
|
data-footer-notice={props.footerNotice ?? ''}
|
||||||
|
data-no-chat-input={String(!!props.noChatInput)}
|
||||||
|
data-show-regenerate={String(!!props.showRegenerate)}
|
||||||
data-speech-agent-id={
|
data-speech-agent-id={
|
||||||
props.speechToTextTarget?.type === 'agent' ? props.speechToTextTarget.agentId : ''
|
props.speechToTextTarget?.type === 'agent' ? props.speechToTextTarget.agentId : ''
|
||||||
}
|
}
|
||||||
@ -59,6 +74,9 @@ vi.mock('@/next/dynamic', async () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{props.chatNode}
|
{props.chatNode}
|
||||||
|
{initialVisibleMessages.map((item) => (
|
||||||
|
<span key={item.id}>{item.content}</span>
|
||||||
|
))}
|
||||||
<span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span>
|
<span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -82,24 +100,35 @@ vi.mock('@/next/dynamic', async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({
|
vi.mock('@/app/components/base/chat/chat/chat-input-area', () => ({
|
||||||
default: ({
|
default: function MockChatInputArea({
|
||||||
footerNotice,
|
footerNotice,
|
||||||
speechToTextTarget,
|
speechToTextTarget,
|
||||||
}: {
|
}: {
|
||||||
footerNotice?: ReactNode
|
footerNotice?: ReactNode
|
||||||
speechToTextTarget?: SpeechToTextTarget
|
speechToTextTarget?: SpeechToTextTarget
|
||||||
}) => (
|
}) {
|
||||||
<div
|
const [inputValue, setInputValue] = useState('')
|
||||||
role="group"
|
|
||||||
aria-label="voice input"
|
return (
|
||||||
data-speech-agent-id={speechToTextTarget?.type === 'agent' ? speechToTextTarget.agentId : ''}
|
<div
|
||||||
data-speech-draft-type={
|
role="group"
|
||||||
speechToTextTarget?.type === 'agent' ? speechToTextTarget.draftType : ''
|
aria-label="voice input"
|
||||||
}
|
data-speech-agent-id={
|
||||||
>
|
speechToTextTarget?.type === 'agent' ? speechToTextTarget.agentId : ''
|
||||||
{footerNotice}
|
}
|
||||||
</div>
|
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', () => ({
|
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', () => {
|
it('should bind Agent preview voice input to the normal Agent draft', () => {
|
||||||
renderPreviewChat({
|
renderPreviewChat({
|
||||||
renderEmptyState: ({ inputNode }) => inputNode,
|
renderEmptyState: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByRole('group', { name: 'voice input' })).toHaveAttribute(
|
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', () => {
|
it('should bind Agent build voice input to the account build draft', () => {
|
||||||
renderPreviewChat({
|
renderPreviewChat({
|
||||||
draftType: 'debug_build',
|
draftType: 'debug_build',
|
||||||
renderEmptyState: ({ inputNode }) => inputNode,
|
renderEmptyState: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(screen.getByRole('group', { name: 'voice input' })).toHaveAttribute(
|
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', () => {
|
it('should expose the owning save-before-transcribe callback', () => {
|
||||||
const onBeforeSpeechToText = vi.fn().mockResolvedValue(undefined)
|
const onBeforeSpeechToText = vi.fn().mockResolvedValue(undefined)
|
||||||
renderPreviewChat({ onBeforeSpeechToText })
|
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 () => {
|
it('should save draft before sending preview chat through the agent chat endpoints', async () => {
|
||||||
const saveDraftBeforeRun = vi.fn().mockResolvedValue(undefined)
|
const saveDraftBeforeRun = vi.fn().mockResolvedValue(undefined)
|
||||||
renderPreviewChat({
|
renderPreviewChat({
|
||||||
@ -529,7 +700,7 @@ describe('AgentPreviewChat', () => {
|
|||||||
)
|
)
|
||||||
renderPreviewChat({
|
renderPreviewChat({
|
||||||
sendButtonLabel: 'Start build',
|
sendButtonLabel: 'Start build',
|
||||||
renderEmptyState: ({ inputNode }) => <div>{inputNode}</div>,
|
renderEmptyState: () => null,
|
||||||
onSaveDraftBeforeRun: saveDraftBeforeRun,
|
onSaveDraftBeforeRun: saveDraftBeforeRun,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -605,39 +776,40 @@ describe('AgentPreviewChat', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should use the default send button after the first build message', async () => {
|
it('should use the default send button after the first build message', async () => {
|
||||||
useChatMock.mockImplementationOnce(
|
const useChatWithExistingMessage = (
|
||||||
(
|
_config: unknown,
|
||||||
_config: unknown,
|
_formSettings: unknown,
|
||||||
_formSettings: unknown,
|
_chatList: unknown[],
|
||||||
_chatList: unknown[],
|
stopCallback: (taskId: string) => void,
|
||||||
stopCallback: (taskId: string) => void,
|
) => {
|
||||||
) => {
|
stopCallbackRef.current = stopCallback
|
||||||
stopCallbackRef.current = stopCallback
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
chatList: [
|
chatList: [
|
||||||
{
|
{
|
||||||
id: 'question-1',
|
id: 'question-1',
|
||||||
content: 'Build an agent',
|
content: 'Build an agent',
|
||||||
isAnswer: false,
|
isAnswer: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'answer-1',
|
id: 'answer-1',
|
||||||
content: 'Done',
|
content: 'Done',
|
||||||
isAnswer: true,
|
isAnswer: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
setTargetMessageId: vi.fn(),
|
setTargetMessageId: vi.fn(),
|
||||||
isResponding: false,
|
isResponding: false,
|
||||||
handleSend: handleSendMock,
|
handleSend: handleSendMock,
|
||||||
suggestedQuestions: [],
|
suggestedQuestions: [],
|
||||||
handleStop: () => stopCallback('task-1'),
|
handleStop: () => stopCallback('task-1'),
|
||||||
handleAnnotationAdded: vi.fn(),
|
handleAnnotationAdded: vi.fn(),
|
||||||
handleAnnotationEdited: vi.fn(),
|
handleAnnotationEdited: vi.fn(),
|
||||||
handleAnnotationRemoved: vi.fn(),
|
handleAnnotationRemoved: vi.fn(),
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
useChatMock
|
||||||
|
.mockImplementationOnce(useChatWithExistingMessage)
|
||||||
|
.mockImplementationOnce(useChatWithExistingMessage)
|
||||||
renderPreviewChat({
|
renderPreviewChat({
|
||||||
sendButtonLabel: 'Start build',
|
sendButtonLabel: 'Start build',
|
||||||
})
|
})
|
||||||
@ -907,7 +1079,7 @@ describe('AgentPreviewChat', () => {
|
|||||||
|
|
||||||
it('should hide the sandbox notice after the first send starts', async () => {
|
it('should hide the sandbox notice after the first send starts', async () => {
|
||||||
renderPreviewChat({
|
renderPreviewChat({
|
||||||
renderEmptyState: ({ inputNode }) => <div>{inputNode}</div>,
|
renderEmptyState: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'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 { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { AgentChatRuntime } from './chat-runtime'
|
import { AgentChatRuntime } from './chat-runtime'
|
||||||
@ -26,66 +26,63 @@ type AgentBuildChatProps = Omit<
|
|||||||
'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel'
|
'inputPlaceholder' | 'renderEmptyState' | 'sendButtonLabel'
|
||||||
>
|
>
|
||||||
|
|
||||||
function AgentBuildChatEmptyState({ inputNode }: AgentChatRuntimeEmptyStateProps) {
|
function AgentBuildChatEmptyState() {
|
||||||
const { t } = useTranslation('agentV2')
|
const { t } = useTranslation('agentV2')
|
||||||
const communityEditionBuildModeTip = t(
|
const communityEditionBuildModeTip = t(
|
||||||
($) => $['agentDetail.configure.build.empty.communityEditionTip'],
|
($) => $['agentDetail.configure.build.empty.communityEditionTip'],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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="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">
|
||||||
<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) => (
|
||||||
{buildIconGridCells.map((cell) => (
|
<span
|
||||||
<span
|
key={cell.id}
|
||||||
key={cell.id}
|
className={cell.opacity > 0 ? 'rounded-[1px] bg-[#98A2B2]' : 'invisible'}
|
||||||
className={cell.opacity > 0 ? 'rounded-[1px] bg-[#98A2B2]' : 'invisible'}
|
style={{ opacity: cell.opacity }}
|
||||||
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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<PopoverContent
|
))}
|
||||||
placement="top"
|
|
||||||
popupClassName="max-w-[340px] px-3 py-2 system-xs-regular text-text-tertiary"
|
|
||||||
>
|
|
||||||
{communityEditionBuildModeTip}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
<span
|
||||||
{t(($) => $['agentDetail.configure.build.empty.description'])}
|
aria-hidden
|
||||||
</p>
|
className="absolute i-ri-hammer-line size-5 text-saas-dify-blue-inverted"
|
||||||
{inputNode}
|
/>
|
||||||
</div>
|
</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'])}
|
inputPlaceholder={t(($) => $['agentDetail.configure.build.inputPlaceholder'])}
|
||||||
inputAutoFocus={false}
|
inputAutoFocus={false}
|
||||||
sendButtonLabel={t(($) => $['agentDetail.configure.build.startBuild'])}
|
sendButtonLabel={t(($) => $['agentDetail.configure.build.startBuild'])}
|
||||||
renderEmptyState={(emptyStateProps: AgentChatRuntimeEmptyStateProps) => (
|
renderEmptyState={() => <AgentBuildChatEmptyState />}
|
||||||
<AgentBuildChatEmptyState {...emptyStateProps} />
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
'use client'
|
||||||
|
|
||||||
import type {
|
import type { AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||||
AgentCliToolConfig,
|
|
||||||
AgentIconType,
|
|
||||||
AgentSoulConfig,
|
|
||||||
AgentSoulDifyToolConfig,
|
|
||||||
AgentThought,
|
|
||||||
MessageDetailResponse,
|
|
||||||
} from '@dify/contracts/api/console/agent/types.gen'
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type {
|
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||||
FeedbackType,
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
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 Loading from '@/app/components/base/loading'
|
import Loading from '@/app/components/base/loading'
|
||||||
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
import { consoleQuery } from '@/service/client'
|
||||||
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
import { getFormattedAgentDebugChatTree, getLastWorkflowRunId } from './chat-history'
|
||||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
import { AgentPreviewChatSession } from './chat-session'
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentChatRuntimeEmptyStateProps = {
|
export type AgentChatRuntimeEmptyStateProps = {
|
||||||
agentIcon?: string | null
|
agentIcon?: string | null
|
||||||
@ -477,7 +15,6 @@ export type AgentChatRuntimeEmptyStateProps = {
|
|||||||
agentIconType?: AgentIconType | null
|
agentIconType?: AgentIconType | null
|
||||||
agentName?: string
|
agentName?: string
|
||||||
hasInstructions: boolean
|
hasInstructions: boolean
|
||||||
inputNode: ReactNode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentChatRuntimeProps = {
|
export type AgentChatRuntimeProps = {
|
||||||
@ -568,14 +105,17 @@ export function AgentChatRuntime({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const chatSessionKey =
|
const inputSessionKey =
|
||||||
|
!conversationId || conversationBelongsToCurrentSession ? 'current-session' : conversationId
|
||||||
|
const conversationSessionKey =
|
||||||
!conversationId || conversationBelongsToCurrentSession
|
!conversationId || conversationBelongsToCurrentSession
|
||||||
? 'current-session'
|
? 'current-session'
|
||||||
: `${conversationId}-${historyQuery.dataUpdatedAt}`
|
: `${conversationId}-${historyQuery.dataUpdatedAt}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentPreviewChatSession
|
<AgentPreviewChatSession
|
||||||
key={chatSessionKey}
|
key={inputSessionKey}
|
||||||
|
conversationSessionKey={conversationSessionKey}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
agentIcon={agentIcon}
|
agentIcon={agentIcon}
|
||||||
agentIconBackground={agentIconBackground}
|
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,
|
agentIconType,
|
||||||
agentName,
|
agentName,
|
||||||
hasInstructions,
|
hasInstructions,
|
||||||
inputNode,
|
|
||||||
}: AgentChatRuntimeEmptyStateProps) {
|
}: AgentChatRuntimeEmptyStateProps) {
|
||||||
const { t } = useTranslation('agentV2')
|
const { t } = useTranslation('agentV2')
|
||||||
const imageUrl = agentIconType === 'image' || agentIconType === 'link' ? agentIcon : undefined
|
const imageUrl = agentIconType === 'image' || agentIconType === 'link' ? agentIcon : undefined
|
||||||
const iconType = imageUrl ? 'image' : agentIconType
|
const iconType = imageUrl ? 'image' : agentIconType
|
||||||
|
|
||||||
return (
|
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
|
||||||
<AppIcon
|
size="xxl"
|
||||||
size="xxl"
|
rounded
|
||||||
rounded
|
iconType={iconType}
|
||||||
iconType={iconType}
|
icon={agentIcon ?? undefined}
|
||||||
icon={agentIcon ?? undefined}
|
background={agentIconBackground}
|
||||||
background={agentIconBackground}
|
imageUrl={imageUrl}
|
||||||
imageUrl={imageUrl}
|
className="bg-background-default"
|
||||||
className="bg-background-default"
|
/>
|
||||||
/>
|
<div className="mt-3 max-w-full truncate system-md-medium text-text-secondary">
|
||||||
<div className="mt-3 max-w-full truncate system-md-medium text-text-secondary">
|
{t(($) => $['agentDetail.configure.preview.empty.title'], {
|
||||||
{t(($) => $['agentDetail.configure.preview.empty.title'], {
|
name: agentName || t(($) => $['agentDetail.configure.preview.empty.defaultAgentName']),
|
||||||
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}
|
|
||||||
</div>
|
</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