mirror of https://github.com/langgenius/dify.git
fix(chat): fix image re-render due to opener remount (#33816)
This commit is contained in:
parent
955a475021
commit
b0566b4193
|
|
@ -141,6 +141,145 @@ describe('useChat', () => {
|
|||
expect(result.current.chatList[0].suggestedQuestions).toEqual(['Ask Bob'])
|
||||
})
|
||||
|
||||
describe('opening statement referential stability', () => {
|
||||
it('should keep the same item reference across multiple streaming chatTree mutations', () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const config = {
|
||||
opening_statement: 'Welcome!',
|
||||
suggested_questions: ['Q1', 'Q2'],
|
||||
}
|
||||
const { result } = renderHook(() => useChat(config as ChatConfig))
|
||||
|
||||
const openerInitial = result.current.chatList[0]
|
||||
expect(openerInitial.isOpeningStatement).toBe(true)
|
||||
expect(openerInitial.content).toBe('Welcome!')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('url', { query: 'hello' }, {})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
|
||||
})
|
||||
expect(result.current.chatList[0]).toBe(openerInitial)
|
||||
|
||||
act(() => {
|
||||
callbacks.onData('chunk-1 ', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' })
|
||||
})
|
||||
expect(result.current.chatList.length).toBeGreaterThan(1)
|
||||
expect(result.current.chatList[0]).toBe(openerInitial)
|
||||
|
||||
act(() => {
|
||||
callbacks.onData('chunk-2 ', false, { messageId: 'm-1' })
|
||||
})
|
||||
expect(result.current.chatList[0]).toBe(openerInitial)
|
||||
|
||||
act(() => {
|
||||
callbacks.onData('chunk-3', false, { messageId: 'm-1' })
|
||||
callbacks.onMessageEnd({ metadata: { retriever_resources: [] } })
|
||||
callbacks.onWorkflowFinished({ data: { status: 'succeeded' } })
|
||||
callbacks.onCompleted()
|
||||
})
|
||||
expect(result.current.chatList[0]).toBe(openerInitial)
|
||||
expect(result.current.chatList.at(-1)!.content).toBe('chunk-1 chunk-2 chunk-3')
|
||||
})
|
||||
|
||||
it('should keep stable reference when getIntroduction identity changes but output is identical', () => {
|
||||
const config = {
|
||||
opening_statement: 'Hello {{name}}',
|
||||
suggested_questions: ['Ask about {{name}}'],
|
||||
}
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ fs }) => useChat(config as ChatConfig, fs as UseChatFormSettings),
|
||||
{ initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } } },
|
||||
)
|
||||
|
||||
const openerBefore = result.current.chatList[0]
|
||||
expect(openerBefore.content).toBe('Hello Alice')
|
||||
expect(openerBefore.suggestedQuestions).toEqual(['Ask about Alice'])
|
||||
|
||||
rerender({ fs: { inputs: { name: 'Alice' }, inputsForm: [] } })
|
||||
|
||||
expect(result.current.chatList[0]).toBe(openerBefore)
|
||||
})
|
||||
|
||||
it('should produce a new item when the processed content actually changes', () => {
|
||||
const config = {
|
||||
opening_statement: 'Hello {{name}}',
|
||||
suggested_questions: ['Ask {{name}}'],
|
||||
}
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ fs }) => useChat(config as ChatConfig, fs as UseChatFormSettings),
|
||||
{ initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } } },
|
||||
)
|
||||
|
||||
const before = result.current.chatList[0]
|
||||
|
||||
rerender({ fs: { inputs: { name: 'Bob' }, inputsForm: [] } })
|
||||
|
||||
const after = result.current.chatList[0]
|
||||
expect(after).not.toBe(before)
|
||||
expect(after.content).toBe('Hello Bob')
|
||||
expect(after.suggestedQuestions).toEqual(['Ask Bob'])
|
||||
})
|
||||
|
||||
it('should keep content and suggestedQuestions stable for opener already in prevChatTree even when sibling metadata changes', () => {
|
||||
let callbacks: HookCallbacks
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const config = {
|
||||
opening_statement: 'Hello updated',
|
||||
suggested_questions: ['S1'],
|
||||
}
|
||||
const prevChatTree = [{
|
||||
id: 'opening-statement',
|
||||
content: 'old',
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: [],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChat(config as ChatConfig, undefined, prevChatTree as ChatItemInTree[]),
|
||||
)
|
||||
|
||||
const openerBefore = result.current.chatList[0]
|
||||
expect(openerBefore.content).toBe('Hello updated')
|
||||
expect(openerBefore.suggestedQuestions).toEqual(['S1'])
|
||||
|
||||
const contentBefore = openerBefore.content
|
||||
const suggestionsBefore = openerBefore.suggestedQuestions
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('url', { query: 'msg' }, {})
|
||||
})
|
||||
act(() => {
|
||||
callbacks.onData('resp', true, { messageId: 'm-1', conversationId: 'c-1', taskId: 't-1' })
|
||||
})
|
||||
|
||||
expect(result.current.chatList.length).toBeGreaterThan(1)
|
||||
const openerAfter = result.current.chatList[0]
|
||||
expect(openerAfter.content).toBe(contentBefore)
|
||||
expect(openerAfter.suggestedQuestions).toBe(suggestionsBefore)
|
||||
})
|
||||
|
||||
it('should use a stable id of "opening-statement"', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useChat({ opening_statement: 'Hi' } as ChatConfig),
|
||||
)
|
||||
expect(result.current.chatList[0].id).toBe('opening-statement')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSend', () => {
|
||||
it('should block send if already responding', async () => {
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
|
|
|||
|
|
@ -88,30 +88,54 @@ export const useChat = (
|
|||
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
|
||||
}, [formSettings?.inputs, formSettings?.inputsForm])
|
||||
|
||||
const processedOpeningContent = config?.opening_statement
|
||||
? getIntroduction(config.opening_statement)
|
||||
: undefined
|
||||
const processedSuggestionsKey = config?.suggested_questions
|
||||
? JSON.stringify(config.suggested_questions.map(q => getIntroduction(q)))
|
||||
: undefined
|
||||
|
||||
const openingStatementItem = useMemo<ChatItemInTree | null>(() => {
|
||||
if (!processedOpeningContent)
|
||||
return null
|
||||
return {
|
||||
id: 'opening-statement',
|
||||
content: processedOpeningContent,
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: processedSuggestionsKey
|
||||
? JSON.parse(processedSuggestionsKey) as string[]
|
||||
: undefined,
|
||||
}
|
||||
}, [processedOpeningContent, processedSuggestionsKey])
|
||||
|
||||
const threadOpener = useMemo(
|
||||
() => threadMessages.find(item => item.isOpeningStatement) ?? null,
|
||||
[threadMessages],
|
||||
)
|
||||
|
||||
const mergedOpeningItem = useMemo<ChatItemInTree | null>(() => {
|
||||
if (!threadOpener || !openingStatementItem)
|
||||
return null
|
||||
return {
|
||||
...threadOpener,
|
||||
content: openingStatementItem.content,
|
||||
suggestedQuestions: openingStatementItem.suggestedQuestions,
|
||||
}
|
||||
}, [threadOpener, openingStatementItem])
|
||||
|
||||
/** Final chat list that will be rendered */
|
||||
const chatList = useMemo(() => {
|
||||
const ret = [...threadMessages]
|
||||
if (config?.opening_statement) {
|
||||
if (openingStatementItem) {
|
||||
const index = threadMessages.findIndex(item => item.isOpeningStatement)
|
||||
if (index > -1) {
|
||||
ret[index] = {
|
||||
...ret[index],
|
||||
content: getIntroduction(config.opening_statement),
|
||||
suggestedQuestions: config.suggested_questions?.map(item => getIntroduction(item)),
|
||||
}
|
||||
}
|
||||
else {
|
||||
ret.unshift({
|
||||
id: 'opening-statement',
|
||||
content: getIntroduction(config.opening_statement),
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: config.suggested_questions?.map(item => getIntroduction(item)),
|
||||
})
|
||||
}
|
||||
if (index > -1 && mergedOpeningItem)
|
||||
ret[index] = mergedOpeningItem
|
||||
else if (index === -1)
|
||||
ret.unshift(openingStatementItem)
|
||||
}
|
||||
return ret
|
||||
}, [threadMessages, config, getIntroduction])
|
||||
}, [threadMessages, openingStatementItem, mergedOpeningItem])
|
||||
|
||||
useEffect(() => {
|
||||
setAutoFreeze(false)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import type { ChatItemInTree } from '@/app/components/base/chat/types'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useChat } from '../hooks'
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
sseGet: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidAllLastRun: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
submitHumanInputForm: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({ notify: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({ getState: () => ({}) }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks', () => ({
|
||||
useWorkflowRun: () => ({ handleRun: vi.fn() }),
|
||||
useSetWorkflowVarsWithValue: () => ({ fetchInspectVars: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks-store', () => ({
|
||||
useHooksStore: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setIterTimes: vi.fn(),
|
||||
setLoopTimes: vi.fn(),
|
||||
inputs: {},
|
||||
}),
|
||||
}),
|
||||
useStore: () => vi.fn(),
|
||||
}))
|
||||
|
||||
describe('workflow debug useChat – opening statement stability', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return empty chatList when config has no opening_statement', () => {
|
||||
const { result } = renderHook(() => useChat({}))
|
||||
expect(result.current.chatList).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty chatList when opening_statement is an empty string', () => {
|
||||
const { result } = renderHook(() => useChat({ opening_statement: '' }))
|
||||
expect(result.current.chatList).toEqual([])
|
||||
})
|
||||
|
||||
it('should use stable id "opening-statement" instead of Date.now()', () => {
|
||||
const config = { opening_statement: 'Welcome!' }
|
||||
|
||||
const { result } = renderHook(() => useChat(config))
|
||||
expect(result.current.chatList[0].id).toBe('opening-statement')
|
||||
})
|
||||
|
||||
it('should preserve reference when inputs change but produce identical content', () => {
|
||||
const config = {
|
||||
opening_statement: 'Hello {{name}}',
|
||||
suggested_questions: ['Ask {{name}}'],
|
||||
}
|
||||
const formSettings = { inputs: { name: 'Alice' }, inputsForm: [] }
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ fs }) => useChat(config, fs),
|
||||
{ initialProps: { fs: formSettings } },
|
||||
)
|
||||
|
||||
const openerBefore = result.current.chatList[0]
|
||||
expect(openerBefore.content).toBe('Hello Alice')
|
||||
|
||||
rerender({ fs: { inputs: { name: 'Alice' }, inputsForm: [] } })
|
||||
|
||||
const openerAfter = result.current.chatList[0]
|
||||
expect(openerAfter).toBe(openerBefore)
|
||||
})
|
||||
|
||||
it('should create new object when content actually changes', () => {
|
||||
const config = {
|
||||
opening_statement: 'Hello {{name}}',
|
||||
suggested_questions: [],
|
||||
}
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ fs }) => useChat(config, fs),
|
||||
{ initialProps: { fs: { inputs: { name: 'Alice' }, inputsForm: [] } } },
|
||||
)
|
||||
|
||||
const openerBefore = result.current.chatList[0]
|
||||
expect(openerBefore.content).toBe('Hello Alice')
|
||||
|
||||
rerender({ fs: { inputs: { name: 'Bob' }, inputsForm: [] } })
|
||||
|
||||
const openerAfter = result.current.chatList[0]
|
||||
expect(openerAfter.content).toBe('Hello Bob')
|
||||
expect(openerAfter).not.toBe(openerBefore)
|
||||
})
|
||||
|
||||
it('should preserve reference for existing opening statement in prevChatTree', () => {
|
||||
const config = {
|
||||
opening_statement: 'Updated welcome',
|
||||
suggested_questions: ['S1'],
|
||||
}
|
||||
const prevChatTree = [{
|
||||
id: 'opening-statement',
|
||||
content: 'old',
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: [],
|
||||
}]
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ cfg }) => useChat(cfg, undefined, prevChatTree as ChatItemInTree[]),
|
||||
{ initialProps: { cfg: config } },
|
||||
)
|
||||
|
||||
const openerBefore = result.current.chatList[0]
|
||||
expect(openerBefore.content).toBe('Updated welcome')
|
||||
|
||||
rerender({ cfg: config })
|
||||
|
||||
const openerAfter = result.current.chatList[0]
|
||||
expect(openerAfter).toBe(openerBefore)
|
||||
})
|
||||
})
|
||||
|
|
@ -91,31 +91,54 @@ export const useChat = (
|
|||
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
|
||||
}, [formSettings?.inputs, formSettings?.inputsForm])
|
||||
|
||||
const processedOpeningContent = config?.opening_statement
|
||||
? getIntroduction(config.opening_statement)
|
||||
: undefined
|
||||
const processedSuggestionsKey = config?.suggested_questions
|
||||
? JSON.stringify(config.suggested_questions.map((q: string) => getIntroduction(q)))
|
||||
: undefined
|
||||
|
||||
const openingStatementItem = useMemo<ChatItemInTree | null>(() => {
|
||||
if (!processedOpeningContent)
|
||||
return null
|
||||
return {
|
||||
id: 'opening-statement',
|
||||
content: processedOpeningContent,
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: processedSuggestionsKey
|
||||
? JSON.parse(processedSuggestionsKey) as string[]
|
||||
: undefined,
|
||||
}
|
||||
}, [processedOpeningContent, processedSuggestionsKey])
|
||||
|
||||
const threadOpener = useMemo(
|
||||
() => threadMessages.find(item => item.isOpeningStatement) ?? null,
|
||||
[threadMessages],
|
||||
)
|
||||
|
||||
const mergedOpeningItem = useMemo<ChatItemInTree | null>(() => {
|
||||
if (!threadOpener || !openingStatementItem)
|
||||
return null
|
||||
return {
|
||||
...threadOpener,
|
||||
content: openingStatementItem.content,
|
||||
suggestedQuestions: openingStatementItem.suggestedQuestions,
|
||||
}
|
||||
}, [threadOpener, openingStatementItem])
|
||||
|
||||
/** Final chat list that will be rendered */
|
||||
const chatList = useMemo(() => {
|
||||
const ret = [...threadMessages]
|
||||
if (config?.opening_statement) {
|
||||
if (openingStatementItem) {
|
||||
const index = threadMessages.findIndex(item => item.isOpeningStatement)
|
||||
|
||||
if (index > -1) {
|
||||
ret[index] = {
|
||||
...ret[index],
|
||||
content: getIntroduction(config.opening_statement),
|
||||
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
|
||||
}
|
||||
}
|
||||
else {
|
||||
ret.unshift({
|
||||
id: `${Date.now()}`,
|
||||
content: getIntroduction(config.opening_statement),
|
||||
isAnswer: true,
|
||||
isOpeningStatement: true,
|
||||
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
|
||||
})
|
||||
}
|
||||
if (index > -1 && mergedOpeningItem)
|
||||
ret[index] = mergedOpeningItem
|
||||
else if (index === -1)
|
||||
ret.unshift(openingStatementItem)
|
||||
}
|
||||
return ret
|
||||
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
|
||||
}, [threadMessages, openingStatementItem, mergedOpeningItem])
|
||||
|
||||
useEffect(() => {
|
||||
setAutoFreeze(false)
|
||||
|
|
|
|||
Loading…
Reference in New Issue