Compare commits

..

7 Commits

Author SHA1 Message Date
autofix-ci[bot]
344f6be7cd [autofix.ci] apply automated fixes 2026-03-13 10:10:00 +00:00
Yanli 盐粒
f169cf8654 Merge origin/main into yanli/fix-iter-log 2026-03-13 18:07:15 +08:00
Yanli 盐粒
e76fbcb045 fix: guard loop child node starts 2026-03-10 20:34:07 +08:00
盐粒 Yanli
e6f00a2bf9 Update web/app/components/workflow/utils/top-level-tracing.ts
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-10 20:13:49 +08:00
Yanli 盐粒
715f3affe5 chore: address review feedback 2026-03-10 19:31:20 +08:00
autofix-ci[bot]
4f73766a21 [autofix.ci] apply automated fixes 2026-03-10 11:18:09 +00:00
Yanli 盐粒
fe90453eed fix: preserve workflow tracing by execution id 2026-03-10 19:14:14 +08:00
39 changed files with 1530 additions and 3486 deletions

View File

@@ -1,13 +1,33 @@
name: Setup Web Environment
description: Setup pnpm, Node.js, and install web dependencies.
inputs:
node-version:
description: Node.js version to use
required: false
default: "22"
install-dependencies:
description: Whether to install web dependencies after setting up Node.js
required: false
default: "true"
runs:
using: composite
steps:
- name: Setup Vite+
uses: voidzero-dev/setup-vp@b5d848f5a62488f3d3d920f8aa6ac318a60c5f07 # v1
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
with:
node-version-file: "./web/.nvmrc"
cache: true
run-install: |
- cwd: ./web
args: ['--frozen-lockfile']
package_json_file: web/package.json
run_install: false
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Install dependencies
if: ${{ inputs.install-dependencies == 'true' }}
shell: bash
run: pnpm --dir web install --frozen-lockfile

View File

@@ -102,11 +102,13 @@ jobs:
- name: Setup web environment
if: steps.web-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
with:
node-version: "24"
- name: ESLint autofix
if: steps.web-changes.outputs.any_changed == 'true'
run: |
cd web
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
pnpm eslint --concurrency=2 --prune-suppressions --quiet || true
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3

View File

@@ -88,7 +88,7 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: |
vp run lint:ci
pnpm run lint:ci
# pnpm run lint:report
# continue-on-error: true
@@ -102,17 +102,17 @@ jobs:
- name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: vp run lint:tss
run: pnpm run lint:tss
- name: Web type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: vp run type-check
run: pnpm run type-check
- name: Web dead code check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: vp run knip
run: pnpm run knip
superlinter:
name: SuperLinter

View File

@@ -50,6 +50,8 @@ jobs:
- name: Setup web environment
uses: ./.github/actions/setup-web
with:
install-dependencies: "false"
- name: Detect changed files and generate diff
id: detect_changes

View File

@@ -43,7 +43,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Run tests
run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
run: pnpm vitest run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
- name: Upload blob report
if: ${{ !cancelled() }}
@@ -84,7 +84,7 @@ jobs:
merge-multiple: true
- name: Merge reports
run: vp test --merge-reports --reporter=json --reporter=agent --coverage
run: pnpm vitest --merge-reports --reporter=json --reporter=agent --coverage
- name: Check app/components diff coverage
env:
@@ -447,4 +447,4 @@ jobs:
- name: Web build check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: vp run build
run: pnpm run build

View File

@@ -58,9 +58,8 @@ class FileService:
# get file extension
extension = os.path.splitext(filename)[1].lstrip(".").lower()
# 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 ["/", "\\"]):
# check if filename contains invalid characters
if any(c in filename for c in ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]):
raise ValueError("Filename contains invalid characters")
if len(filename) > 200:

View File

@@ -358,19 +358,21 @@ class WorkflowRunRestore:
self,
model: type[DeclarativeBase] | Any,
) -> tuple[set[str], set[str], set[str]]:
table = model.__table__
columns = list(table.columns)
autoincrement_column = getattr(table, "autoincrement_column", None)
def has_insert_default(column: Any) -> bool:
# SQLAlchemy may set column.autoincrement to "auto" on non-PK columns.
# Only treat the resolved autoincrement column as DB-generated.
return column.default is not None or column.server_default is not None or column is autoincrement_column
columns = list(model.__table__.columns)
column_names = {column.key for column in columns}
required_columns = {column.key for column in columns if not column.nullable and not has_insert_default(column)}
required_columns = {
column.key
for column in columns
if not column.nullable
and column.default is None
and column.server_default is None
and not column.autoincrement
}
non_nullable_with_default = {
column.key for column in columns if not column.nullable and has_insert_default(column)
column.key
for column in columns
if not column.nullable
and (column.default is not None or column.server_default is not None or column.autoincrement)
}
return column_names, required_columns, non_nullable_with_default

View File

@@ -263,27 +263,6 @@ 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 path separators (/, \\)
- Validates rejection of filenames with invalid characters (/, \\, :, *, ?, ", <, >, |)
- Tests filename length truncation (max 200 characters)
- Prevents path traversal attacks
- Handles edge cases like empty filenames
@@ -535,23 +535,30 @@ 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 path separators.
Security-critical test that validates rejection of dangerous filename characters.
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)
- ZIP entry traversal issues
- Ambiguous path handling
- Command injection
- File system corruption
- Cross-platform compatibility issues
"""
# Arrange - Create filename with invalid character
filename = f"test{invalid_char}file.txt"
invalid_chars = ["/", "\\"]
# Define complete list of invalid characters
invalid_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]
# Act - Check if filename contains any invalid character
has_invalid_char = any(c in filename for c in invalid_chars)
@@ -563,7 +570,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)
@@ -571,16 +578,6 @@ 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
@@ -907,7 +904,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)
@@ -924,7 +921,7 @@ class TestFilenameValidation:
"مستند.txt", # Arabic
"ファイル.jpg", # Japanese
]
invalid_chars = ["/", "\\"]
invalid_chars = ["/", "\\", ":", "*", "?", '"', "<", ">", "|"]
# Act & Assert - Unicode should be allowed
for filename in unicode_filenames:

View File

@@ -13,7 +13,6 @@ from datetime import datetime
from unittest.mock import Mock, create_autospec, patch
import pytest
from sqlalchemy import Column, Integer, MetaData, String, Table
from libs.archive_storage import ArchiveStorageNotConfiguredError
from models.trigger import WorkflowTriggerLog
@@ -128,41 +127,10 @@ class WorkflowRunRestoreTestDataFactory:
if tables_data is None:
tables_data = {
"workflow_runs": [
{
"id": "run-123",
"tenant_id": "tenant-123",
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
"created_by": "user-123",
}
],
"workflow_runs": [{"id": "run-123", "tenant_id": "tenant-123"}],
"workflow_app_logs": [
{
"id": "log-1",
"tenant_id": "tenant-123",
"app_id": "app-123",
"workflow_id": "workflow-123",
"workflow_run_id": "run-123",
"created_from": "app",
"created_by_role": "account",
"created_by": "user-123",
},
{
"id": "log-2",
"tenant_id": "tenant-123",
"app_id": "app-123",
"workflow_id": "workflow-123",
"workflow_run_id": "run-123",
"created_from": "app",
"created_by_role": "account",
"created_by": "user-123",
},
{"id": "log-1", "workflow_run_id": "run-123"},
{"id": "log-2", "workflow_run_id": "run-123"},
],
}
@@ -438,48 +406,14 @@ class TestGetModelColumnInfo:
assert "created_by" in column_names
assert "status" in column_names
# Columns without defaults should be required for restore inserts.
assert {
"tenant_id",
"app_id",
"workflow_id",
"type",
"triggered_from",
"version",
"status",
"created_by_role",
"created_by",
}.issubset(required_columns)
assert "id" not in required_columns
assert "created_at" not in required_columns
# WorkflowRun model has no required columns (all have defaults or are auto-generated)
assert required_columns == set()
# Check columns with defaults or server defaults
assert "id" in non_nullable_with_default
assert "created_at" in non_nullable_with_default
assert "elapsed_time" in non_nullable_with_default
assert "total_tokens" in non_nullable_with_default
assert "tenant_id" not in non_nullable_with_default
def test_non_pk_auto_autoincrement_column_is_still_required(self):
"""`autoincrement='auto'` should not mark non-PK columns as defaulted."""
restore = WorkflowRunRestore()
test_table = Table(
"test_autoincrement",
MetaData(),
Column("id", Integer, primary_key=True, autoincrement=True),
Column("required_field", String(255), nullable=False),
Column("defaulted_field", String(255), nullable=False, default="x"),
)
class MockModel:
__table__ = test_table
_, required_columns, non_nullable_with_default = restore._get_model_column_info(MockModel)
assert required_columns == {"required_field"}
assert "id" in non_nullable_with_default
assert "defaulted_field" in non_nullable_with_default
# ---------------------------------------------------------------------------
@@ -531,32 +465,7 @@ class TestRestoreTableRecords:
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
mock_pg_insert.return_value = mock_stmt
records = [
{
"id": "test1",
"tenant_id": "tenant-123",
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
"created_by": "user-123",
},
{
"id": "test2",
"tenant_id": "tenant-123",
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
"created_by": "user-123",
},
]
records = [{"id": "test1", "tenant_id": "tenant-123"}, {"id": "test2", "tenant_id": "tenant-123"}]
result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0")
@@ -568,7 +477,8 @@ class TestRestoreTableRecords:
restore = WorkflowRunRestore()
mock_session = Mock()
# Use a dedicated mock model to isolate required-column validation behavior.
# Since WorkflowRun has no required columns, we need to test with a different model
# Let's test with a mock model that has required columns
mock_model = Mock()
# Mock a required column
@@ -1055,13 +965,6 @@ class TestIntegration:
"id": "run-123",
"tenant_id": "tenant-123",
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
"created_by": "user-123",
"created_at": "2024-01-01T12:00:00",
}
],

View File

@@ -1,235 +0,0 @@
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 { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { 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,10 +131,6 @@ 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()
@@ -216,54 +212,4 @@ 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, useRef, useState } from 'react'
import { useCallback, useEffect, 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,7 +99,6 @@ 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>(
@@ -138,22 +137,10 @@ 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()
if (hideMoreTimerRef.current)
clearTimeout(hideMoreTimerRef.current)
hideMoreTimerRef.current = setTimeout(() => {
setTimeout(() => {
setIsShowMore(false)
hideMoreTimerRef.current = null
}, 200)
}
@@ -244,12 +231,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="grow text-text-primary title-2xl-semi-bold">{t(`${prefixSettings}.title`, { ns: 'appOverview' })}</div>
<div className="title-2xl-semi-bold grow text-text-primary">{t(`${prefixSettings}.title`, { ns: 'appOverview' })}</div>
<ActionButton className="shrink-0" onClick={onHide}>
<RiCloseLine className="h-4 w-4" />
</ActionButton>
</div>
<div className="mt-0.5 text-text-tertiary system-xs-regular">
<div className="system-xs-regular mt-0.5 text-text-tertiary">
<span>{t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })}</span>
</div>
</div>
@@ -258,7 +245,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* name & icon */}
<div className="flex gap-4">
<div className="grow">
<div className={cn('mb-1 py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold mb-1 py-1 text-text-secondary')}>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</div>
<Input
className="w-full"
value={inputInfo.title}
@@ -278,32 +265,32 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* description */}
<div className="relative">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{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('pb-0.5 text-text-tertiary body-xs-regular')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
<p className={cn('body-xs-regular pb-0.5 text-text-tertiary')}>{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('py-1 text-text-secondary system-sm-semibold')}>{t('answerIcon.title', { ns: 'app' })}</div>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{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="pb-0.5 text-text-tertiary body-xs-regular">{t('answerIcon.description', { ns: 'app' })}</p>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{t('answerIcon.description', { ns: 'app' })}</p>
</div>
)}
{/* language */}
<div className="flex items-center">
<div className={cn('grow py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold grow py-1 text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
<SimpleSelect
wrapperClassName="w-[200px]"
items={languages.filter(item => item.supported)}
@@ -316,8 +303,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{isChat && (
<div className="flex items-center">
<div className="grow">
<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 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>
<div className="shrink-0">
<Input
@@ -327,7 +314,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
placeholder="E.g #A020F0"
/>
<div className="flex items-center justify-between">
<p className={cn('text-text-tertiary body-xs-regular')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
<p className={cn('body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
<Switch value={inputInfo.chatColorThemeInverted} onChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
</div>
</div>
@@ -336,22 +323,22 @@ const SettingsModal: FC<ISettingsModalProps> = ({
{/* workflow detail */}
<div className="w-full">
<div className="flex items-center justify-between">
<div className={cn('py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{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="pb-0.5 text-text-tertiary body-xs-regular">{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</p>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{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('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')}>
<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')}>
{t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' })}
{' '}
&
@@ -369,7 +356,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
<div className="w-full">
<div className="flex items-center">
<div className="flex grow items-center">
<div className={cn('mr-1 py-1 text-text-secondary system-sm-semibold')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
<div className={cn('system-sm-semibold mr-1 py-1 text-text-secondary')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
{/* upgrade button */}
{enableBilling && isFreePlan && (
<div className="h-[18px] select-none">
@@ -398,7 +385,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
/>
</Tooltip>
</div>
<p className="pb-0.5 text-text-tertiary body-xs-regular">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
<p className="body-xs-regular pb-0.5 text-text-tertiary">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
{inputInfo.copyrightSwitchValue && (
<Input
className="mt-2 h-10"
@@ -410,8 +397,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* privacy policy */}
<div className="w-full">
<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')}>
<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')}>
<Trans
i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
ns="appOverview"
@@ -427,8 +414,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
{/* custom disclaimer */}
<div className="w-full">
<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>
<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>
<Textarea
className="mt-1"
value={inputInfo.customDisclaimer}

View File

@@ -449,6 +449,66 @@ describe('useChat', () => {
expect(lastResponse.workflowProcess?.status).toBe('failed')
})
it('should keep separate iteration traces for repeated executions of the same iteration node', async () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'iteration trace test' }, {})
})
act(() => {
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
callbacks.onIterationStart({ data: { id: 'iter-run-1', node_id: 'iter-1' } })
callbacks.onIterationStart({ data: { id: 'iter-run-2', node_id: 'iter-1' } })
callbacks.onIterationFinish({ data: { id: 'iter-run-1', node_id: 'iter-1', status: 'succeeded' } })
callbacks.onIterationFinish({ data: { id: 'iter-run-2', node_id: 'iter-1', status: 'succeeded' } })
})
const tracing = result.current.chatList[1].workflowProcess?.tracing ?? []
expect(tracing).toHaveLength(2)
expect(tracing).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'iter-run-1', status: 'succeeded' }),
expect.objectContaining({ id: 'iter-run-2', status: 'succeeded' }),
]))
})
it('should keep separate top-level traces for repeated executions of the same node', async () => {
let callbacks: HookCallbacks
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
callbacks = options as HookCallbacks
})
const { result } = renderHook(() => useChat())
act(() => {
result.current.handleSend('test-url', { query: 'top-level trace test' }, {})
})
act(() => {
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
callbacks.onNodeStarted({ data: { id: 'node-run-1', node_id: 'node-1', title: 'Node 1' } })
callbacks.onNodeStarted({ data: { id: 'node-run-2', node_id: 'node-1', title: 'Node 1 retry' } })
callbacks.onNodeFinished({ data: { id: 'node-run-1', node_id: 'node-1', status: 'succeeded' } })
callbacks.onNodeFinished({ data: { id: 'node-run-2', node_id: 'node-1', status: 'succeeded' } })
})
const tracing = result.current.chatList[1].workflowProcess?.tracing ?? []
expect(tracing).toHaveLength(2)
expect(tracing).toEqual(expect.arrayContaining([
expect.objectContaining({ id: 'node-run-1', status: 'succeeded' }),
expect.objectContaining({ id: 'node-run-2', status: 'succeeded' }),
]))
})
it('should handle early exits in tracing events during iteration or loop', async () => {
let callbacks: HookCallbacks
@@ -484,7 +544,7 @@ describe('useChat', () => {
callbacks.onNodeFinished({ data: { id: 'n-1', iteration_id: 'iter-1' } })
})
const traceLen1 = result.current.chatList[result.current.chatList.length - 1].workflowProcess?.tracing?.length
const traceLen1 = result.current.chatList.at(-1)!.workflowProcess?.tracing?.length
expect(traceLen1).toBe(0) // None added due to iteration early hits
})
@@ -568,7 +628,7 @@ describe('useChat', () => {
expect(result.current.chatList.some(item => item.id === 'question-m-child')).toBe(true)
expect(result.current.chatList.some(item => item.id === 'm-child')).toBe(true)
expect(result.current.chatList[result.current.chatList.length - 1].content).toBe('child answer')
expect(result.current.chatList.at(-1)!.content).toBe('child answer')
})
it('should strip local file urls before sending payload', () => {
@@ -666,7 +726,7 @@ describe('useChat', () => {
})
expect(onGetConversationMessages).toHaveBeenCalled()
expect(result.current.chatList[result.current.chatList.length - 1].content).toBe('streamed content')
expect(result.current.chatList.at(-1)!.content).toBe('streamed content')
})
it('should clear suggested questions when suggestion fetch fails after completion', async () => {
@@ -712,7 +772,7 @@ describe('useChat', () => {
callbacks.onNodeFinished({ data: { node_id: 'n-loop', id: 'n-loop' } })
})
const latestResponse = result.current.chatList[result.current.chatList.length - 1]
const latestResponse = result.current.chatList.at(-1)!
expect(latestResponse.workflowProcess?.tracing).toHaveLength(0)
})
@@ -739,7 +799,7 @@ describe('useChat', () => {
callbacks.onTTSChunk('m-th-bind', '')
})
const latestResponse = result.current.chatList[result.current.chatList.length - 1]
const latestResponse = result.current.chatList.at(-1)!
expect(latestResponse.id).toBe('m-th-bind')
expect(latestResponse.conversationId).toBe('c-th-bind')
expect(latestResponse.workflowProcess?.status).toBe('succeeded')
@@ -832,7 +892,7 @@ describe('useChat', () => {
callbacks.onCompleted()
})
const lastResponse = result.current.chatList[result.current.chatList.length - 1]
const lastResponse = result.current.chatList.at(-1)!
expect(lastResponse.agent_thoughts![0].thought).toContain('resumed')
expect(lastResponse.workflowProcess?.tracing?.length).toBeGreaterThan(0)

View File

@@ -32,6 +32,7 @@ import {
} from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast/context'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { upsertTopLevelTracingNodeOnStart } from '@/app/components/workflow/utils/top-level-tracing'
import useTimestamp from '@/hooks/use-timestamp'
import {
sseGet,
@@ -395,8 +396,7 @@ export const useChat = (
if (!responseItem.workflowProcess?.tracing)
return
const tracing = responseItem.workflowProcess.tracing
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
const iterationIndex = tracing.findIndex(item => item.id === iterationFinishedData.id)!
if (iterationIndex > -1) {
tracing[iterationIndex] = {
...tracing[iterationIndex],
@@ -408,38 +408,34 @@ export const useChat = (
},
onNodeStarted: ({ data: nodeStartedData }) => {
updateChatTreeNode(messageId, (responseItem) => {
if (params.loop_id)
return
if (!responseItem.workflowProcess)
return
if (!responseItem.workflowProcess.tracing)
responseItem.workflowProcess.tracing = []
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
// if the node is already started, update the node
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
if (nodeStartedData.iteration_id)
return
responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
}
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
})
},
onNodeFinished: ({ data: nodeFinishedData }) => {
updateChatTreeNode(messageId, (responseItem) => {
if (params.loop_id)
return
if (!responseItem.workflowProcess?.tracing)
return
if (nodeFinishedData.iteration_id)
return
if (nodeFinishedData.loop_id)
return
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.id === nodeFinishedData.id
@@ -481,8 +477,7 @@ export const useChat = (
if (!responseItem.workflowProcess?.tracing)
return
const tracing = responseItem.workflowProcess.tracing
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
const loopIndex = tracing.findIndex(item => item.id === loopFinishedData.id)!
if (loopIndex > -1) {
tracing[loopIndex] = {
...tracing[loopIndex],
@@ -558,7 +553,7 @@ export const useChat = (
{},
otherOptions,
)
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer])
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer, params.loop_id])
const updateCurrentQAOnTree = useCallback(({
parentId,
@@ -948,12 +943,13 @@ export const useChat = (
},
onIterationFinish: ({ data: iterationFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
tracing[iterationIndex] = {
...tracing[iterationIndex],
...iterationFinishedData,
status: WorkflowRunningStatus.Succeeded,
const iterationIndex = tracing.findIndex(item => item.id === iterationFinishedData.id)!
if (iterationIndex > -1) {
tracing[iterationIndex] = {
...tracing[iterationIndex],
...iterationFinishedData,
status: WorkflowRunningStatus.Succeeded,
}
}
updateCurrentQAOnTree({
@@ -964,30 +960,19 @@ export const useChat = (
})
},
onNodeStarted: ({ data: nodeStartedData }) => {
// `data` is the outer send payload for this request; loop child runs should not emit top-level node traces here.
if (data.loop_id)
return
if (!responseItem.workflowProcess)
return
if (!responseItem.workflowProcess.tracing)
responseItem.workflowProcess.tracing = []
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
if (nodeStartedData.iteration_id)
return
if (data.loop_id)
return
responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
}
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
@@ -996,10 +981,14 @@ export const useChat = (
})
},
onNodeFinished: ({ data: nodeFinishedData }) => {
// Use the outer request payload here as well so loop child runs skip top-level finish handling entirely.
if (data.loop_id)
return
if (nodeFinishedData.iteration_id)
return
if (data.loop_id)
if (nodeFinishedData.loop_id)
return
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
@@ -1045,12 +1034,13 @@ export const useChat = (
},
onLoopFinish: ({ data: loopFinishedData }) => {
const tracing = responseItem.workflowProcess!.tracing!
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
tracing[loopIndex] = {
...tracing[loopIndex],
...loopFinishedData,
status: WorkflowRunningStatus.Succeeded,
const loopIndex = tracing.findIndex(item => item.id === loopFinishedData.id)!
if (loopIndex > -1) {
tracing[loopIndex] = {
...tracing[loopIndex],
...loopFinishedData,
status: WorkflowRunningStatus.Succeeded,
}
}
updateCurrentQAOnTree({

View File

@@ -1,190 +0,0 @@
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

@@ -1,261 +0,0 @@
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

@@ -1,298 +0,0 @@
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

@@ -1,314 +0,0 @@
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

@@ -1,158 +0,0 @@
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

@@ -1,270 +0,0 @@
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,20 +1,65 @@
'use client'
import type { FC } from 'react'
import type { InputValueTypes, TextGenerationRunControl } from './types'
import type {
MoreLikeThisConfig,
PromptConfig,
SavedMessage,
TextToSpeechConfig,
} from '@/models/debug'
import type { InstalledApp } from '@/models/explore'
import type { VisionFile } from '@/types/app'
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import {
RiBookmark3Line,
RiErrorWarningFill,
} from '@remixicon/react'
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 { 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'
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
}
export type IMainProps = {
isInstalledApp?: boolean
@@ -27,6 +72,8 @@ 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
@@ -34,90 +81,428 @@ const TextGeneration: FC<IMainProps> = ({
const searchParams = useSearchParams()
const mode = searchParams.get('mode') || 'create'
const [currentTab, setCurrentTab] = useState<string>(['create', 'batch'].includes(mode) ? mode : 'create')
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
const inputsRef = useRef(inputs)
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const [runControl, setRunControl] = useState<TextGenerationRunControl | null>(null)
const [controlSend, setControlSend] = useState(0)
const [controlStopResponding, setControlStopResponding] = useState(0)
const [resultExisted, setResultExisted] = useState(false)
const [isShowResultPanel, { setTrue: showResultPanelState, setFalse: hideResultPanel }] = useBoolean(false)
const updateInputs = useCallback((newInputs: Record<string, InputValueTypes>) => {
setInputs(newInputs)
// 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 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)
const {
accessMode,
appId,
appSourceType,
customConfig,
handleRemoveSavedMessage,
handleSaveMessage,
moreLikeThisConfig,
promptConfig,
savedMessages,
siteInfo,
systemFeatures,
textToSpeechConfig,
visionConfig,
} = useTextGenerationAppState({
isInstalledApp,
isWorkflow,
})
// 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()
}
const {
allFailedTaskList,
allSuccessTaskList,
allTaskList,
allTasksRun,
controlRetry,
exportRes,
handleCompleted,
handleRetryAllFailedTask,
handleRunBatch: runBatchExecution,
isCallBatchAPI,
noPendingTask,
resetBatchExecution,
setIsCallBatchAPI,
showTaskList,
} = useTextGenerationBatch({
promptConfig,
notify,
t,
// send message task
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 [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const [runControl, setRunControl] = useState<{ onStop: () => Promise<void> | void, isStopping: boolean } | null>(null)
useEffect(() => {
if (isCallBatchAPI)
setRunControl(null)
}, [isCallBatchAPI])
const showResultPanel = useCallback(() => {
setTimeout(() => {
showResultPanelState()
}, 0)
}, [showResultPanelState])
const handleRunStart = useCallback(() => {
setResultExisted(true)
}, [])
const handleRunOnce = useCallback(() => {
const handleSend = () => {
setIsCallBatchAPI(false)
setControlSend(Date.now())
resetBatchExecution()
showResultPanel()
}, [resetBatchExecution, setIsCallBatchAPI, showResultPanel])
const handleRunBatch = useCallback((data: string[][]) => {
runBatchExecution(data, {
onStart: () => {
setControlSend(Date.now())
setControlStopResponding(Date.now())
showResultPanel()
},
// eslint-disable-next-line ts/no-use-before-define
setAllTaskList([]) // clear batch task running status
// eslint-disable-next-line ts/no-use-before-define
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]
})
}, [runBatchExecution, 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>
)
if (!appId || !siteInfo || !promptConfig) {
return (
@@ -126,72 +511,147 @@ const TextGeneration: FC<IMainProps> = ({
</div>
)
}
return (
<div
className={cn(
'bg-background-default-burn',
isPC ? 'flex' : 'flex-col',
isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen',
)}
<div className={cn(
'bg-background-default-burn',
isPC && 'flex',
!isPC && 'flex-col',
isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen',
)}
>
<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}
/>
{/* 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>
</div>
)
}

View File

@@ -337,11 +337,12 @@ const Result: FC<IResultProps> = ({
onIterationFinish: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const iterationsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
draft.tracing[iterationsIndex] = {
...data,
expand: !!data.error,
const iterationsIndex = draft.tracing.findIndex(item => item.id === data.id)
if (iterationsIndex > -1) {
draft.tracing[iterationsIndex] = {
...data,
expand: !!data.error,
}
}
}))
},
@@ -366,11 +367,12 @@ const Result: FC<IResultProps> = ({
onLoopFinish: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
draft.tracing[loopsIndex] = {
...data,
expand: !!data.error,
const loopsIndex = draft.tracing.findIndex(item => item.id === data.id)
if (loopsIndex > -1) {
draft.tracing[loopsIndex] = {
...data,
expand: !!data.error,
}
}
}))
},

View File

@@ -1,6 +1,5 @@
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'
@@ -170,9 +169,7 @@ const RunOnce: FC<IRunOnceProps> = ({
)}
{item.type === 'file' && (
<FileUploaderInAttachmentWrapper
value={inputs[item.key] && typeof inputs[item.key] === 'object' && !Array.isArray(inputs[item.key])
? [inputs[item.key] as FileEntity]
: []}
value={(inputs[item.key] && typeof inputs[item.key] === 'object') ? [inputs[item.key]] : []}
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files[0] }) }}
fileConfig={{
...item.config,
@@ -182,7 +179,7 @@ const RunOnce: FC<IRunOnceProps> = ({
)}
{item.type === 'file-list' && (
<FileUploaderInAttachmentWrapper
value={Array.isArray(inputs[item.key]) ? inputs[item.key] as FileEntity[] : []}
value={Array.isArray(inputs[item.key]) ? inputs[item.key] : []}
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files }) }}
fileConfig={{
...item.config,

View File

@@ -1,195 +0,0 @@
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

@@ -1,177 +0,0 @@
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,5 +1,3 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
type TaskParam = {
inputs: Record<string, string | boolean | undefined>
}
@@ -17,22 +15,5 @@ export enum TaskStatus {
failed = 'failed',
}
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
}
// eslint-disable-next-line ts/no-explicit-any
export type InputValueTypes = string | boolean | number | string[] | object | undefined | any

View File

@@ -178,6 +178,28 @@ describe('useWorkflowAgentLog', () => {
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
})
it('should attach the log to the matching execution id when a node runs multiple times', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ id: 'trace-1', node_id: 'n1', execution_metadata: {} },
{ id: 'trace-2', node_id: 'n1', execution_metadata: {} },
],
}),
},
})
result.current.handleWorkflowAgentLog({
data: { node_id: 'n1', node_execution_id: 'trace-2', message_id: 'm2' },
} as AgentLogResponse)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing[0].execution_metadata!.agent_log).toBeUndefined()
expect(tracing[1].execution_metadata!.agent_log).toHaveLength(1)
expect(tracing[1].execution_metadata!.agent_log![0].message_id).toBe('m2')
})
})
describe('useWorkflowNodeHumanInputFormFilled', () => {

View File

@@ -77,15 +77,15 @@ describe('useWorkflowNodeStarted', () => {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ node_id: 'n1', status: NodeRunningStatus.Succeeded },
{ id: 'trace-0', node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
],
}),
},
})
result.current.handleWorkflowNodeStarted(
{ data: { node_id: 'n1' } } as NodeStartedResponse,
{ data: { id: 'trace-1', node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
@@ -93,6 +93,30 @@ describe('useWorkflowNodeStarted', () => {
expect(tracing).toHaveLength(2)
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
})
it('should append a new tracing entry when the same node starts a new execution id', () => {
const { result, store } = renderWorkflowHook(() => useWorkflowNodeStarted(), {
initialStoreState: {
workflowRunningData: baseRunningData({
tracing: [
{ id: 'trace-0', node_id: 'n0', status: NodeRunningStatus.Succeeded },
{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
],
}),
},
})
result.current.handleWorkflowNodeStarted(
{ data: { id: 'trace-2', node_id: 'n1' } } as NodeStartedResponse,
containerParams,
)
const tracing = store.getState().workflowRunningData!.tracing!
expect(tracing).toHaveLength(3)
expect(tracing[2].id).toBe('trace-2')
expect(tracing[2].node_id).toBe('n1')
expect(tracing[2].status).toBe(NodeRunningStatus.Running)
})
})
describe('useWorkflowNodeIterationStarted', () => {

View File

@@ -14,7 +14,12 @@ export const useWorkflowAgentLog = () => {
} = workflowStore.getState()
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
const currentIndex = draft.tracing!.findIndex(item => item.node_id === data.node_id)
const currentIndex = draft.tracing!.findIndex((item) => {
if (data.node_execution_id)
return item.id === data.node_execution_id
return item.node_id === data.node_id
})
if (currentIndex > -1) {
const current = draft.tracing![currentIndex]

View File

@@ -33,8 +33,8 @@ export const useWorkflowNodeStarted = () => {
transform,
} = store.getState()
const nodes = getNodes()
const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.node_id === data.node_id)
if (currentIndex && currentIndex > -1) {
const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.id === data.id)
if (currentIndex !== undefined && currentIndex > -1) {
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
draft.tracing![currentIndex] = {
...data,

View File

@@ -42,6 +42,7 @@ import {
import { useHooksStore } from '../../hooks-store'
import { useWorkflowStore } from '../../store'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { upsertTopLevelTracingNodeOnStart } from '../../utils/top-level-tracing'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
@@ -486,19 +487,13 @@ export const useChat = (
}
},
onNodeStarted: ({ data }) => {
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess!.tracing![currentIndex] = {
...data,
status: NodeRunningStatus.Running,
}
}
else {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
}
if (params.loop_id)
return
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess!.tracing!, {
...data,
status: NodeRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
@@ -517,6 +512,9 @@ export const useChat = (
})
},
onNodeFinished: ({ data }) => {
if (params.loop_id)
return
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
@@ -758,8 +756,7 @@ export const useChat = (
if (!responseItem.workflowProcess?.tracing)
return
const tracing = responseItem.workflowProcess.tracing
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
const iterationIndex = tracing.findIndex(item => item.id === iterationFinishedData.id)!
if (iterationIndex > -1) {
tracing[iterationIndex] = {
...tracing[iterationIndex],
@@ -776,22 +773,10 @@ export const useChat = (
if (!responseItem.workflowProcess.tracing)
responseItem.workflowProcess.tracing = []
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
if (nodeStartedData.iteration_id)
return
responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
}
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
})
},
onNodeFinished: ({ data: nodeFinishedData }) => {
@@ -802,6 +787,9 @@ export const useChat = (
if (nodeFinishedData.iteration_id)
return
if (nodeFinishedData.loop_id)
return
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
if (!item.execution_metadata?.parallel_id)
return item.id === nodeFinishedData.id
@@ -829,8 +817,7 @@ export const useChat = (
if (!responseItem.workflowProcess?.tracing)
return
const tracing = responseItem.workflowProcess.tracing
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
const loopIndex = tracing.findIndex(item => item.id === loopFinishedData.id)!
if (loopIndex > -1) {
tracing[loopIndex] = {
...tracing[loopIndex],

View File

@@ -0,0 +1,133 @@
import type { NodeTracing } from '@/types/workflow'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import { upsertTopLevelTracingNodeOnStart } from './top-level-tracing'
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: 'llm' as NodeTracing['node_type'],
title: 'Node 1',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs: {},
outputs_truncated: false,
status: NodeRunningStatus.Succeeded,
elapsed_time: 0,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 0,
...overrides,
})
describe('upsertTopLevelTracingNodeOnStart', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should append a new top-level node when no matching trace exists', () => {
const tracing: NodeTracing[] = []
const startedNode = createTrace({
id: 'trace-2',
node_id: 'node-2',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
expect(updated).toBe(true)
expect(tracing).toEqual([startedNode])
})
it('should update an existing top-level node when the execution id matches', () => {
const tracing: NodeTracing[] = [
createTrace({
id: 'trace-1',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
}),
]
const startedNode = createTrace({
id: 'trace-1',
node_id: 'node-1',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
expect(updated).toBe(true)
expect(tracing).toEqual([startedNode])
})
it('should append a new top-level node when the same node starts with a new execution id', () => {
const existingTrace = createTrace({
id: 'trace-1',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
})
const tracing: NodeTracing[] = [existingTrace]
const startedNode = createTrace({
id: 'trace-2',
node_id: 'node-1',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
expect(updated).toBe(true)
expect(tracing).toEqual([existingTrace, startedNode])
})
it('should ignore nested iteration node starts even when the node id matches a top-level trace', () => {
const existingTrace = createTrace({
id: 'top-level-trace',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
})
const tracing: NodeTracing[] = [existingTrace]
const nestedIterationTrace = createTrace({
id: 'iteration-trace',
node_id: 'node-1',
iteration_id: 'iteration-1',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, nestedIterationTrace)
expect(updated).toBe(false)
expect(tracing).toEqual([existingTrace])
})
it('should ignore nested loop node starts even when the node id matches a top-level trace', () => {
const existingTrace = createTrace({
id: 'top-level-trace',
node_id: 'node-1',
status: NodeRunningStatus.Succeeded,
})
const tracing: NodeTracing[] = [existingTrace]
const nestedLoopTrace = createTrace({
id: 'loop-trace',
node_id: 'node-1',
loop_id: 'loop-1',
status: NodeRunningStatus.Running,
})
const updated = upsertTopLevelTracingNodeOnStart(tracing, nestedLoopTrace)
expect(updated).toBe(false)
expect(tracing).toEqual([existingTrace])
})
})

View File

@@ -0,0 +1,22 @@
import type { NodeTracing } from '@/types/workflow'
const isNestedTracingNode = (trace: Pick<NodeTracing, 'iteration_id' | 'loop_id'>) => {
return Boolean(trace.iteration_id || trace.loop_id)
}
export const upsertTopLevelTracingNodeOnStart = (
tracing: NodeTracing[],
startedNode: NodeTracing,
) => {
if (isNestedTracingNode(startedNode))
return false
const currentIndex = tracing.findIndex(item => item.id === startedNode.id)
if (currentIndex > -1)
// Started events are the authoritative snapshot for an execution; merging would retain stale client-side fields.
tracing[currentIndex] = startedNode
else
tracing.push(startedNode)
return true
}

View File

@@ -1275,6 +1275,9 @@
},
"regexp/no-unused-capturing-group": {
"count": 1
},
"tailwindcss/enforce-consistent-class-order": {
"count": 21
}
},
"app/components/app/overview/trigger-card.tsx": {
@@ -5934,6 +5937,12 @@
"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

@@ -20,8 +20,6 @@ const config: KnipConfig = {
'@iconify-json/*',
'@storybook/addon-onboarding',
'@voidzero-dev/vite-plus-core',
],
rules: {
files: 'warn',

View File

@@ -50,9 +50,9 @@
"start:vinext": "vinext start",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build",
"test": "vp test",
"test:coverage": "vp test --coverage",
"test:watch": "vp test --watch",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest --watch",
"type-check": "tsc --noEmit",
"type-check:tsgo": "tsgo --noEmit",
"uglify-embed": "node ./bin/uglify-embed"
@@ -213,7 +213,6 @@
"@vitejs/plugin-react": "6.0.0",
"@vitejs/plugin-rsc": "0.5.21",
"@vitest/coverage-v8": "4.1.0",
"@voidzero-dev/vite-plus-core": "0.1.11",
"agentation": "2.3.2",
"autoprefixer": "10.4.27",
"code-inspector-plugin": "1.4.4",
@@ -242,10 +241,9 @@
"typescript": "5.9.3",
"uglify-js": "3.19.3",
"vinext": "https://pkg.pr.new/vinext@18fe3ea",
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.11",
"vite": "8.0.0",
"vite-plugin-inspect": "11.3.3",
"vite-plus": "0.1.11",
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.11",
"vitest": "4.1.0",
"vitest-canvas-mock": "1.1.3"
},
"pnpm": {
@@ -295,8 +293,6 @@
"svgo@>=3.0.0,<3.3.3": "3.3.3",
"tar@<=7.5.10": "7.5.11",
"typed-array-buffer": "npm:@nolyfill/typed-array-buffer@^1.0.44",
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.11",
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.11",
"which-typed-array": "npm:@nolyfill/which-typed-array@^1.0.44"
},
"ignoredBuiltDependencies": [

1293
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,11 @@
/// <reference types="vitest/config" />
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import react from '@vitejs/plugin-react'
import vinext from 'vinext'
import { defineConfig } from 'vite'
import Inspect from 'vite-plugin-inspect'
import { defineConfig } from 'vite-plus'
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
import { collectComponentCoverageExcludedFiles } from './scripts/component-coverage-filters.mjs'