mirror of
https://github.com/langgenius/dify.git
synced 2026-03-14 03:37:02 +00:00
Compare commits
7 Commits
main
...
yanli/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344f6be7cd | ||
|
|
f169cf8654 | ||
|
|
e76fbcb045 | ||
|
|
e6f00a2bf9 | ||
|
|
715f3affe5 | ||
|
|
4f73766a21 | ||
|
|
fe90453eed |
34
.github/actions/setup-web/action.yml
vendored
34
.github/actions/setup-web/action.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@@ -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
|
||||
|
||||
8
.github/workflows/style.yml
vendored
8
.github/workflows/style.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@@ -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
|
||||
|
||||
6
.github/workflows/web-tests.yml
vendored
6
.github/workflows/web-tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
133
web/app/components/workflow/utils/top-level-tracing.spec.ts
Normal file
133
web/app/components/workflow/utils/top-level-tracing.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
22
web/app/components/workflow/utils/top-level-tracing.ts
Normal file
22
web/app/components/workflow/utils/top-level-tracing.ts
Normal 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
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -20,8 +20,6 @@ const config: KnipConfig = {
|
||||
'@iconify-json/*',
|
||||
|
||||
'@storybook/addon-onboarding',
|
||||
|
||||
'@voidzero-dev/vite-plus-core',
|
||||
],
|
||||
rules: {
|
||||
files: 'warn',
|
||||
|
||||
@@ -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
1293
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user