Compare commits

...

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
62fed068a9 test: enable async leak detection in vitest config
Co-authored-by: hyoban <38493346+hyoban@users.noreply.github.com>
2026-03-13 14:55:07 +00:00
copilot-swe-agent[bot]
a10aeeed59 Initial plan 2026-03-13 14:45:57 +00:00
yevanmore
194c205ed3 fix(api): allow punctuation in uploaded filenames (#33364)
Co-authored-by: Chen Yefan <cyefan2@gmail.com>
2026-03-13 21:33:09 +08:00
Coding On Star
7e1dc3c122 refactor(web): split share text-generation and add high-coverage tests (#33408)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/amd64, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, DIFY_API_IMAGE_NAME, linux/arm64, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/amd64, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, DIFY_WEB_IMAGE_NAME, linux/arm64, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-13 19:10:24 +08:00
20 changed files with 2403 additions and 656 deletions

View File

@@ -58,8 +58,9 @@ class FileService:
# get file extension
extension = os.path.splitext(filename)[1].lstrip(".").lower()
# check if filename contains invalid characters
if any(c in filename for c in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]):
# Only reject path separators here. The original filename is stored as metadata,
# while the storage key is UUID-based.
if any(c in filename for c in ["/", "\\"]):
raise ValueError("Filename contains invalid characters")
if len(filename) > 200:

View File

@@ -263,6 +263,27 @@ class TestFileService:
user=account,
)
def test_upload_file_allows_regular_punctuation_in_filename(
self, db_session_with_containers: Session, engine, mock_external_service_dependencies
):
"""
Test file upload allows punctuation that is safe when stored as metadata.
"""
account = self._create_test_account(db_session_with_containers, mock_external_service_dependencies)
filename = 'candidate?resume for "dify"<final>|v2:.txt'
content = b"test content"
mimetype = "text/plain"
upload_file = FileService(engine).upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=account,
)
assert upload_file.name == filename
def test_upload_file_filename_too_long(
self, db_session_with_containers: Session, engine, mock_external_service_dependencies
):

View File

@@ -35,7 +35,7 @@ TEST COVERAGE OVERVIEW:
- Tests hash consistency and determinism
6. Invalid Filename Handling (TestInvalidFilenameHandling)
- Validates rejection of filenames with invalid characters (/, \\, :, *, ?, ", <, >, |)
- Validates rejection of filenames with path separators (/, \\)
- Tests filename length truncation (max 200 characters)
- Prevents path traversal attacks
- Handles edge cases like empty filenames
@@ -535,30 +535,23 @@ class TestInvalidFilenameHandling:
@pytest.mark.parametrize(
"invalid_char",
["/", "\\", ":", "*", "?", '"', "<", ">", "|"],
["/", "\\"],
)
def test_filename_contains_invalid_characters(self, invalid_char):
"""Test detection of invalid characters in filename.
Security-critical test that validates rejection of dangerous filename characters.
Security-critical test that validates rejection of path separators.
These characters are blocked because they:
- / and \\ : Directory separators, could enable path traversal
- : : Drive letter separator on Windows, reserved character
- * and ? : Wildcards, could cause issues in file operations
- " : Quote character, could break command-line operations
- < and > : Redirection operators, command injection risk
- | : Pipe operator, command injection risk
Blocking these characters prevents:
- Path traversal attacks (../../etc/passwd)
- Command injection
- File system corruption
- Cross-platform compatibility issues
- ZIP entry traversal issues
- Ambiguous path handling
"""
# Arrange - Create filename with invalid character
filename = f"test{invalid_char}file.txt"
# Define complete list of invalid characters
invalid_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]
invalid_chars = ["/", "\\"]
# Act - Check if filename contains any invalid character
has_invalid_char = any(c in filename for c in invalid_chars)
@@ -570,7 +563,7 @@ class TestInvalidFilenameHandling:
"""Test that valid filenames pass validation."""
# Arrange
filename = "valid_file-name_123.txt"
invalid_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]
invalid_chars = ["/", "\\"]
# Act
has_invalid_char = any(c in filename for c in invalid_chars)
@@ -578,6 +571,16 @@ class TestInvalidFilenameHandling:
# Assert
assert has_invalid_char is False
@pytest.mark.parametrize("safe_char", [":", "*", "?", '"', "<", ">", "|"])
def test_filename_allows_safe_metadata_characters(self, safe_char):
"""Test that non-separator punctuation remains allowed in filenames."""
filename = f"candidate{safe_char}resume.txt"
invalid_chars = ["/", "\\"]
has_invalid_char = any(c in filename for c in invalid_chars)
assert has_invalid_char is False
def test_extremely_long_filename_truncation(self):
"""Test handling of extremely long filenames."""
# Arrange
@@ -904,7 +907,7 @@ class TestFilenameValidation:
"""Test that filenames with spaces are handled correctly."""
# Arrange
filename = "my document with spaces.pdf"
invalid_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]
invalid_chars = ["/", "\\"]
# Act - Check for invalid characters
has_invalid = any(c in filename for c in invalid_chars)
@@ -921,7 +924,7 @@ class TestFilenameValidation:
"مستند.txt", # Arabic
"ファイル.jpg", # Japanese
]
invalid_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]
invalid_chars = ["/", "\\"]
# Act & Assert - Unicode should be allowed
for filename in unicode_filenames:

View File

@@ -0,0 +1,235 @@
import type { AccessMode } from '@/models/access-control'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import TextGeneration from '@/app/components/share/text-generation'
const useSearchParamsMock = vi.fn(() => new URLSearchParams())
vi.mock('next/navigation', () => ({
useSearchParams: () => useSearchParamsMock(),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(() => 'pc'),
MediaType: { pc: 'pc', pad: 'pad', mobile: 'mobile' },
}))
vi.mock('@/hooks/use-app-favicon', () => ({
useAppFavicon: vi.fn(),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/i18n-config/client', () => ({
changeLanguage: vi.fn(() => Promise.resolve()),
}))
vi.mock('@/app/components/share/text-generation/run-once', () => ({
default: ({
inputs,
onInputsChange,
onSend,
runControl,
}: {
inputs: Record<string, unknown>
onInputsChange: (inputs: Record<string, unknown>) => void
onSend: () => void
runControl?: { isStopping: boolean } | null
}) => (
<div data-testid="run-once-mock">
<span data-testid="run-once-input-name">{String(inputs.name ?? '')}</span>
<button onClick={() => onInputsChange({ ...inputs, name: 'Gamma' })}>change-inputs</button>
<button onClick={onSend}>run-once</button>
<span>{runControl ? 'stop-ready' : 'idle'}</span>
</div>
),
}))
vi.mock('@/app/components/share/text-generation/run-batch', () => ({
default: ({ onSend }: { onSend: (data: string[][]) => void }) => (
<button
onClick={() => onSend([
['Name'],
['Alpha'],
['Beta'],
])}
>
run-batch
</button>
),
}))
vi.mock('@/app/components/app/text-generate/saved-items', () => ({
default: ({ list }: { list: { id: string }[] }) => <div data-testid="saved-items-mock">{list.length}</div>,
}))
vi.mock('@/app/components/share/text-generation/menu-dropdown', () => ({
default: () => <div data-testid="menu-dropdown-mock" />,
}))
vi.mock('@/app/components/share/text-generation/result', () => {
const MockResult = ({
isCallBatchAPI,
onRunControlChange,
onRunStart,
taskId,
}: {
isCallBatchAPI: boolean
onRunControlChange?: (control: { onStop: () => void, isStopping: boolean } | null) => void
onRunStart: () => void
taskId?: number
}) => {
const runControlRef = React.useRef(false)
React.useEffect(() => {
onRunStart()
}, [onRunStart])
React.useEffect(() => {
if (!isCallBatchAPI && !runControlRef.current) {
runControlRef.current = true
onRunControlChange?.({ onStop: vi.fn(), isStopping: false })
}
}, [isCallBatchAPI, onRunControlChange])
return <div data-testid={taskId ? `result-task-${taskId}` : 'result-single'} />
}
return {
default: MockResult,
}
})
const fetchSavedMessageMock = vi.fn()
vi.mock('@/service/share', async () => {
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
return {
...actual,
fetchSavedMessage: (...args: Parameters<typeof actual.fetchSavedMessage>) => fetchSavedMessageMock(...args),
removeMessage: vi.fn(),
saveMessage: vi.fn(),
}
})
const mockSystemFeatures = {
branding: {
enabled: false,
workspace_logo: null,
},
}
const mockWebAppState = {
appInfo: {
app_id: 'app-123',
site: {
title: 'Text Generation',
description: 'Share description',
default_language: 'en-US',
icon_type: 'emoji',
icon: 'robot',
icon_background: '#fff',
icon_url: '',
},
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
},
appParams: {
user_input_form: [
{
'text-input': {
label: 'Name',
variable: 'name',
required: true,
max_length: 48,
default: '',
hide: false,
},
},
],
more_like_this: {
enabled: true,
},
file_upload: {
enabled: false,
number_limits: 2,
detail: 'low',
allowed_upload_methods: ['local_file'],
},
text_to_speech: {
enabled: true,
},
system_parameters: {
image_file_size_limit: 10,
},
},
webAppAccessMode: 'public' as AccessMode,
}
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
selector({ systemFeatures: mockSystemFeatures }),
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState),
}))
describe('TextGeneration', () => {
beforeEach(() => {
vi.clearAllMocks()
useSearchParamsMock.mockReturnValue(new URLSearchParams())
fetchSavedMessageMock.mockResolvedValue({
data: [{ id: 'saved-1' }, { id: 'saved-2' }],
})
})
it('should switch between create, batch, and saved tabs after app state loads', async () => {
render(<TextGeneration />)
await waitFor(() => {
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
})
expect(screen.getByTestId('run-once-input-name')).toHaveTextContent('')
fireEvent.click(screen.getByRole('button', { name: 'change-inputs' }))
await waitFor(() => {
expect(screen.getByTestId('run-once-input-name')).toHaveTextContent('Gamma')
})
fireEvent.click(screen.getByTestId('tab-header-item-batch'))
expect(screen.getByRole('button', { name: 'run-batch' })).toBeInTheDocument()
fireEvent.click(screen.getByTestId('tab-header-item-saved'))
expect(screen.getByTestId('saved-items-mock')).toHaveTextContent('2')
fireEvent.click(screen.getByTestId('tab-header-item-create'))
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
})
it('should wire single-run stop control and clear it when batch execution starts', async () => {
render(<TextGeneration />)
await waitFor(() => {
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'run-once' }))
await waitFor(() => {
expect(screen.getByText('stop-ready')).toBeInTheDocument()
})
expect(screen.getByTestId('result-single')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('tab-header-item-batch'))
fireEvent.click(screen.getByRole('button', { name: 'run-batch' }))
await waitFor(() => {
expect(screen.getByText('idle')).toBeInTheDocument()
})
expect(screen.getByTestId('result-task-1')).toBeInTheDocument()
expect(screen.getByTestId('result-task-2')).toBeInTheDocument()
})
})

View File

@@ -6,7 +6,7 @@ import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Plan } from '@/app/components/billing/type'
import { baseProviderContextValue } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
@@ -131,6 +131,10 @@ describe('SettingsModal', () => {
})
})
afterEach(() => {
vi.useRealTimers()
})
it('should render the modal and expose the expanded settings section', async () => {
renderSettingsModal()
expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument()
@@ -212,4 +216,54 @@ describe('SettingsModal', () => {
}))
expect(mockOnClose).toHaveBeenCalled()
})
it('should clear the delayed hide-more timer when the modal unmounts after closing', () => {
vi.useFakeTimers()
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
const { unmount } = renderSettingsModal()
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
fireEvent.click(screen.getByText('common.operation.cancel'))
unmount()
expect(clearTimeoutSpy).toHaveBeenCalled()
vi.runAllTimers()
})
it('should replace the pending hide-more timer and clear the ref after the timeout completes', async () => {
const hideCallbacks: Array<() => void> = []
const originalSetTimeout = globalThis.setTimeout
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((
callback: TimerHandler,
delay?: number,
...args: unknown[]
) => {
if (delay === 200) {
hideCallbacks.push(() => {
if (typeof callback === 'function')
callback(...args)
})
return hideCallbacks.length as unknown as ReturnType<typeof setTimeout>
}
return originalSetTimeout(callback, delay, ...args)
}) as unknown as typeof setTimeout)
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
renderSettingsModal()
act(() => {
fireEvent.click(screen.getByText('common.operation.cancel'))
fireEvent.click(screen.getByText('common.operation.cancel'))
})
expect(clearTimeoutSpy).toHaveBeenCalled()
expect(hideCallbacks.length).toBeGreaterThanOrEqual(2)
act(() => {
hideCallbacks.at(-1)?.()
})
setTimeoutSpy.mockRestore()
clearTimeoutSpy.mockRestore()
})
})

View File

@@ -6,7 +6,7 @@ import type { AppIconType, AppSSO, Language } from '@/types/app'
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
import Link from 'next/link'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import AppIcon from '@/app/components/base/app-icon'
@@ -99,6 +99,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const [language, setLanguage] = useState(default_language)
const [saveLoading, setSaveLoading] = useState(false)
const { t } = useTranslation()
const hideMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [appIcon, setAppIcon] = useState<AppIconSelection>(
@@ -137,10 +138,22 @@ const SettingsModal: FC<ISettingsModalProps> = ({
: { type: 'emoji', icon, background: icon_background! })
}, [appInfo, chat_color_theme, chat_color_theme_inverted, copyright, custom_disclaimer, default_language, description, icon, icon_background, icon_type, icon_url, privacy_policy, show_workflow_steps, title, use_icon_as_answer_icon])
useEffect(() => {
return () => {
if (hideMoreTimerRef.current) {
clearTimeout(hideMoreTimerRef.current)
hideMoreTimerRef.current = null
}
}
}, [])
const onHide = () => {
onClose()
setTimeout(() => {
if (hideMoreTimerRef.current)
clearTimeout(hideMoreTimerRef.current)
hideMoreTimerRef.current = setTimeout(() => {
setIsShowMore(false)
hideMoreTimerRef.current = null
}, 200)
}
@@ -231,12 +244,12 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* header */}
<div className="pb-3 pl-6 pr-5 pt-5">
<div className="flex items-center gap-1">
<div className="title-2xl-semi-bold grow text-text-primary">{t(`${prefixSettings}.title`, { ns: 'appOverview' })}</div>
<div className="grow text-text-primary title-2xl-semi-bold">{t(`${prefixSettings}.title`, { ns: 'appOverview' })}</div>
<ActionButton className="shrink-0" onClick={onHide}>
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
<div className="system-xs-regular mt-0.5 text-text-tertiary">
<div className="mt-0.5 text-text-tertiary system-xs-regular">
<span>{t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })}</span>
</div>
</div>
@@ -245,7 +258,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* name & icon */}
<div className="flex gap-4">
<div className="grow">
<div className={cn('system-sm-semibold mb-1 py-1 text-text-secondary')}>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</div>
<div className={cn('mb-1 py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</div>
<Input
className="w-full"
value={inputInfo.title}
@@ -265,32 +278,32 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* description */}
<div className="relative">
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<Textarea
className="mt-1"
value={inputInfo.desc}
onChange={e => onDesChange(e.target.value)}
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
/>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
<p className={cn('pb-0.5 text-text-tertiary body-xs-regular')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
</div>
<Divider className="my-0 h-px" />
{/* answer icon */}
{isChat && (
<div className="w-full">
<div className="flex items-center justify-between">
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t('answerIcon.title', { ns: 'app' })}</div>
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t('answerIcon.title', { ns: 'app' })}</div>
<Switch
value={inputInfo.use_icon_as_answer_icon}
onChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
/>
</div>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{t('answerIcon.description', { ns: 'app' })}</p>
<p className="pb-0.5 text-text-tertiary body-xs-regular">{t('answerIcon.description', { ns: 'app' })}</p>
</div>
)}
{/* language */}
<div className="flex items-center">
<div className={cn('system-sm-semibold grow py-1 text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<div className={cn('grow py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<SimpleSelect
wrapperClassName="w-[200px]"
items={languages.filter(item => item.supported)}
@@ -303,8 +316,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{isChat && (
<div className="flex items-center">
<div className="grow">
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.chatColorTheme`, { ns: 'appOverview' })}</div>
<div className="body-xs-regular pb-0.5 text-text-tertiary">{t(`${prefixSettings}.chatColorThemeDesc`, { ns: 'appOverview' })}</div>
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.chatColorTheme`, { ns: 'appOverview' })}</div>
<div className="pb-0.5 text-text-tertiary body-xs-regular">{t(`${prefixSettings}.chatColorThemeDesc`, { ns: 'appOverview' })}</div>
</div>
<div className="shrink-0">
<Input
@@ -314,7 +327,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
placeholder="E.g #A020F0"
/>
<div className="flex items-center justify-between">
<p className={cn('body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
<p className={cn('text-text-tertiary body-xs-regular')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
<Switch value={inputInfo.chatColorThemeInverted} onChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
</div>
</div>
@@ -323,22 +336,22 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* workflow detail */}
<div className="w-full">
<div className="flex items-center justify-between">
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</div>
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</div>
<Switch
disabled={!(appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT)}
value={inputInfo.show_workflow_steps}
onChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
/>
</div>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</p>
<p className="pb-0.5 text-text-tertiary body-xs-regular">{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</p>
</div>
{/* more settings switch */}
<Divider className="my-0 h-px" />
{!isShowMore && (
<div className="flex cursor-pointer items-center" onClick={() => setIsShowMore(true)}>
<div className="grow">
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 text-text-tertiary body-xs-regular')}>
{t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' })}
{' '}
&
@@ -356,7 +369,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className="w-full">
<div className="flex items-center">
<div className="flex grow items-center">
<div className={cn('system-sm-semibold mr-1 py-1 text-text-secondary')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
<div className={cn('mr-1 py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
{/* upgrade button */}
{enableBilling && isFreePlan && (
<div className="h-[18px] select-none">
@@ -385,7 +398,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
/>
</Tooltip>
</div>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
<p className="pb-0.5 text-text-tertiary body-xs-regular">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
{inputInfo.copyrightSwitchValue && (
<Input
className="mt-2 h-10"
@@ -397,8 +410,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* privacy policy */}
<div className="w-full">
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.privacyPolicy`, { ns: 'appOverview' })}</div>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.privacyPolicy`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 text-text-tertiary body-xs-regular')}>
<Trans
i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
ns="appOverview"
@@ -414,8 +427,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* custom disclaimer */}
<div className="w-full">
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
<p className={cn('pb-0.5 text-text-tertiary body-xs-regular')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
<Textarea
className="mt-1"
value={inputInfo.customDisclaimer}

View File

@@ -82,8 +82,11 @@ vi.mock('@/app/components/base/voice-input', () => {
}
})
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16))
vi.stubGlobal('cancelAnimationFrame', (id: number) => clearTimeout(id))
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(Date.now())
return 0
})
vi.stubGlobal('cancelAnimationFrame', vi.fn())
vi.stubGlobal('devicePixelRatio', 1)
// Mock Canvas

View File

@@ -0,0 +1,190 @@
import type { PromptConfig } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { AppSourceType } from '@/service/share'
import { Resolution, TransferMethod } from '@/types/app'
import TextGenerationResultPanel from '../text-generation-result-panel'
import { TaskStatus } from '../types'
const resPropsSpy = vi.fn()
const resDownloadPropsSpy = vi.fn()
vi.mock('@/app/components/share/text-generation/result', () => ({
default: (props: Record<string, unknown>) => {
resPropsSpy(props)
return <div data-testid={`res-${String(props.taskId ?? 'single')}`} />
},
}))
vi.mock('@/app/components/share/text-generation/run-batch/res-download', () => ({
default: (props: Record<string, unknown>) => {
resDownloadPropsSpy(props)
return <div data-testid="res-download-mock" />
},
}))
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string', required: true },
],
}
const siteInfo: SiteInfo = {
title: 'Text Generation',
description: 'Share description',
icon_type: 'emoji',
icon: 'robot',
}
const visionConfig: VisionSettings = {
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
}
const batchTasks = [
{
id: 1,
status: TaskStatus.completed,
params: { inputs: { name: 'Alpha' } },
},
{
id: 2,
status: TaskStatus.failed,
params: { inputs: { name: 'Beta' } },
},
]
const baseProps = {
allFailedTaskList: [],
allSuccessTaskList: [],
allTaskList: batchTasks,
appId: 'app-123',
appSourceType: AppSourceType.webApp,
completionFiles: [],
controlRetry: 88,
controlSend: 77,
controlStopResponding: 66,
exportRes: [{ 'Name': 'Alpha', 'share.generation.completionResult': 'Done' }],
handleCompleted: vi.fn(),
handleRetryAllFailedTask: vi.fn(),
handleSaveMessage: vi.fn(async () => {}),
inputs: { name: 'Alice' },
isCallBatchAPI: false,
isPC: true,
isShowResultPanel: true,
isWorkflow: false,
moreLikeThisEnabled: true,
noPendingTask: true,
onHideResultPanel: vi.fn(),
onRunControlChange: vi.fn(),
onRunStart: vi.fn(),
onShowResultPanel: vi.fn(),
promptConfig,
resultExisted: true,
showTaskList: batchTasks,
siteInfo,
textToSpeechEnabled: true,
visionConfig,
}
describe('TextGenerationResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render a single result in run-once mode and pass non-batch props', () => {
render(<TextGenerationResultPanel {...baseProps} />)
expect(screen.getByTestId('res-single')).toBeInTheDocument()
expect(resPropsSpy).toHaveBeenCalledWith(expect.objectContaining({
appId: 'app-123',
appSourceType: AppSourceType.webApp,
completionFiles: [],
controlSend: 77,
controlStopResponding: 66,
hideInlineStopButton: true,
inputs: { name: 'Alice' },
isCallBatchAPI: false,
moreLikeThisEnabled: true,
taskId: undefined,
}))
expect(screen.queryByTestId('res-download-mock')).not.toBeInTheDocument()
})
it('should render batch results, download entry, loading area, and retry banner', () => {
const handleRetryAllFailedTask = vi.fn()
render(
<TextGenerationResultPanel
{...baseProps}
allFailedTaskList={[batchTasks[1]]}
allSuccessTaskList={[batchTasks[0]]}
isCallBatchAPI
noPendingTask={false}
handleRetryAllFailedTask={handleRetryAllFailedTask}
/>,
)
expect(screen.getByTestId('res-1')).toBeInTheDocument()
expect(screen.getByTestId('res-2')).toBeInTheDocument()
expect(resPropsSpy).toHaveBeenNthCalledWith(1, expect.objectContaining({
inputs: { name: 'Alpha' },
isError: false,
controlRetry: 0,
taskId: 1,
onRunControlChange: undefined,
}))
expect(resPropsSpy).toHaveBeenNthCalledWith(2, expect.objectContaining({
inputs: { name: 'Beta' },
isError: true,
controlRetry: 88,
taskId: 2,
}))
expect(screen.getByText('share.generation.executions:{"num":2}')).toBeInTheDocument()
expect(screen.getByTestId('res-download-mock')).toBeInTheDocument()
expect(resDownloadPropsSpy).toHaveBeenCalledWith(expect.objectContaining({
isMobile: false,
values: baseProps.exportRes,
}))
expect(screen.getByText('share.generation.batchFailed.info:{"num":1}')).toBeInTheDocument()
expect(screen.getByText('share.generation.batchFailed.retry')).toBeInTheDocument()
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
fireEvent.click(screen.getByText('share.generation.batchFailed.retry'))
expect(handleRetryAllFailedTask).toHaveBeenCalledTimes(1)
})
it('should toggle mobile result panel handle between show and hide actions', () => {
const onHideResultPanel = vi.fn()
const onShowResultPanel = vi.fn()
const { rerender } = render(
<TextGenerationResultPanel
{...baseProps}
isPC={false}
isShowResultPanel={true}
onHideResultPanel={onHideResultPanel}
onShowResultPanel={onShowResultPanel}
/>,
)
fireEvent.click(document.querySelector('.cursor-grab') as HTMLElement)
expect(onHideResultPanel).toHaveBeenCalledTimes(1)
rerender(
<TextGenerationResultPanel
{...baseProps}
isPC={false}
isShowResultPanel={false}
onHideResultPanel={onHideResultPanel}
onShowResultPanel={onShowResultPanel}
/>,
)
fireEvent.click(document.querySelector('.cursor-grab') as HTMLElement)
expect(onShowResultPanel).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,261 @@
import type { ComponentProps } from 'react'
import type { PromptConfig, SavedMessage } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { fireEvent, render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { Resolution, TransferMethod } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import TextGenerationSidebar from '../text-generation-sidebar'
const runOncePropsSpy = vi.fn()
const runBatchPropsSpy = vi.fn()
const savedItemsPropsSpy = vi.fn()
vi.mock('@/app/components/share/text-generation/run-once', () => ({
default: (props: Record<string, unknown>) => {
runOncePropsSpy(props)
return <div data-testid="run-once-mock" />
},
}))
vi.mock('@/app/components/share/text-generation/run-batch', () => ({
default: (props: Record<string, unknown>) => {
runBatchPropsSpy(props)
return <div data-testid="run-batch-mock" />
},
}))
vi.mock('@/app/components/app/text-generate/saved-items', () => ({
default: (props: { onStartCreateContent: () => void, list: Array<{ id: string }> }) => {
savedItemsPropsSpy(props)
return (
<div data-testid="saved-items-mock">
<span>{props.list.length}</span>
<button onClick={props.onStartCreateContent}>back-to-create</button>
</div>
)
},
}))
vi.mock('@/app/components/share/text-generation/menu-dropdown', () => ({
default: () => <div data-testid="menu-dropdown-mock" />,
}))
const promptConfig: PromptConfig = {
prompt_template: 'template',
prompt_variables: [
{ key: 'name', name: 'Name', type: 'string', required: true },
],
}
const savedMessages: SavedMessage[] = [
{ id: 'saved-1', answer: 'Answer 1' },
{ id: 'saved-2', answer: 'Answer 2' },
]
const siteInfo: SiteInfo = {
title: 'Text Generation',
description: 'Share description',
icon_type: 'emoji',
icon: 'robot',
icon_background: '#fff',
icon_url: '',
}
const visionConfig: VisionSettings = {
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
}
const baseProps: ComponentProps<typeof TextGenerationSidebar> = {
accessMode: AccessMode.PUBLIC,
allTasksRun: true,
currentTab: 'create',
customConfig: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
inputs: { name: 'Alice' },
inputsRef: { current: { name: 'Alice' } },
isInstalledApp: false,
isPC: true,
isWorkflow: false,
onBatchSend: vi.fn(),
onInputsChange: vi.fn(),
onRemoveSavedMessage: vi.fn(async () => {}),
onRunOnceSend: vi.fn(),
onTabChange: vi.fn(),
onVisionFilesChange: vi.fn(),
promptConfig,
resultExisted: false,
runControl: null,
savedMessages,
siteInfo,
systemFeatures: defaultSystemFeatures,
textToSpeechConfig: { enabled: true },
visionConfig,
}
const renderSidebar = (overrides: Partial<typeof baseProps> = {}) => {
return render(<TextGenerationSidebar {...baseProps} {...overrides} />)
}
describe('TextGenerationSidebar', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render create tab content and pass orchestration props to RunOnce', () => {
renderSidebar()
expect(screen.getByText('Text Generation')).toBeInTheDocument()
expect(screen.getByText('Share description')).toBeInTheDocument()
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
expect(runOncePropsSpy).toHaveBeenCalledWith(expect.objectContaining({
inputs: { name: 'Alice' },
promptConfig,
runControl: null,
visionConfig,
}))
expect(screen.queryByTestId('saved-items-mock')).not.toBeInTheDocument()
})
it('should render batch tab and hide saved tab for workflow apps', () => {
renderSidebar({
currentTab: 'batch',
isWorkflow: true,
})
expect(screen.getByTestId('run-batch-mock')).toBeInTheDocument()
expect(runBatchPropsSpy).toHaveBeenCalledWith(expect.objectContaining({
vars: promptConfig.prompt_variables,
isAllFinished: true,
}))
expect(screen.queryByTestId('tab-header-item-saved')).not.toBeInTheDocument()
})
it('should render saved items and allow switching back to create tab', () => {
const onTabChange = vi.fn()
renderSidebar({
currentTab: 'saved',
onTabChange,
})
expect(screen.getByTestId('saved-items-mock')).toBeInTheDocument()
expect(savedItemsPropsSpy).toHaveBeenCalledWith(expect.objectContaining({
list: baseProps.savedMessages,
isShowTextToSpeech: true,
}))
fireEvent.click(screen.getByRole('button', { name: 'back-to-create' }))
expect(onTabChange).toHaveBeenCalledWith('create')
})
it('should prefer workspace branding and hide powered-by block when branding is removed', () => {
const { rerender } = renderSidebar({
systemFeatures: {
...defaultSystemFeatures,
branding: {
...defaultSystemFeatures.branding,
enabled: true,
workspace_logo: 'https://example.com/workspace-logo.png',
},
},
})
const brandingLogo = screen.getByRole('img', { name: 'logo' })
expect(brandingLogo).toHaveAttribute('src', 'https://example.com/workspace-logo.png')
rerender(
<TextGenerationSidebar
{...baseProps}
customConfig={{
remove_webapp_brand: true,
replace_webapp_logo: '',
}}
/>,
)
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
})
it('should render mobile installed-app layout without saved badge when no saved messages exist', () => {
const { container } = renderSidebar({
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
isInstalledApp: true,
isPC: false,
resultExisted: false,
savedMessages: [],
siteInfo: {
...siteInfo,
description: '',
icon_background: '',
},
})
const root = container.firstElementChild as HTMLElement
const header = root.children[0] as HTMLElement
const body = root.children[1] as HTMLElement
expect(root).toHaveClass('rounded-l-2xl')
expect(root).not.toHaveClass('h-[calc(100%_-_64px)]')
expect(header).toHaveClass('p-4', 'pb-0')
expect(body).toHaveClass('px-4')
expect(screen.queryByText('Share description')).not.toBeInTheDocument()
})
it('should render mobile saved tab with compact spacing and no text-to-speech flag', () => {
const { container } = renderSidebar({
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
currentTab: 'saved',
isPC: false,
resultExisted: true,
textToSpeechConfig: null,
})
const root = container.firstElementChild as HTMLElement
const body = root.children[1] as HTMLElement
const footer = root.children[2] as HTMLElement
expect(root).toHaveClass('h-[calc(100%_-_64px)]')
expect(body).toHaveClass('px-4')
expect(footer).toHaveClass('px-4', 'rounded-b-2xl')
expect(savedItemsPropsSpy).toHaveBeenCalledWith(expect.objectContaining({
className: expect.stringContaining('mt-4'),
isShowTextToSpeech: undefined,
}))
})
it('should round the mobile panel body and hide branding when the webapp brand is removed', () => {
const { container } = renderSidebar({
isPC: false,
resultExisted: true,
customConfig: {
remove_webapp_brand: true,
replace_webapp_logo: '',
},
})
const root = container.firstElementChild as HTMLElement
const body = root.children[1] as HTMLElement
expect(body).toHaveClass('rounded-b-2xl')
expect(screen.queryByText('share.chat.poweredBy')).not.toBeInTheDocument()
})
it('should render the custom webapp logo when workspace branding is unavailable', () => {
renderSidebar({
customConfig: {
remove_webapp_brand: false,
replace_webapp_logo: 'https://example.com/custom-logo.png',
},
})
const brandingLogo = screen.getByRole('img', { name: 'logo' })
expect(brandingLogo).toHaveAttribute('src', 'https://example.com/custom-logo.png')
})
})

View File

@@ -0,0 +1,298 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { AppSourceType } from '@/service/share'
import { useTextGenerationAppState } from '../use-text-generation-app-state'
const {
changeLanguageMock,
fetchSavedMessageMock,
notifyMock,
removeMessageMock,
saveMessageMock,
useAppFaviconMock,
useDocumentTitleMock,
} = vi.hoisted(() => ({
changeLanguageMock: vi.fn(() => Promise.resolve()),
fetchSavedMessageMock: vi.fn(),
notifyMock: vi.fn(),
removeMessageMock: vi.fn(),
saveMessageMock: vi.fn(),
useAppFaviconMock: vi.fn(),
useDocumentTitleMock: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: notifyMock,
},
}))
vi.mock('@/hooks/use-app-favicon', () => ({
useAppFavicon: useAppFaviconMock,
}))
vi.mock('@/hooks/use-document-title', () => ({
default: useDocumentTitleMock,
}))
vi.mock('@/i18n-config/client', () => ({
changeLanguage: changeLanguageMock,
}))
vi.mock('@/service/share', async () => {
const actual = await vi.importActual<typeof import('@/service/share')>('@/service/share')
return {
...actual,
fetchSavedMessage: (...args: Parameters<typeof actual.fetchSavedMessage>) => fetchSavedMessageMock(...args),
removeMessage: (...args: Parameters<typeof actual.removeMessage>) => removeMessageMock(...args),
saveMessage: (...args: Parameters<typeof actual.saveMessage>) => saveMessageMock(...args),
}
})
const mockSystemFeatures = {
branding: {
enabled: false,
workspace_logo: null,
},
}
const defaultAppInfo = {
app_id: 'app-123',
site: {
title: 'Share title',
description: 'Share description',
default_language: 'en-US',
icon_type: 'emoji',
icon: 'robot',
icon_background: '#fff',
icon_url: '',
},
custom_config: {
remove_webapp_brand: false,
replace_webapp_logo: '',
},
}
type MockAppInfo = Omit<typeof defaultAppInfo, 'custom_config'> & {
custom_config: typeof defaultAppInfo.custom_config | null
}
const defaultAppParams = {
user_input_form: [
{
'text-input': {
label: 'Name',
variable: 'name',
required: true,
max_length: 48,
default: 'Alice',
hide: false,
},
},
{
checkbox: {
label: 'Enabled',
variable: 'enabled',
required: false,
default: true,
hide: false,
},
},
],
more_like_this: {
enabled: true,
},
file_upload: {
enabled: true,
number_limits: 2,
detail: 'low',
allowed_upload_methods: ['local_file'],
},
text_to_speech: {
enabled: true,
},
system_parameters: {
image_file_size_limit: 10,
},
}
type MockWebAppState = {
appInfo: MockAppInfo | null
appParams: typeof defaultAppParams | null
webAppAccessMode: string
}
const mockWebAppState: MockWebAppState = {
appInfo: defaultAppInfo,
appParams: defaultAppParams,
webAppAccessMode: 'public',
}
const resetMockWebAppState = () => {
mockWebAppState.appInfo = {
...defaultAppInfo,
site: {
...defaultAppInfo.site,
},
custom_config: {
...defaultAppInfo.custom_config,
},
}
mockWebAppState.appParams = {
...defaultAppParams,
user_input_form: [...defaultAppParams.user_input_form],
more_like_this: {
enabled: true,
},
file_upload: {
...defaultAppParams.file_upload,
allowed_upload_methods: [...defaultAppParams.file_upload.allowed_upload_methods],
},
text_to_speech: {
...defaultAppParams.text_to_speech,
},
system_parameters: {
image_file_size_limit: 10,
},
}
mockWebAppState.webAppAccessMode = 'public'
}
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
selector({ systemFeatures: mockSystemFeatures }),
}))
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState),
}))
describe('useTextGenerationAppState', () => {
beforeEach(() => {
vi.clearAllMocks()
resetMockWebAppState()
fetchSavedMessageMock.mockResolvedValue({
data: [{ id: 'saved-1' }],
})
removeMessageMock.mockResolvedValue(undefined)
saveMessageMock.mockResolvedValue(undefined)
})
it('should initialize app state and fetch saved messages for non-workflow web apps', async () => {
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: false,
}))
await waitFor(() => {
expect(result.current.appId).toBe('app-123')
expect(result.current.promptConfig?.prompt_variables.map(item => item.name)).toEqual(['Name', 'Enabled'])
expect(result.current.savedMessages).toEqual([{ id: 'saved-1' }])
})
expect(result.current.appSourceType).toBe(AppSourceType.webApp)
expect(result.current.siteInfo?.title).toBe('Share title')
expect(result.current.visionConfig.transfer_methods).toEqual(['local_file'])
expect(result.current.visionConfig.image_file_size_limit).toBe(10)
expect(changeLanguageMock).toHaveBeenCalledWith('en-US')
expect(fetchSavedMessageMock).toHaveBeenCalledWith(AppSourceType.webApp, 'app-123')
expect(useDocumentTitleMock).toHaveBeenCalledWith('Share title')
expect(useAppFaviconMock).toHaveBeenCalledWith(expect.objectContaining({
enable: true,
icon: 'robot',
}))
})
it('should no-op save actions before the app id is initialized', async () => {
mockWebAppState.appInfo = null
mockWebAppState.appParams = null
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: false,
}))
await act(async () => {
await result.current.fetchSavedMessages('')
await result.current.handleSaveMessage('message-1')
await result.current.handleRemoveSavedMessage('message-1')
})
expect(result.current.appId).toBe('')
expect(fetchSavedMessageMock).not.toHaveBeenCalled()
expect(saveMessageMock).not.toHaveBeenCalled()
expect(removeMessageMock).not.toHaveBeenCalled()
expect(notifyMock).not.toHaveBeenCalled()
})
it('should fallback to null custom config when the share metadata omits it', async () => {
mockWebAppState.appInfo = {
...defaultAppInfo,
custom_config: null,
}
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: false,
}))
await waitFor(() => {
expect(result.current.appId).toBe('app-123')
expect(result.current.customConfig).toBeNull()
})
})
it('should save and remove messages then refresh saved messages', async () => {
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: false,
isWorkflow: false,
}))
await waitFor(() => {
expect(result.current.appId).toBe('app-123')
})
fetchSavedMessageMock.mockClear()
await act(async () => {
await result.current.handleSaveMessage('message-1')
})
expect(saveMessageMock).toHaveBeenCalledWith('message-1', AppSourceType.webApp, 'app-123')
expect(fetchSavedMessageMock).toHaveBeenCalledWith(AppSourceType.webApp, 'app-123')
expect(notifyMock).toHaveBeenCalledWith({
type: 'success',
message: 'common.api.saved',
})
fetchSavedMessageMock.mockClear()
notifyMock.mockClear()
await act(async () => {
await result.current.handleRemoveSavedMessage('message-1')
})
expect(removeMessageMock).toHaveBeenCalledWith('message-1', AppSourceType.webApp, 'app-123')
expect(fetchSavedMessageMock).toHaveBeenCalledWith(AppSourceType.webApp, 'app-123')
expect(notifyMock).toHaveBeenCalledWith({
type: 'success',
message: 'common.api.remove',
})
})
it('should skip saved message fetching for workflows and disable favicon for installed apps', async () => {
const { result } = renderHook(() => useTextGenerationAppState({
isInstalledApp: true,
isWorkflow: true,
}))
await waitFor(() => {
expect(result.current.appId).toBe('app-123')
})
expect(result.current.appSourceType).toBe(AppSourceType.installedApp)
expect(fetchSavedMessageMock).not.toHaveBeenCalled()
expect(useAppFaviconMock).toHaveBeenCalledWith(expect.objectContaining({
enable: false,
}))
})
})

View File

@@ -0,0 +1,314 @@
import type { PromptConfig, PromptVariable } from '@/models/debug'
import { act, renderHook } from '@testing-library/react'
import { BATCH_CONCURRENCY } from '@/config'
import { TaskStatus } from '../../types'
import { useTextGenerationBatch } from '../use-text-generation-batch'
const createVariable = (overrides: Partial<PromptVariable>): PromptVariable => ({
key: 'input',
name: 'Input',
type: 'string',
required: true,
...overrides,
})
const createPromptConfig = (): PromptConfig => ({
prompt_template: 'template',
prompt_variables: [
createVariable({ key: 'name', name: 'Name', type: 'string', required: true }),
createVariable({ key: 'score', name: 'Score', type: 'number', required: false }),
],
})
const createTranslator = () => vi.fn((key: string) => key)
const renderBatchHook = (promptConfig: PromptConfig = createPromptConfig()) => {
const notify = vi.fn()
const onStart = vi.fn()
const t = createTranslator()
const hook = renderHook(() => useTextGenerationBatch({
promptConfig,
notify,
t,
}))
return {
...hook,
notify,
onStart,
t,
}
}
describe('useTextGenerationBatch', () => {
it('should initialize the first batch group when csv content is valid', () => {
const { result, onStart } = renderBatchHook()
const csvData = [
['Name', 'Score'],
...Array.from({ length: BATCH_CONCURRENCY + 1 }, (_, index) => [`Item ${index + 1}`, '']),
]
let isStarted = false
act(() => {
isStarted = result.current.handleRunBatch(csvData, { onStart })
})
expect(isStarted).toBe(true)
expect(onStart).toHaveBeenCalledTimes(1)
expect(result.current.isCallBatchAPI).toBe(true)
expect(result.current.allTaskList).toHaveLength(BATCH_CONCURRENCY + 1)
expect(result.current.allTaskList.slice(0, BATCH_CONCURRENCY).every(task => task.status === TaskStatus.running)).toBe(true)
expect(result.current.allTaskList.at(-1)?.status).toBe(TaskStatus.pending)
expect(result.current.allTaskList[0]?.params.inputs).toEqual({
name: 'Item 1',
score: undefined,
})
})
it('should reject csv data when the header does not match prompt variables', () => {
const { result, notify, onStart } = renderBatchHook()
let isStarted = true
act(() => {
isStarted = result.current.handleRunBatch([
['Prompt', 'Score'],
['Hello', '1'],
], { onStart })
})
expect(isStarted).toBe(false)
expect(onStart).not.toHaveBeenCalled()
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'generation.errorMsg.fileStructNotMatch',
})
expect(result.current.allTaskList).toEqual([])
})
it('should reject empty batch inputs and rows without executable payload', () => {
const { result, notify, onStart } = renderBatchHook()
let isStarted = true
act(() => {
isStarted = result.current.handleRunBatch([], { onStart })
})
expect(isStarted).toBe(false)
expect(notify).toHaveBeenLastCalledWith({
type: 'error',
message: 'generation.errorMsg.empty',
})
notify.mockClear()
act(() => {
isStarted = result.current.handleRunBatch([
['Name', 'Score'],
], { onStart })
})
expect(isStarted).toBe(false)
expect(notify).toHaveBeenLastCalledWith({
type: 'error',
message: 'generation.errorMsg.atLeastOne',
})
notify.mockClear()
act(() => {
isStarted = result.current.handleRunBatch([
['Name', 'Score'],
['', ''],
], { onStart })
})
expect(isStarted).toBe(false)
expect(notify).toHaveBeenLastCalledWith({
type: 'error',
message: 'generation.errorMsg.atLeastOne',
})
})
it('should reject csv data when empty rows appear in the middle of the payload', () => {
const { result, notify, onStart } = renderBatchHook()
let isStarted = true
act(() => {
isStarted = result.current.handleRunBatch([
['Name', 'Score'],
['Alice', '1'],
['', ''],
['Bob', '2'],
['', ''],
['', ''],
], { onStart })
})
expect(isStarted).toBe(false)
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'generation.errorMsg.emptyLine',
})
})
it('should reject rows with missing required values', () => {
const { result, notify, onStart } = renderBatchHook()
let isStarted = true
act(() => {
isStarted = result.current.handleRunBatch([
['Name', 'Score'],
['', '1'],
], { onStart })
})
expect(isStarted).toBe(false)
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'generation.errorMsg.invalidLine',
})
})
it('should reject rows that exceed the configured max length', () => {
const { result, notify, onStart } = renderBatchHook({
prompt_template: 'template',
prompt_variables: [
createVariable({ key: 'name', name: 'Name', type: 'string', required: true, max_length: 3 }),
createVariable({ key: 'score', name: 'Score', type: 'number', required: false }),
],
})
let isStarted = true
act(() => {
isStarted = result.current.handleRunBatch([
['Name', 'Score'],
['Alice', '1'],
], { onStart })
})
expect(isStarted).toBe(false)
expect(notify).toHaveBeenCalledWith({
type: 'error',
message: 'generation.errorMsg.moreThanMaxLengthLine',
})
})
it('should promote pending tasks after the current batch group completes', () => {
const { result } = renderBatchHook()
const csvData = [
['Name', 'Score'],
...Array.from({ length: BATCH_CONCURRENCY + 1 }, (_, index) => [`Item ${index + 1}`, `${index + 1}`]),
]
act(() => {
result.current.handleRunBatch(csvData, { onStart: vi.fn() })
})
act(() => {
Array.from({ length: BATCH_CONCURRENCY }).forEach((_, index) => {
result.current.handleCompleted(`Result ${index + 1}`, index + 1, true)
})
})
expect(result.current.allTaskList.at(-1)?.status).toBe(TaskStatus.running)
expect(result.current.exportRes.at(0)).toEqual({
'Name': 'Item 1',
'Score': '1',
'generation.completionResult': 'Result 1',
})
})
it('should block starting a new batch while previous tasks are still running', () => {
const { result, notify, onStart } = renderBatchHook()
const csvData = [
['Name', 'Score'],
...Array.from({ length: BATCH_CONCURRENCY + 1 }, (_, index) => [`Item ${index + 1}`, `${index + 1}`]),
]
act(() => {
result.current.handleRunBatch(csvData, { onStart })
})
notify.mockClear()
let isStarted = true
act(() => {
isStarted = result.current.handleRunBatch(csvData, { onStart })
})
expect(isStarted).toBe(false)
expect(onStart).toHaveBeenCalledTimes(1)
expect(notify).toHaveBeenCalledWith({
type: 'info',
message: 'errorMessage.waitForBatchResponse',
})
})
it('should ignore completion updates without a task id', () => {
const { result } = renderBatchHook()
act(() => {
result.current.handleRunBatch([
['Name', 'Score'],
['Alice', '1'],
], { onStart: vi.fn() })
})
const taskSnapshot = result.current.allTaskList
act(() => {
result.current.handleCompleted('ignored')
})
expect(result.current.allTaskList).toEqual(taskSnapshot)
})
it('should expose failed tasks, retry signals, and reset state after batch failures', () => {
const { result } = renderBatchHook()
act(() => {
result.current.handleRunBatch([
['Name', 'Score'],
['Alice', ''],
], { onStart: vi.fn() })
})
act(() => {
result.current.handleCompleted({ answer: 'failed' } as unknown as string, 1, false)
})
expect(result.current.allFailedTaskList).toEqual([
expect.objectContaining({
id: 1,
status: TaskStatus.failed,
}),
])
expect(result.current.allTasksFinished).toBe(false)
expect(result.current.allTasksRun).toBe(true)
expect(result.current.noPendingTask).toBe(true)
expect(result.current.exportRes).toEqual([
{
'Name': 'Alice',
'Score': '',
'generation.completionResult': JSON.stringify({ answer: 'failed' }),
},
])
act(() => {
result.current.handleRetryAllFailedTask()
})
expect(result.current.controlRetry).toBeGreaterThan(0)
act(() => {
result.current.resetBatchExecution()
})
expect(result.current.allTaskList).toEqual([])
expect(result.current.allFailedTaskList).toEqual([])
expect(result.current.showTaskList).toEqual([])
expect(result.current.exportRes).toEqual([])
expect(result.current.noPendingTask).toBe(true)
})
})

View File

@@ -0,0 +1,158 @@
import type { TextGenerationCustomConfig } from '../types'
import type {
MoreLikeThisConfig,
PromptConfig,
SavedMessage,
TextToSpeechConfig,
} from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import useDocumentTitle from '@/hooks/use-document-title'
import { changeLanguage } from '@/i18n-config/client'
import { AppSourceType, fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
import { Resolution, TransferMethod } from '@/types/app'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
type UseTextGenerationAppStateOptions = {
isInstalledApp: boolean
isWorkflow: boolean
}
type ShareAppParams = {
user_input_form: Parameters<typeof userInputsFormToPromptVariables>[0]
more_like_this: MoreLikeThisConfig | null
file_upload: VisionSettings & {
allowed_file_upload_methods?: TransferMethod[]
allowed_upload_methods?: TransferMethod[]
}
text_to_speech: TextToSpeechConfig | null
system_parameters?: Record<string, unknown> & {
image_file_size_limit?: number
}
}
export const useTextGenerationAppState = ({
isInstalledApp,
isWorkflow,
}: UseTextGenerationAppStateOptions) => {
const { notify } = Toast
const { t } = useTranslation()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const appData = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const accessMode = useWebAppStore(s => s.webAppAccessMode)
const [appId, setAppId] = useState('')
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
const [customConfig, setCustomConfig] = useState<TextGenerationCustomConfig | null>(null)
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
const [visionConfig, setVisionConfig] = useState<VisionSettings>({
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
})
const fetchSavedMessages = useCallback(async (targetAppId = appId) => {
if (!targetAppId)
return
const res = await doFetchSavedMessage(appSourceType, targetAppId) as { data: SavedMessage[] }
setSavedMessages(res.data)
}, [appId, appSourceType])
const handleSaveMessage = useCallback(async (messageId: string) => {
if (!appId)
return
await saveMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
await fetchSavedMessages(appId)
}, [appId, appSourceType, fetchSavedMessages, notify, t])
const handleRemoveSavedMessage = useCallback(async (messageId: string) => {
if (!appId)
return
await removeMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
await fetchSavedMessages(appId)
}, [appId, appSourceType, fetchSavedMessages, notify, t])
useEffect(() => {
let cancelled = false
const initialize = async () => {
if (!appData || !appParams)
return
const { app_id: nextAppId, site, custom_config } = appData
setAppId(nextAppId)
setSiteInfo(site as SiteInfo)
setCustomConfig((custom_config || null) as TextGenerationCustomConfig | null)
await changeLanguage(site.default_language)
const { user_input_form, more_like_this, file_upload, text_to_speech } = appParams as unknown as ShareAppParams
if (cancelled)
return
setVisionConfig({
...file_upload,
transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
fileUploadConfig: appParams?.system_parameters,
} as VisionSettings)
setPromptConfig({
prompt_template: '',
prompt_variables: userInputsFormToPromptVariables(user_input_form),
} as PromptConfig)
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
if (!isWorkflow)
await fetchSavedMessages(nextAppId)
}
void initialize()
return () => {
cancelled = true
}
}, [appData, appParams, fetchSavedMessages, isWorkflow])
useDocumentTitle(siteInfo?.title || t('generation.title', { ns: 'share' }))
useAppFavicon({
enable: !isInstalledApp,
icon_type: siteInfo?.icon_type,
icon: siteInfo?.icon,
icon_background: siteInfo?.icon_background,
icon_url: siteInfo?.icon_url,
})
return {
accessMode,
appId,
appSourceType,
customConfig,
fetchSavedMessages,
handleRemoveSavedMessage,
handleSaveMessage,
moreLikeThisConfig,
promptConfig,
savedMessages,
siteInfo,
systemFeatures,
textToSpeechConfig,
visionConfig,
setVisionConfig,
}
}

View File

@@ -0,0 +1,270 @@
import type { Task } from '../types'
import type { PromptConfig } from '@/models/debug'
import { useCallback, useMemo, useRef, useState } from 'react'
import { BATCH_CONCURRENCY } from '@/config'
import { TaskStatus } from '../types'
type BatchNotify = (payload: { type: 'error' | 'info', message: string }) => void
type BatchTranslate = (key: string, options?: Record<string, unknown>) => string
type UseTextGenerationBatchOptions = {
promptConfig: PromptConfig | null
notify: BatchNotify
t: BatchTranslate
}
type RunBatchCallbacks = {
onStart: () => void
}
const GROUP_SIZE = BATCH_CONCURRENCY
export const useTextGenerationBatch = ({
promptConfig,
notify,
t,
}: UseTextGenerationBatchOptions) => {
const [isCallBatchAPI, setIsCallBatchAPI] = useState(false)
const [controlRetry, setControlRetry] = useState(0)
const [allTaskList, setAllTaskList] = useState<Task[]>([])
const [batchCompletionMap, setBatchCompletionMap] = useState<Record<string, string>>({})
const allTaskListRef = useRef<Task[]>([])
const currGroupNumRef = useRef(0)
const batchCompletionResRef = useRef<Record<string, string>>({})
const updateAllTaskList = useCallback((taskList: Task[]) => {
setAllTaskList(taskList)
allTaskListRef.current = taskList
}, [])
const updateBatchCompletionRes = useCallback((res: Record<string, string>) => {
batchCompletionResRef.current = res
setBatchCompletionMap(res)
}, [])
const resetBatchExecution = useCallback(() => {
updateAllTaskList([])
updateBatchCompletionRes({})
currGroupNumRef.current = 0
}, [updateAllTaskList, updateBatchCompletionRes])
const checkBatchInputs = useCallback((data: string[][]) => {
if (!data || data.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.empty', { ns: 'share' }) })
return false
}
const promptVariables = promptConfig?.prompt_variables ?? []
const headerData = data[0]
let isMapVarName = true
promptVariables.forEach((item, index) => {
if (!isMapVarName)
return
if (item.name !== headerData[index])
isMapVarName = false
})
if (!isMapVarName) {
notify({ type: 'error', message: t('generation.errorMsg.fileStructNotMatch', { ns: 'share' }) })
return false
}
let payloadData = data.slice(1)
if (payloadData.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.atLeastOne', { ns: 'share' }) })
return false
}
const emptyLineIndexes = payloadData
.filter(item => item.every(value => value === ''))
.map(item => payloadData.indexOf(item))
if (emptyLineIndexes.length > 0) {
let hasMiddleEmptyLine = false
let startIndex = emptyLineIndexes[0] - 1
emptyLineIndexes.forEach((index) => {
if (hasMiddleEmptyLine)
return
if (startIndex + 1 !== index) {
hasMiddleEmptyLine = true
return
}
startIndex += 1
})
if (hasMiddleEmptyLine) {
notify({ type: 'error', message: t('generation.errorMsg.emptyLine', { ns: 'share', rowIndex: startIndex + 2 }) })
return false
}
}
payloadData = payloadData.filter(item => !item.every(value => value === ''))
if (payloadData.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.atLeastOne', { ns: 'share' }) })
return false
}
let errorRowIndex = 0
let requiredVarName = ''
let tooLongVarName = ''
let maxLength = 0
for (const [index, item] of payloadData.entries()) {
for (const [varIndex, varItem] of promptVariables.entries()) {
const value = item[varIndex] ?? ''
if (varItem.type === 'string' && varItem.max_length && value.length > varItem.max_length) {
tooLongVarName = varItem.name
maxLength = varItem.max_length
errorRowIndex = index + 1
break
}
if (varItem.required && value.trim() === '') {
requiredVarName = varItem.name
errorRowIndex = index + 1
break
}
}
if (errorRowIndex !== 0)
break
}
if (errorRowIndex !== 0) {
if (requiredVarName) {
notify({
type: 'error',
message: t('generation.errorMsg.invalidLine', { ns: 'share', rowIndex: errorRowIndex + 1, varName: requiredVarName }),
})
}
if (tooLongVarName) {
notify({
type: 'error',
message: t('generation.errorMsg.moreThanMaxLengthLine', {
ns: 'share',
rowIndex: errorRowIndex + 1,
varName: tooLongVarName,
maxLength,
}),
})
}
return false
}
return true
}, [notify, promptConfig, t])
const handleRunBatch = useCallback((data: string[][], { onStart }: RunBatchCallbacks) => {
if (!checkBatchInputs(data))
return false
const latestTaskList = allTaskListRef.current
const allTasksFinished = latestTaskList.every(task => task.status === TaskStatus.completed)
if (!allTasksFinished && latestTaskList.length > 0) {
notify({ type: 'info', message: t('errorMessage.waitForBatchResponse', { ns: 'appDebug' }) })
return false
}
const payloadData = data.filter(item => !item.every(value => value === '')).slice(1)
const promptVariables = promptConfig?.prompt_variables ?? []
const nextTaskList: Task[] = payloadData.map((item, index) => {
const inputs: Record<string, string | boolean | undefined> = {}
promptVariables.forEach((variable, varIndex) => {
const input = item[varIndex]
inputs[variable.key] = input
if (!input)
inputs[variable.key] = variable.type === 'string' || variable.type === 'paragraph' ? '' : undefined
})
return {
id: index + 1,
status: index < GROUP_SIZE ? TaskStatus.running : TaskStatus.pending,
params: { inputs },
}
})
setIsCallBatchAPI(true)
updateAllTaskList(nextTaskList)
updateBatchCompletionRes({})
currGroupNumRef.current = 0
onStart()
return true
}, [checkBatchInputs, notify, promptConfig, t, updateAllTaskList, updateBatchCompletionRes])
const handleCompleted = useCallback((completionRes: string, taskId?: number, isSuccess?: boolean) => {
if (!taskId)
return
const latestTaskList = allTaskListRef.current
const latestBatchCompletionRes = batchCompletionResRef.current
const pendingTaskList = latestTaskList.filter(task => task.status === TaskStatus.pending)
const runTasksCount = 1 + latestTaskList.filter(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)).length
const shouldStartNextGroup = currGroupNumRef.current !== runTasksCount
&& pendingTaskList.length > 0
&& (runTasksCount % GROUP_SIZE === 0 || (latestTaskList.length - runTasksCount < GROUP_SIZE))
if (shouldStartNextGroup)
currGroupNumRef.current = runTasksCount
const nextPendingTaskIds = shouldStartNextGroup ? pendingTaskList.slice(0, GROUP_SIZE).map(item => item.id) : []
updateAllTaskList(latestTaskList.map((task) => {
if (task.id === taskId)
return { ...task, status: isSuccess ? TaskStatus.completed : TaskStatus.failed }
if (shouldStartNextGroup && nextPendingTaskIds.includes(task.id))
return { ...task, status: TaskStatus.running }
return task
}))
updateBatchCompletionRes({
...latestBatchCompletionRes,
[taskId]: completionRes,
})
}, [updateAllTaskList, updateBatchCompletionRes])
const handleRetryAllFailedTask = useCallback(() => {
setControlRetry(Date.now())
}, [])
const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
const allSuccessTaskList = allTaskList.filter(task => task.status === TaskStatus.completed)
const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed)
const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed)
const allTasksRun = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status))
const exportRes = useMemo(() => {
return allTaskList.map((task) => {
const result: Record<string, string> = {}
promptConfig?.prompt_variables.forEach((variable) => {
result[variable.name] = String(task.params.inputs[variable.key] ?? '')
})
let completionValue = batchCompletionMap[String(task.id)]
if (typeof completionValue === 'object')
completionValue = JSON.stringify(completionValue)
result[t('generation.completionResult', { ns: 'share' })] = completionValue
return result
})
}, [allTaskList, batchCompletionMap, promptConfig, t])
return {
allFailedTaskList,
allSuccessTaskList,
allTaskList,
allTasksFinished,
allTasksRun,
controlRetry,
exportRes,
handleCompleted,
handleRetryAllFailedTask,
handleRunBatch,
isCallBatchAPI,
noPendingTask: pendingTaskList.length === 0,
resetBatchExecution,
setIsCallBatchAPI,
showTaskList,
}
}

View File

@@ -1,65 +1,20 @@
'use client'
import type { FC } from 'react'
import type {
MoreLikeThisConfig,
PromptConfig,
SavedMessage,
TextToSpeechConfig,
} from '@/models/debug'
import type { InputValueTypes, TextGenerationRunControl } from './types'
import type { InstalledApp } from '@/models/explore'
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import {
RiBookmark3Line,
RiErrorWarningFill,
} from '@remixicon/react'
import type { VisionFile } from '@/types/app'
import { useBoolean } from 'ahooks'
import { useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SavedItems from '@/app/components/app/text-generate/saved-items'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import Loading from '@/app/components/base/loading'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import Toast from '@/app/components/base/toast'
import Res from '@/app/components/share/text-generation/result'
import RunOnce from '@/app/components/share/text-generation/run-once'
import { appDefaultIconBackground, BATCH_CONCURRENCY } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { changeLanguage } from '@/i18n-config/client'
import { AccessMode } from '@/models/access-control'
import { AppSourceType, fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
import { Resolution, TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
import TabHeader from '../../base/tab-header'
import MenuDropdown from './menu-dropdown'
import RunBatch from './run-batch'
import ResDownload from './run-batch/res-download'
const GROUP_SIZE = BATCH_CONCURRENCY // to avoid RPM(Request per minute) limit. The group task finished then the next group.
enum TaskStatus {
pending = 'pending',
running = 'running',
completed = 'completed',
failed = 'failed',
}
type TaskParam = {
inputs: Record<string, any>
}
type Task = {
id: number
status: TaskStatus
params: TaskParam
}
import { useTextGenerationAppState } from './hooks/use-text-generation-app-state'
import { useTextGenerationBatch } from './hooks/use-text-generation-batch'
import TextGenerationResultPanel from './text-generation-result-panel'
import TextGenerationSidebar from './text-generation-sidebar'
export type IMainProps = {
isInstalledApp?: boolean
@@ -72,8 +27,6 @@ const TextGeneration: FC<IMainProps> = ({
isWorkflow = false,
}) => {
const { notify } = Toast
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const { t } = useTranslation()
const media = useBreakpoints()
const isPC = media === MediaType.pc
@@ -81,428 +34,90 @@ const TextGeneration: FC<IMainProps> = ({
const searchParams = useSearchParams()
const mode = searchParams.get('mode') || 'create'
const [currentTab, setCurrentTab] = useState<string>(['create', 'batch'].includes(mode) ? mode : 'create')
// Notice this situation isCallBatchAPI but not in batch tab
const [isCallBatchAPI, setIsCallBatchAPI] = useState(false)
const isInBatchTab = currentTab === 'batch'
const [inputs, doSetInputs] = useState<Record<string, any>>({})
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
const inputsRef = useRef(inputs)
const setInputs = useCallback((newInputs: Record<string, any>) => {
doSetInputs(newInputs)
inputsRef.current = newInputs
}, [])
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const [appId, setAppId] = useState<string>('')
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
const [customConfig, setCustomConfig] = useState<Record<string, any> | null>(null)
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
// save message
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
const fetchSavedMessage = useCallback(async () => {
if (!appId)
return
const res: any = await doFetchSavedMessage(appSourceType, appId)
setSavedMessages(res.data)
}, [appSourceType, appId])
const handleSaveMessage = async (messageId: string) => {
await saveMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
fetchSavedMessage()
}
const handleRemoveSavedMessage = async (messageId: string) => {
await removeMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
fetchSavedMessage()
}
// send message task
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const [runControl, setRunControl] = useState<TextGenerationRunControl | null>(null)
const [controlSend, setControlSend] = useState(0)
const [controlStopResponding, setControlStopResponding] = useState(0)
const [visionConfig, setVisionConfig] = useState<VisionSettings>({
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
const [resultExisted, setResultExisted] = useState(false)
const [isShowResultPanel, { setTrue: showResultPanelState, setFalse: hideResultPanel }] = useBoolean(false)
const updateInputs = useCallback((newInputs: Record<string, InputValueTypes>) => {
setInputs(newInputs)
inputsRef.current = newInputs
}, [])
const {
accessMode,
appId,
appSourceType,
customConfig,
handleRemoveSavedMessage,
handleSaveMessage,
moreLikeThisConfig,
promptConfig,
savedMessages,
siteInfo,
systemFeatures,
textToSpeechConfig,
visionConfig,
} = useTextGenerationAppState({
isInstalledApp,
isWorkflow,
})
const {
allFailedTaskList,
allSuccessTaskList,
allTaskList,
allTasksRun,
controlRetry,
exportRes,
handleCompleted,
handleRetryAllFailedTask,
handleRunBatch: runBatchExecution,
isCallBatchAPI,
noPendingTask,
resetBatchExecution,
setIsCallBatchAPI,
showTaskList,
} = useTextGenerationBatch({
promptConfig,
notify,
t,
})
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const [runControl, setRunControl] = useState<{ onStop: () => Promise<void> | void, isStopping: boolean } | null>(null)
useEffect(() => {
if (isCallBatchAPI)
setRunControl(null)
}, [isCallBatchAPI])
const handleSend = () => {
const showResultPanel = useCallback(() => {
setTimeout(() => {
showResultPanelState()
}, 0)
}, [showResultPanelState])
const handleRunStart = useCallback(() => {
setResultExisted(true)
}, [])
const handleRunOnce = useCallback(() => {
setIsCallBatchAPI(false)
setControlSend(Date.now())
// eslint-disable-next-line ts/no-use-before-define
setAllTaskList([]) // clear batch task running status
// eslint-disable-next-line ts/no-use-before-define
resetBatchExecution()
showResultPanel()
}
}, [resetBatchExecution, setIsCallBatchAPI, showResultPanel])
const [controlRetry, setControlRetry] = useState(0)
const handleRetryAllFailedTask = () => {
setControlRetry(Date.now())
}
const [allTaskList, doSetAllTaskList] = useState<Task[]>([])
const allTaskListRef = useRef<Task[]>([])
const getLatestTaskList = () => allTaskListRef.current
const setAllTaskList = (taskList: Task[]) => {
doSetAllTaskList(taskList)
allTaskListRef.current = taskList
}
const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
const noPendingTask = pendingTaskList.length === 0
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
const currGroupNumRef = useRef(0)
const setCurrGroupNum = (num: number) => {
currGroupNumRef.current = num
}
const getCurrGroupNum = () => {
return currGroupNumRef.current
}
const allSuccessTaskList = allTaskList.filter(task => task.status === TaskStatus.completed)
const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed)
const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed)
const allTasksRun = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status))
const batchCompletionResRef = useRef<Record<string, string>>({})
const setBatchCompletionRes = (res: Record<string, string>) => {
batchCompletionResRef.current = res
}
const getBatchCompletionRes = () => batchCompletionResRef.current
const exportRes = allTaskList.map((task) => {
const batchCompletionResLatest = getBatchCompletionRes()
const res: Record<string, string> = {}
const { inputs } = task.params
promptConfig?.prompt_variables.forEach((v) => {
res[v.name] = inputs[v.key]
const handleRunBatch = useCallback((data: string[][]) => {
runBatchExecution(data, {
onStart: () => {
setControlSend(Date.now())
setControlStopResponding(Date.now())
showResultPanel()
},
})
let result = batchCompletionResLatest[task.id]
// task might return multiple fields, should marshal object to string
if (typeof batchCompletionResLatest[task.id] === 'object')
result = JSON.stringify(result)
res[t('generation.completionResult', { ns: 'share' })] = result
return res
})
const checkBatchInputs = (data: string[][]) => {
if (!data || data.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.empty', { ns: 'share' }) })
return false
}
const headerData = data[0]
let isMapVarName = true
promptConfig?.prompt_variables.forEach((item, index) => {
if (!isMapVarName)
return
if (item.name !== headerData[index])
isMapVarName = false
})
if (!isMapVarName) {
notify({ type: 'error', message: t('generation.errorMsg.fileStructNotMatch', { ns: 'share' }) })
return false
}
let payloadData = data.slice(1)
if (payloadData.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.atLeastOne', { ns: 'share' }) })
return false
}
// check middle empty line
const allEmptyLineIndexes = payloadData.filter(item => item.every(i => i === '')).map(item => payloadData.indexOf(item))
if (allEmptyLineIndexes.length > 0) {
let hasMiddleEmptyLine = false
let startIndex = allEmptyLineIndexes[0] - 1
allEmptyLineIndexes.forEach((index) => {
if (hasMiddleEmptyLine)
return
if (startIndex + 1 !== index) {
hasMiddleEmptyLine = true
return
}
startIndex++
})
if (hasMiddleEmptyLine) {
notify({ type: 'error', message: t('generation.errorMsg.emptyLine', { ns: 'share', rowIndex: startIndex + 2 }) })
return false
}
}
// check row format
payloadData = payloadData.filter(item => !item.every(i => i === ''))
// after remove empty rows in the end, checked again
if (payloadData.length === 0) {
notify({ type: 'error', message: t('generation.errorMsg.atLeastOne', { ns: 'share' }) })
return false
}
let errorRowIndex = 0
let requiredVarName = ''
let moreThanMaxLengthVarName = ''
let maxLength = 0
payloadData.forEach((item, index) => {
if (errorRowIndex !== 0)
return
promptConfig?.prompt_variables.forEach((varItem, varIndex) => {
if (errorRowIndex !== 0)
return
if (varItem.type === 'string' && varItem.max_length) {
if (item[varIndex].length > varItem.max_length) {
moreThanMaxLengthVarName = varItem.name
maxLength = varItem.max_length
errorRowIndex = index + 1
return
}
}
if (!varItem.required)
return
if (item[varIndex].trim() === '') {
requiredVarName = varItem.name
errorRowIndex = index + 1
}
})
})
if (errorRowIndex !== 0) {
if (requiredVarName)
notify({ type: 'error', message: t('generation.errorMsg.invalidLine', { ns: 'share', rowIndex: errorRowIndex + 1, varName: requiredVarName }) })
if (moreThanMaxLengthVarName)
notify({ type: 'error', message: t('generation.errorMsg.moreThanMaxLengthLine', { ns: 'share', rowIndex: errorRowIndex + 1, varName: moreThanMaxLengthVarName, maxLength }) })
return false
}
return true
}
const handleRunBatch = (data: string[][]) => {
if (!checkBatchInputs(data))
return
if (!allTasksFinished) {
notify({ type: 'info', message: t('errorMessage.waitForBatchResponse', { ns: 'appDebug' }) })
return
}
const payloadData = data.filter(item => !item.every(i => i === '')).slice(1)
const varLen = promptConfig?.prompt_variables.length || 0
setIsCallBatchAPI(true)
const allTaskList: Task[] = payloadData.map((item, i) => {
const inputs: Record<string, any> = {}
if (varLen > 0) {
item.slice(0, varLen).forEach((input, index) => {
const varSchema = promptConfig?.prompt_variables[index]
inputs[varSchema?.key as string] = input
if (!input) {
if (varSchema?.type === 'string' || varSchema?.type === 'paragraph')
inputs[varSchema?.key as string] = ''
else
inputs[varSchema?.key as string] = undefined
}
})
}
return {
id: i + 1,
status: i < GROUP_SIZE ? TaskStatus.running : TaskStatus.pending,
params: {
inputs,
},
}
})
setAllTaskList(allTaskList)
setCurrGroupNum(0)
setControlSend(Date.now())
// clear run once task status
setControlStopResponding(Date.now())
// eslint-disable-next-line ts/no-use-before-define
showResultPanel()
}
const handleCompleted = (completionRes: string, taskId?: number, isSuccess?: boolean) => {
const allTaskListLatest = getLatestTaskList()
const batchCompletionResLatest = getBatchCompletionRes()
const pendingTaskList = allTaskListLatest.filter(task => task.status === TaskStatus.pending)
const runTasksCount = 1 + allTaskListLatest.filter(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)).length
const needToAddNextGroupTask = (getCurrGroupNum() !== runTasksCount) && pendingTaskList.length > 0 && (runTasksCount % GROUP_SIZE === 0 || (allTaskListLatest.length - runTasksCount < GROUP_SIZE))
// avoid add many task at the same time
if (needToAddNextGroupTask)
setCurrGroupNum(runTasksCount)
const nextPendingTaskIds = needToAddNextGroupTask ? pendingTaskList.slice(0, GROUP_SIZE).map(item => item.id) : []
const newAllTaskList = allTaskListLatest.map((item) => {
if (item.id === taskId) {
return {
...item,
status: isSuccess ? TaskStatus.completed : TaskStatus.failed,
}
}
if (needToAddNextGroupTask && nextPendingTaskIds.includes(item.id)) {
return {
...item,
status: TaskStatus.running,
}
}
return item
})
setAllTaskList(newAllTaskList)
if (taskId) {
setBatchCompletionRes({
...batchCompletionResLatest,
[`${taskId}`]: completionRes,
})
}
}
const appData = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const accessMode = useWebAppStore(s => s.webAppAccessMode)
useEffect(() => {
(async () => {
if (!appData || !appParams)
return
if (!isWorkflow)
fetchSavedMessage()
const { app_id: appId, site: siteInfo, custom_config } = appData
setAppId(appId)
setSiteInfo(siteInfo as SiteInfo)
setCustomConfig(custom_config)
await changeLanguage(siteInfo.default_language)
const { user_input_form, more_like_this, file_upload, text_to_speech }: any = appParams
setVisionConfig({
// legacy of image upload compatible
...file_upload,
transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
// legacy of image upload compatible
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
fileUploadConfig: appParams?.system_parameters,
} as any)
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
setPromptConfig({
prompt_template: '', // placeholder for future
prompt_variables,
} as PromptConfig)
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
})()
}, [appData, appParams, fetchSavedMessage, isWorkflow])
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
useDocumentTitle(siteInfo?.title || t('generation.title', { ns: 'share' }))
useAppFavicon({
enable: !isInstalledApp,
icon_type: siteInfo?.icon_type,
icon: siteInfo?.icon,
icon_background: siteInfo?.icon_background,
icon_url: siteInfo?.icon_url,
})
const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = useBoolean(false)
const showResultPanel = () => {
// fix: useClickAway hideResSidebar will close sidebar
setTimeout(() => {
doShowResultPanel()
}, 0)
}
const [resultExisted, setResultExisted] = useState(false)
const renderRes = (task?: Task) => (
<Res
key={task?.id}
isWorkflow={isWorkflow}
isCallBatchAPI={isCallBatchAPI}
isPC={isPC}
isMobile={!isPC}
appSourceType={isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp}
appId={appId}
isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig}
moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}
inputs={isCallBatchAPI ? (task as Task).params.inputs : inputs}
controlSend={controlSend}
controlRetry={task?.status === TaskStatus.failed ? controlRetry : 0}
controlStopResponding={controlStopResponding}
onShowRes={showResultPanel}
handleSaveMessage={handleSaveMessage}
taskId={task?.id}
onCompleted={handleCompleted}
visionConfig={visionConfig}
completionFiles={completionFiles}
isShowTextToSpeech={!!textToSpeechConfig?.enabled}
siteInfo={siteInfo}
onRunStart={() => setResultExisted(true)}
onRunControlChange={!isCallBatchAPI ? setRunControl : undefined}
hideInlineStopButton={!isCallBatchAPI}
/>
)
const renderBatchRes = () => {
return (showTaskList.map(task => renderRes(task)))
}
const renderResWrap = (
<div
className={cn(
'relative flex h-full flex-col',
!isPC && 'h-[calc(100vh_-_36px)] rounded-t-2xl shadow-lg backdrop-blur-sm',
!isPC
? isShowResultPanel
? 'bg-background-default-burn'
: 'border-t-[0.5px] border-divider-regular bg-components-panel-bg'
: 'bg-chatbot-bg',
)}
>
{isCallBatchAPI && (
<div className={cn(
'flex shrink-0 items-center justify-between px-14 pb-2 pt-9',
!isPC && 'px-4 pb-1 pt-3',
)}
>
<div className="system-md-semibold-uppercase text-text-primary">{t('generation.executions', { ns: 'share', num: allTaskList.length })}</div>
{allSuccessTaskList.length > 0 && (
<ResDownload
isMobile={!isPC}
values={exportRes}
/>
)}
</div>
)}
<div className={cn(
'flex h-0 grow flex-col overflow-y-auto',
isPC && 'px-14 py-8',
isPC && isCallBatchAPI && 'pt-0',
!isPC && 'p-0 pb-2',
)}
>
{!isCallBatchAPI ? renderRes() : renderBatchRes()}
{!noPendingTask && (
<div className="mt-4">
<Loading type="area" />
</div>
)}
</div>
{isCallBatchAPI && allFailedTaskList.length > 0 && (
<div className="absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-sm">
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
<div className="system-sm-medium text-text-secondary">{t('generation.batchFailed.info', { ns: 'share', num: allFailedTaskList.length })}</div>
<div className="h-3.5 w-px bg-divider-regular"></div>
<div onClick={handleRetryAllFailedTask} className="system-sm-semibold-uppercase cursor-pointer text-text-accent">{t('generation.batchFailed.retry', { ns: 'share' })}</div>
</div>
)}
</div>
)
}, [runBatchExecution, showResultPanel])
if (!appId || !siteInfo || !promptConfig) {
return (
@@ -511,147 +126,72 @@ const TextGeneration: FC<IMainProps> = ({
</div>
)
}
return (
<div className={cn(
'bg-background-default-burn',
isPC && 'flex',
!isPC && 'flex-col',
isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen',
)}
<div
className={cn(
'bg-background-default-burn',
isPC ? 'flex' : 'flex-col',
isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen',
)}
>
{/* Left */}
<div className={cn(
'relative flex h-full shrink-0 flex-col',
isPC ? 'w-[600px] max-w-[50%]' : resultExisted ? 'h-[calc(100%_-_64px)]' : '',
isInstalledApp && 'rounded-l-2xl',
)}
>
{/* header */}
<div className={cn('shrink-0 space-y-4 border-b border-divider-subtle', isPC ? 'bg-components-panel-bg p-8 pb-0' : 'p-4 pb-0')}>
<div className="flex items-center gap-3">
<AppIcon
size={isPC ? 'large' : 'small'}
iconType={siteInfo.icon_type}
icon={siteInfo.icon}
background={siteInfo.icon_background || appDefaultIconBackground}
imageUrl={siteInfo.icon_url}
/>
<div className="system-md-semibold grow truncate text-text-secondary">{siteInfo.title}</div>
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} />
</div>
{siteInfo.description && (
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
)}
<TabHeader
items={[
{ id: 'create', name: t('generation.tabs.create', { ns: 'share' }) },
{ id: 'batch', name: t('generation.tabs.batch', { ns: 'share' }) },
...(!isWorkflow
? [{
id: 'saved',
name: t('generation.tabs.saved', { ns: 'share' }),
isRight: true,
icon: <RiBookmark3Line className="h-4 w-4" />,
extra: savedMessages.length > 0
? (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)
: null,
}]
: []),
]}
value={currentTab}
onChange={setCurrentTab}
/>
</div>
{/* form */}
<div className={cn(
'h-0 grow overflow-y-auto bg-components-panel-bg',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<div className={cn(currentTab === 'create' ? 'block' : 'hidden')}>
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={setInputs}
promptConfig={promptConfig}
onSend={handleSend}
visionConfig={visionConfig}
onVisionFilesChange={setCompletionFiles}
runControl={runControl}
/>
</div>
<div className={cn(isInBatchTab ? 'block' : 'hidden')}>
<RunBatch
vars={promptConfig.prompt_variables}
onSend={handleRunBatch}
isAllFinished={allTasksRun}
/>
</div>
{currentTab === 'saved' && (
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={handleRemoveSavedMessage}
onStartCreateContent={() => setCurrentTab('create')}
/>
)}
</div>
{/* powered by */}
{!customConfig?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 bg-components-panel-bg py-3',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
: customConfig?.replace_webapp_logo
? <img src={`${customConfig?.replace_webapp_logo}`} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />
}
</div>
)}
</div>
{/* Result */}
<div className={cn(
isPC
? 'h-full w-0 grow'
: isShowResultPanel
? 'fixed inset-0 z-50 bg-background-overlay backdrop-blur-sm'
: resultExisted
? 'relative h-16 shrink-0 overflow-hidden bg-background-default-burn pt-2.5'
: '',
)}
>
{!isPC && (
<div
className={cn(
isShowResultPanel
? 'flex items-center justify-center p-2 pt-6'
: 'absolute left-0 top-0 z-10 flex w-full items-center justify-center px-2 pb-[57px] pt-[3px]',
)}
onClick={() => {
if (isShowResultPanel)
hideResultPanel()
else
showResultPanel()
}}
>
<div className="h-1 w-8 cursor-grab rounded bg-divider-solid" />
</div>
)}
{renderResWrap}
</div>
<TextGenerationSidebar
accessMode={accessMode}
allTasksRun={allTasksRun}
currentTab={currentTab}
customConfig={customConfig}
inputs={inputs}
inputsRef={inputsRef}
isInstalledApp={isInstalledApp}
isPC={isPC}
isWorkflow={isWorkflow}
onBatchSend={handleRunBatch}
onInputsChange={updateInputs}
onRemoveSavedMessage={handleRemoveSavedMessage}
onRunOnceSend={handleRunOnce}
onTabChange={setCurrentTab}
onVisionFilesChange={setCompletionFiles}
promptConfig={promptConfig}
resultExisted={resultExisted}
runControl={runControl}
savedMessages={savedMessages}
siteInfo={siteInfo}
systemFeatures={systemFeatures}
textToSpeechConfig={textToSpeechConfig}
visionConfig={visionConfig}
/>
<TextGenerationResultPanel
allFailedTaskList={allFailedTaskList}
allSuccessTaskList={allSuccessTaskList}
allTaskList={allTaskList}
appId={appId}
appSourceType={appSourceType}
completionFiles={completionFiles}
controlRetry={controlRetry}
controlSend={controlSend}
controlStopResponding={controlStopResponding}
exportRes={exportRes}
handleCompleted={handleCompleted}
handleRetryAllFailedTask={handleRetryAllFailedTask}
handleSaveMessage={handleSaveMessage}
inputs={inputs}
isCallBatchAPI={isCallBatchAPI}
isPC={isPC}
isShowResultPanel={isShowResultPanel}
isWorkflow={isWorkflow}
moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}
noPendingTask={noPendingTask}
onHideResultPanel={hideResultPanel}
onRunControlChange={setRunControl}
onRunStart={handleRunStart}
onShowResultPanel={showResultPanel}
promptConfig={promptConfig}
resultExisted={resultExisted}
showTaskList={showTaskList}
siteInfo={siteInfo}
textToSpeechEnabled={!!textToSpeechConfig?.enabled}
visionConfig={visionConfig}
/>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import type { ChangeEvent, FC, FormEvent } from 'react'
import type { InputValueTypes } from '../types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { PromptConfig } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
@@ -169,7 +170,9 @@ const RunOnce: FC<IRunOnceProps> = ({
)}
{item.type === 'file' && (
<FileUploaderInAttachmentWrapper
value={(inputs[item.key] && typeof inputs[item.key] === 'object') ? [inputs[item.key]] : []}
value={inputs[item.key] && typeof inputs[item.key] === 'object' && !Array.isArray(inputs[item.key])
? [inputs[item.key] as FileEntity]
: []}
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files[0] }) }}
fileConfig={{
...item.config,
@@ -179,7 +182,7 @@ const RunOnce: FC<IRunOnceProps> = ({
)}
{item.type === 'file-list' && (
<FileUploaderInAttachmentWrapper
value={Array.isArray(inputs[item.key]) ? inputs[item.key] : []}
value={Array.isArray(inputs[item.key]) ? inputs[item.key] as FileEntity[] : []}
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files }) }}
fileConfig={{
...item.config,

View File

@@ -0,0 +1,195 @@
import type { FC } from 'react'
import type { InputValueTypes, Task, TextGenerationRunControl } from './types'
import type { PromptConfig } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { AppSourceType } from '@/service/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Res from '@/app/components/share/text-generation/result'
import { cn } from '@/utils/classnames'
import ResDownload from './run-batch/res-download'
import { TaskStatus } from './types'
type TextGenerationResultPanelProps = {
allFailedTaskList: Task[]
allSuccessTaskList: Task[]
allTaskList: Task[]
appId: string
appSourceType: AppSourceType
completionFiles: VisionFile[]
controlRetry: number
controlSend: number
controlStopResponding: number
exportRes: Record<string, string>[]
handleCompleted: (completionRes: string, taskId?: number, isSuccess?: boolean) => void
handleRetryAllFailedTask: () => void
handleSaveMessage: (messageId: string) => Promise<void>
inputs: Record<string, InputValueTypes>
isCallBatchAPI: boolean
isPC: boolean
isShowResultPanel: boolean
isWorkflow: boolean
moreLikeThisEnabled: boolean
noPendingTask: boolean
onHideResultPanel: () => void
onRunControlChange: (control: TextGenerationRunControl | null) => void
onRunStart: () => void
onShowResultPanel: () => void
promptConfig: PromptConfig
resultExisted: boolean
showTaskList: Task[]
siteInfo: SiteInfo
textToSpeechEnabled: boolean
visionConfig: VisionSettings
}
const TextGenerationResultPanel: FC<TextGenerationResultPanelProps> = ({
allFailedTaskList,
allSuccessTaskList,
allTaskList,
appId,
appSourceType,
completionFiles,
controlRetry,
controlSend,
controlStopResponding,
exportRes,
handleCompleted,
handleRetryAllFailedTask,
handleSaveMessage,
inputs,
isCallBatchAPI,
isPC,
isShowResultPanel,
isWorkflow,
moreLikeThisEnabled,
noPendingTask,
onHideResultPanel,
onRunControlChange,
onRunStart,
onShowResultPanel,
promptConfig,
resultExisted,
showTaskList,
siteInfo,
textToSpeechEnabled,
visionConfig,
}) => {
const { t } = useTranslation()
const renderResult = (task?: Task) => (
<Res
key={task?.id}
isWorkflow={isWorkflow}
isCallBatchAPI={isCallBatchAPI}
isPC={isPC}
isMobile={!isPC}
appSourceType={appSourceType}
appId={appId}
isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig}
moreLikeThisEnabled={moreLikeThisEnabled}
inputs={isCallBatchAPI && task ? task.params.inputs : inputs}
controlSend={controlSend}
controlRetry={task?.status === TaskStatus.failed ? controlRetry : 0}
controlStopResponding={controlStopResponding}
onShowRes={onShowResultPanel}
handleSaveMessage={handleSaveMessage}
taskId={task?.id}
onCompleted={handleCompleted}
visionConfig={visionConfig}
completionFiles={completionFiles}
isShowTextToSpeech={textToSpeechEnabled}
siteInfo={siteInfo}
onRunStart={onRunStart}
onRunControlChange={!isCallBatchAPI ? onRunControlChange : undefined}
hideInlineStopButton={!isCallBatchAPI}
/>
)
return (
<div
className={cn(
isPC
? 'h-full w-0 grow'
: isShowResultPanel
? 'fixed inset-0 z-50 bg-background-overlay backdrop-blur-sm'
: resultExisted
? 'relative h-16 shrink-0 overflow-hidden bg-background-default-burn pt-2.5'
: '',
)}
>
{!isPC && (
<div
className={cn(
isShowResultPanel
? 'flex items-center justify-center p-2 pt-6'
: 'absolute left-0 top-0 z-10 flex w-full items-center justify-center px-2 pb-[57px] pt-[3px]',
)}
onClick={() => {
if (isShowResultPanel)
onHideResultPanel()
else
onShowResultPanel()
}}
>
<div className="h-1 w-8 cursor-grab rounded bg-divider-solid" />
</div>
)}
<div
className={cn(
'relative flex h-full flex-col',
!isPC && 'h-[calc(100vh_-_36px)] rounded-t-2xl shadow-lg backdrop-blur-sm',
!isPC
? isShowResultPanel
? 'bg-background-default-burn'
: 'border-t-[0.5px] border-divider-regular bg-components-panel-bg'
: 'bg-chatbot-bg',
)}
>
{isCallBatchAPI && (
<div
className={cn(
'flex shrink-0 items-center justify-between px-14 pb-2 pt-9',
!isPC && 'px-4 pb-1 pt-3',
)}
>
<div className="text-text-primary system-md-semibold-uppercase">{t('generation.executions', { ns: 'share', num: allTaskList.length })}</div>
{allSuccessTaskList.length > 0 && (
<ResDownload
isMobile={!isPC}
values={exportRes}
/>
)}
</div>
)}
<div
className={cn(
'flex h-0 grow flex-col overflow-y-auto',
isPC && 'px-14 py-8',
isPC && isCallBatchAPI && 'pt-0',
!isPC && 'p-0 pb-2',
)}
>
{isCallBatchAPI ? showTaskList.map(task => renderResult(task)) : renderResult()}
{!noPendingTask && (
<div className="mt-4">
<Loading type="area" />
</div>
)}
</div>
{isCallBatchAPI && allFailedTaskList.length > 0 && (
<div className="absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-sm">
<span aria-hidden className="i-ri-error-warning-fill h-4 w-4 text-text-destructive" />
<div className="text-text-secondary system-sm-medium">{t('generation.batchFailed.info', { ns: 'share', num: allFailedTaskList.length })}</div>
<div className="h-3.5 w-px bg-divider-regular"></div>
<div onClick={handleRetryAllFailedTask} className="cursor-pointer text-text-accent system-sm-semibold-uppercase">{t('generation.batchFailed.retry', { ns: 'share' })}</div>
</div>
)}
</div>
</div>
)
}
export default TextGenerationResultPanel

View File

@@ -0,0 +1,177 @@
import type { FC, RefObject } from 'react'
import type { InputValueTypes, TextGenerationCustomConfig, TextGenerationRunControl } from './types'
import type { PromptConfig, SavedMessage, TextToSpeechConfig } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import type { SystemFeatures } from '@/types/feature'
import { useTranslation } from 'react-i18next'
import SavedItems from '@/app/components/app/text-generate/saved-items'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { appDefaultIconBackground } from '@/config'
import { AccessMode } from '@/models/access-control'
import { cn } from '@/utils/classnames'
import TabHeader from '../../base/tab-header'
import MenuDropdown from './menu-dropdown'
import RunBatch from './run-batch'
import RunOnce from './run-once'
type TextGenerationSidebarProps = {
accessMode: AccessMode
allTasksRun: boolean
currentTab: string
customConfig: TextGenerationCustomConfig | null
inputs: Record<string, InputValueTypes>
inputsRef: RefObject<Record<string, InputValueTypes>>
isInstalledApp: boolean
isPC: boolean
isWorkflow: boolean
onBatchSend: (data: string[][]) => void
onInputsChange: (inputs: Record<string, InputValueTypes>) => void
onRemoveSavedMessage: (messageId: string) => Promise<void>
onRunOnceSend: () => void
onTabChange: (tab: string) => void
onVisionFilesChange: (files: VisionFile[]) => void
promptConfig: PromptConfig
resultExisted: boolean
runControl: TextGenerationRunControl | null
savedMessages: SavedMessage[]
siteInfo: SiteInfo
systemFeatures: SystemFeatures
textToSpeechConfig: TextToSpeechConfig | null
visionConfig: VisionSettings
}
const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
accessMode,
allTasksRun,
currentTab,
customConfig,
inputs,
inputsRef,
isInstalledApp,
isPC,
isWorkflow,
onBatchSend,
onInputsChange,
onRemoveSavedMessage,
onRunOnceSend,
onTabChange,
onVisionFilesChange,
promptConfig,
resultExisted,
runControl,
savedMessages,
siteInfo,
systemFeatures,
textToSpeechConfig,
visionConfig,
}) => {
const { t } = useTranslation()
return (
<div
className={cn(
'relative flex h-full shrink-0 flex-col',
isPC ? 'w-[600px] max-w-[50%]' : resultExisted ? 'h-[calc(100%_-_64px)]' : '',
isInstalledApp && 'rounded-l-2xl',
)}
>
<div className={cn('shrink-0 space-y-4 border-b border-divider-subtle', isPC ? 'bg-components-panel-bg p-8 pb-0' : 'p-4 pb-0')}>
<div className="flex items-center gap-3">
<AppIcon
size={isPC ? 'large' : 'small'}
iconType={siteInfo.icon_type}
icon={siteInfo.icon}
background={siteInfo.icon_background || appDefaultIconBackground}
imageUrl={siteInfo.icon_url}
/>
<div className="grow truncate text-text-secondary system-md-semibold">{siteInfo.title}</div>
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} />
</div>
{siteInfo.description && (
<div className="text-text-tertiary system-xs-regular">{siteInfo.description}</div>
)}
<TabHeader
items={[
{ id: 'create', name: t('generation.tabs.create', { ns: 'share' }) },
{ id: 'batch', name: t('generation.tabs.batch', { ns: 'share' }) },
...(!isWorkflow
? [{
id: 'saved',
name: t('generation.tabs.saved', { ns: 'share' }),
isRight: true,
icon: <span aria-hidden className="i-ri-bookmark-3-line h-4 w-4" />,
extra: savedMessages.length > 0
? (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)
: null,
}]
: []),
]}
value={currentTab}
onChange={onTabChange}
/>
</div>
<div
className={cn(
'h-0 grow overflow-y-auto bg-components-panel-bg',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<div className={cn(currentTab === 'create' ? 'block' : 'hidden')}>
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={onInputsChange}
promptConfig={promptConfig}
onSend={onRunOnceSend}
visionConfig={visionConfig}
onVisionFilesChange={onVisionFilesChange}
runControl={runControl}
/>
</div>
<div className={cn(currentTab === 'batch' ? 'block' : 'hidden')}>
<RunBatch
vars={promptConfig.prompt_variables}
onSend={onBatchSend}
isAllFinished={allTasksRun}
/>
</div>
{currentTab === 'saved' && (
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={onRemoveSavedMessage}
onStartCreateContent={() => onTabChange('create')}
/>
)}
</div>
{!customConfig?.remove_webapp_brand && (
<div
className={cn(
'flex shrink-0 items-center gap-1.5 bg-components-panel-bg py-3',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<div className="text-text-tertiary system-2xs-medium-uppercase">{t('chat.poweredBy', { ns: 'share' })}</div>
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
: customConfig?.replace_webapp_logo
? <img src={customConfig.replace_webapp_logo} alt="logo" className="block h-5 w-auto" />
: <DifyLogo size="small" />}
</div>
)}
</div>
)
}
export default TextGenerationSidebar

View File

@@ -1,3 +1,5 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
type TaskParam = {
inputs: Record<string, string | boolean | undefined>
}
@@ -15,5 +17,22 @@ export enum TaskStatus {
failed = 'failed',
}
// eslint-disable-next-line ts/no-explicit-any
export type InputValueTypes = string | boolean | number | string[] | object | undefined | any
export type InputValueTypes
= | string
| boolean
| number
| string[]
| Record<string, unknown>
| FileEntity
| FileEntity[]
| undefined
export type TextGenerationRunControl = {
onStop: () => Promise<void> | void
isStopping: boolean
}
export type TextGenerationCustomConfig = Record<string, unknown> & {
remove_webapp_brand?: boolean
replace_webapp_logo?: string
}

View File

@@ -1275,9 +1275,6 @@
},
"regexp/no-unused-capturing-group": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 21
}
},
"app/components/app/overview/trigger-card.tsx": {
@@ -5937,12 +5934,6 @@
"app/components/share/text-generation/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 6
},
"ts/no-explicit-any": {
"count": 8
}
},
"app/components/share/text-generation/info-modal.tsx": {

View File

@@ -82,6 +82,7 @@ export default defineConfig(({ mode }) => {
// Vitest config
test: {
detectAsyncLeaks: true,
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],