Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
756ce8a10e | ||
|
|
e07de297bf |
@@ -3,6 +3,7 @@ CLI command modules extracted from `commands.py`.
|
||||
"""
|
||||
|
||||
from .account import create_tenant, reset_email, reset_password
|
||||
from .app_maintenance import convert_to_agent_apps, fix_app_site_missing
|
||||
from .data_migrate import data_migrate, legacy_model_types
|
||||
from .data_migration import (
|
||||
export_migration_data,
|
||||
@@ -10,6 +11,7 @@ from .data_migration import (
|
||||
import_migration_data,
|
||||
migration_data_wizard,
|
||||
)
|
||||
from .database import upgrade_db
|
||||
from .plugin import (
|
||||
backfill_plugin_auto_upgrade,
|
||||
extract_plugins,
|
||||
@@ -36,12 +38,6 @@ from .retention import (
|
||||
restore_workflow_runs,
|
||||
)
|
||||
from .storage import clear_orphaned_file_records, file_usage, migrate_oss, remove_orphaned_files_on_storage
|
||||
from .system import (
|
||||
convert_to_agent_apps,
|
||||
fix_app_site_missing,
|
||||
reset_encrypt_key_pair,
|
||||
upgrade_db,
|
||||
)
|
||||
from .vector import (
|
||||
add_qdrant_index,
|
||||
migrate_annotation_vector_database,
|
||||
@@ -49,6 +45,8 @@ from .vector import (
|
||||
old_metadata_migration,
|
||||
vdb_migrate,
|
||||
)
|
||||
from .workflow_migration import migrate_legacy_sys_files_workflows
|
||||
from .workspace import reset_encrypt_key_pair
|
||||
|
||||
__all__ = [
|
||||
"add_qdrant_index",
|
||||
@@ -80,6 +78,7 @@ __all__ = [
|
||||
"migrate_data_for_plugin",
|
||||
"migrate_dataset_permissions_to_rbac",
|
||||
"migrate_knowledge_vector_database",
|
||||
"migrate_legacy_sys_files_workflows",
|
||||
"migrate_member_roles_to_rbac",
|
||||
"migrate_oss",
|
||||
"migration_data_wizard",
|
||||
|
||||
@@ -1,86 +1,27 @@
|
||||
"""App data maintenance CLI commands."""
|
||||
|
||||
import logging
|
||||
|
||||
import click
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from configs import dify_config
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from events.app_event import app_was_created
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.db_migration_lock import DbMigrationAutoRenewLock
|
||||
from libs.rsa import generate_key_pair
|
||||
from models import Tenant
|
||||
from models.model import App, AppMode, Conversation
|
||||
from models.provider import Provider, ProviderModel
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_UPGRADE_LOCK_TTL_SECONDS = 60
|
||||
|
||||
|
||||
@click.command(
|
||||
"reset-encrypt-key-pair",
|
||||
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
|
||||
"After the reset, all LLM credentials and tool provider credentials "
|
||||
"(builtin / API / MCP) will be purged, requiring re-entry. "
|
||||
"Only support SELF_HOSTED mode.",
|
||||
)
|
||||
@click.confirmation_option(
|
||||
prompt=click.style(
|
||||
"Are you sure you want to reset encrypt key pair? "
|
||||
"This will also purge builtin / API / MCP tool provider records for every tenant. "
|
||||
"This operation cannot be rolled back!",
|
||||
fg="red",
|
||||
)
|
||||
)
|
||||
def reset_encrypt_key_pair():
|
||||
"""
|
||||
Reset the encrypted key pair of workspace for encrypt LLM credentials.
|
||||
After the reset, all LLM credentials will become invalid, requiring re-entry.
|
||||
Only support SELF_HOSTED mode.
|
||||
"""
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
click.echo(click.style("This command is only for SELF_HOSTED installations.", fg="red"))
|
||||
return
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
tenants = session.scalars(select(Tenant)).all()
|
||||
for tenant in tenants:
|
||||
if not tenant:
|
||||
click.echo(click.style("No workspaces found. Run /install first.", fg="red"))
|
||||
return
|
||||
|
||||
tenant.encrypt_public_key = generate_key_pair(tenant.id)
|
||||
|
||||
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
|
||||
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
|
||||
|
||||
# Purge tool provider records that hold credentials encrypted under the
|
||||
# tenant key. Leaving them in place causes /console/api/workspaces/current/
|
||||
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
|
||||
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
|
||||
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
|
||||
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
|
||||
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command("convert-to-agent-apps", help="Convert Agent Assistant to Agent App.")
|
||||
def convert_to_agent_apps():
|
||||
def convert_to_agent_apps() -> None:
|
||||
"""
|
||||
Convert Agent Assistant to Agent App.
|
||||
"""
|
||||
click.echo(click.style("Starting convert to agent apps.", fg="green"))
|
||||
|
||||
proceeded_app_ids = []
|
||||
proceeded_app_ids: list[str] = []
|
||||
|
||||
while True:
|
||||
# fetch first 1000 apps
|
||||
@@ -133,48 +74,14 @@ def convert_to_agent_apps():
|
||||
click.echo(click.style(f"Conversion complete. Converted {len(proceeded_app_ids)} agent apps.", fg="green"))
|
||||
|
||||
|
||||
@click.command("upgrade-db", help="Upgrade the database")
|
||||
def upgrade_db():
|
||||
click.echo("Preparing database migration...")
|
||||
lock = DbMigrationAutoRenewLock(
|
||||
redis_client=redis_client,
|
||||
name="db_upgrade_lock",
|
||||
ttl_seconds=DB_UPGRADE_LOCK_TTL_SECONDS,
|
||||
logger=logger,
|
||||
log_context="db_migration",
|
||||
)
|
||||
if lock.acquire(blocking=False):
|
||||
migration_succeeded = False
|
||||
try:
|
||||
click.echo(click.style("Starting database migration.", fg="green"))
|
||||
|
||||
# run db migration
|
||||
import flask_migrate
|
||||
|
||||
flask_migrate.upgrade()
|
||||
|
||||
migration_succeeded = True
|
||||
click.echo(click.style("Database migration successful!", fg="green"))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to execute database migration")
|
||||
click.echo(click.style(f"Database migration failed: {e}", fg="red"))
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
status = "successful" if migration_succeeded else "failed"
|
||||
lock.release_safely(status=status)
|
||||
else:
|
||||
click.echo("Database migration skipped")
|
||||
|
||||
|
||||
@click.command("fix-app-site-missing", help="Fix app related site missing issue.")
|
||||
def fix_app_site_missing():
|
||||
def fix_app_site_missing() -> None:
|
||||
"""
|
||||
Fix app related site missing issue.
|
||||
"""
|
||||
click.echo(click.style("Starting fix for missing app-related sites.", fg="green"))
|
||||
|
||||
failed_app_ids = []
|
||||
failed_app_ids: list[str] = []
|
||||
while True:
|
||||
sql = """select apps.id as id from apps left join sites on sites.app_id=apps.id
|
||||
where sites.id is null limit 1000"""
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Database schema migration CLI commands."""
|
||||
|
||||
import logging
|
||||
|
||||
import click
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.db_migration_lock import DbMigrationAutoRenewLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_UPGRADE_LOCK_TTL_SECONDS = 60
|
||||
|
||||
|
||||
@click.command("upgrade-db", help="Upgrade the database")
|
||||
def upgrade_db() -> None:
|
||||
click.echo("Preparing database migration...")
|
||||
lock = DbMigrationAutoRenewLock(
|
||||
redis_client=redis_client,
|
||||
name="db_upgrade_lock",
|
||||
ttl_seconds=DB_UPGRADE_LOCK_TTL_SECONDS,
|
||||
logger=logger,
|
||||
log_context="db_migration",
|
||||
)
|
||||
if lock.acquire(blocking=False):
|
||||
migration_succeeded = False
|
||||
try:
|
||||
click.echo(click.style("Starting database migration.", fg="green"))
|
||||
|
||||
import flask_migrate
|
||||
|
||||
flask_migrate.upgrade()
|
||||
|
||||
migration_succeeded = True
|
||||
click.echo(click.style("Database migration successful!", fg="green"))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to execute database migration")
|
||||
click.echo(click.style(f"Database migration failed: {e}", fg="red"))
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
status = "successful" if migration_succeeded else "failed"
|
||||
lock.release_safely(status=status)
|
||||
else:
|
||||
click.echo("Database migration skipped")
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Workflow data migration CLI commands.
|
||||
|
||||
TODO: Remove the legacy system file workflow migration command after the production migration is complete.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import click
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, load_only, sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegacySysFilesWorkflowMigrationStats:
|
||||
scanned: int = 0
|
||||
migrated: int = 0
|
||||
failed: int = 0
|
||||
batches: int = 0
|
||||
last_id: str | None = None
|
||||
|
||||
|
||||
def _build_legacy_sys_files_workflow_query(
|
||||
*,
|
||||
start_after_id: str | None,
|
||||
batch_size: int,
|
||||
tenant_id: str | None,
|
||||
app_id: str | None,
|
||||
):
|
||||
# Workflow IDs are UUID4, so this is not chronological pagination. The migration only needs a stable total
|
||||
# order that matches the resume cursor; ordering by the same primary-key column used in the `id > cursor`
|
||||
# predicate lets each batch continue deterministically without offset scans.
|
||||
stmt = (
|
||||
select(Workflow)
|
||||
.options(load_only(Workflow.id, Workflow.type, Workflow.graph))
|
||||
.where(Workflow.type.in_((WorkflowType.WORKFLOW, WorkflowType.CHAT)))
|
||||
.order_by(Workflow.id)
|
||||
.limit(batch_size)
|
||||
)
|
||||
if start_after_id:
|
||||
stmt = stmt.where(Workflow.id > start_after_id)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(Workflow.tenant_id == tenant_id)
|
||||
if app_id:
|
||||
stmt = stmt.where(Workflow.app_id == app_id)
|
||||
return stmt
|
||||
|
||||
|
||||
def _migrate_legacy_sys_files_workflow_batch(
|
||||
*,
|
||||
session: Session,
|
||||
start_after_id: str | None,
|
||||
batch_size: int,
|
||||
tenant_id: str | None,
|
||||
app_id: str | None,
|
||||
dry_run: bool,
|
||||
) -> LegacySysFilesWorkflowMigrationStats:
|
||||
stats = LegacySysFilesWorkflowMigrationStats()
|
||||
workflows = session.scalars(
|
||||
_build_legacy_sys_files_workflow_query(
|
||||
start_after_id=start_after_id,
|
||||
batch_size=batch_size,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
)
|
||||
).all()
|
||||
|
||||
for workflow in workflows:
|
||||
stats.scanned += 1
|
||||
stats.last_id = workflow.id
|
||||
try:
|
||||
if workflow.migrate_legacy_sys_files_graph_in_place():
|
||||
stats.migrated += 1
|
||||
except Exception:
|
||||
stats.failed += 1
|
||||
logger.exception("Failed to migrate legacy sys.files workflow, workflow_id=%s", workflow.id)
|
||||
|
||||
if dry_run:
|
||||
session.rollback()
|
||||
else:
|
||||
session.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def run_legacy_sys_files_workflow_migration(
|
||||
*,
|
||||
batch_size: int,
|
||||
limit: int | None,
|
||||
start_after_id: str | None,
|
||||
tenant_id: str | None,
|
||||
app_id: str | None,
|
||||
dry_run: bool,
|
||||
) -> LegacySysFilesWorkflowMigrationStats:
|
||||
"""Scan Workflow and Advanced Chat graphs in keyset-paginated batches."""
|
||||
if batch_size <= 0:
|
||||
raise click.UsageError("--batch-size must be greater than 0")
|
||||
if limit is not None and limit <= 0:
|
||||
raise click.UsageError("--limit must be greater than 0 when provided")
|
||||
|
||||
session_maker = sessionmaker(db.engine, expire_on_commit=False)
|
||||
total = LegacySysFilesWorkflowMigrationStats(last_id=start_after_id)
|
||||
next_start_after_id = start_after_id
|
||||
|
||||
while limit is None or total.scanned < limit:
|
||||
remaining = None if limit is None else limit - total.scanned
|
||||
current_batch_size = batch_size if remaining is None else min(batch_size, remaining)
|
||||
if current_batch_size <= 0:
|
||||
break
|
||||
|
||||
with session_maker() as session:
|
||||
batch_stats = _migrate_legacy_sys_files_workflow_batch(
|
||||
session=session,
|
||||
start_after_id=next_start_after_id,
|
||||
batch_size=current_batch_size,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
if batch_stats.scanned == 0:
|
||||
break
|
||||
|
||||
total.scanned += batch_stats.scanned
|
||||
total.migrated += batch_stats.migrated
|
||||
total.failed += batch_stats.failed
|
||||
total.batches += 1
|
||||
total.last_id = batch_stats.last_id
|
||||
next_start_after_id = batch_stats.last_id
|
||||
|
||||
if batch_stats.scanned < current_batch_size:
|
||||
break
|
||||
|
||||
return total
|
||||
|
||||
|
||||
@click.command(
|
||||
"migrate-legacy-sys-files-workflows",
|
||||
help="Migrate Workflow and Advanced Chat graphs that still reference deprecated sys.files.",
|
||||
)
|
||||
@click.option("--batch-size", default=1000, show_default=True, type=int, help="Number of workflows to scan per batch.")
|
||||
@click.option("--limit", default=None, type=int, help="Maximum number of workflows to scan in this run.")
|
||||
@click.option("--start-after-id", default=None, help="Resume scanning after this workflow ID.")
|
||||
@click.option("--tenant-id", default=None, help="Limit migration to one tenant.")
|
||||
@click.option("--app-id", default=None, help="Limit migration to one app.")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Scan and report without saving changes.")
|
||||
def migrate_legacy_sys_files_workflows(
|
||||
batch_size: int,
|
||||
limit: int | None,
|
||||
start_after_id: str | None,
|
||||
tenant_id: str | None,
|
||||
app_id: str | None,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
stats = run_legacy_sys_files_workflow_migration(
|
||||
batch_size=batch_size,
|
||||
limit=limit,
|
||||
start_after_id=start_after_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
click.echo(
|
||||
"Legacy sys.files workflow migration finished: "
|
||||
f"scanned={stats.scanned} migrated={stats.migrated} failed={stats.failed} "
|
||||
f"batches={stats.batches} last_id={stats.last_id or ''}"
|
||||
)
|
||||
if dry_run:
|
||||
click.echo("Dry run only: no workflow graph changes were saved.")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Workspace maintenance CLI commands."""
|
||||
|
||||
import click
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from enums.deployment_edition import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from libs.rsa import generate_key_pair
|
||||
from models import Tenant
|
||||
from models.provider import Provider, ProviderModel
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
|
||||
|
||||
|
||||
@click.command(
|
||||
"reset-encrypt-key-pair",
|
||||
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
|
||||
"After the reset, all LLM credentials and tool provider credentials "
|
||||
"(builtin / API / MCP) will be purged, requiring re-entry. "
|
||||
"Only support SELF_HOSTED mode.",
|
||||
)
|
||||
@click.confirmation_option(
|
||||
prompt=click.style(
|
||||
"Are you sure you want to reset encrypt key pair? "
|
||||
"This will also purge builtin / API / MCP tool provider records for every tenant. "
|
||||
"This operation cannot be rolled back!",
|
||||
fg="red",
|
||||
)
|
||||
)
|
||||
def reset_encrypt_key_pair() -> None:
|
||||
"""
|
||||
Reset the encrypted key pair of workspace for encrypt LLM credentials.
|
||||
After the reset, all LLM credentials will become invalid, requiring re-entry.
|
||||
Only support SELF_HOSTED mode.
|
||||
"""
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
click.echo(click.style("This command is only for SELF_HOSTED installations.", fg="red"))
|
||||
return
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
tenants = session.scalars(select(Tenant)).all()
|
||||
for tenant in tenants:
|
||||
if not tenant:
|
||||
click.echo(click.style("No workspaces found. Run /install first.", fg="red"))
|
||||
return
|
||||
|
||||
tenant.encrypt_public_key = generate_key_pair(tenant.id)
|
||||
|
||||
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
|
||||
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
|
||||
|
||||
# Purge tool provider records that hold credentials encrypted under the
|
||||
# tenant key. Leaving them in place causes /console/api/workspaces/current/
|
||||
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
|
||||
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
|
||||
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
|
||||
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
|
||||
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
@@ -26,6 +26,10 @@ from controllers.service_api.app.error import (
|
||||
ProviderQuotaExceededError,
|
||||
WorkflowVersionExecutionNotAllowedError,
|
||||
)
|
||||
from controllers.service_api.app.legacy_system_files import (
|
||||
attach_legacy_system_file_warning_for_service_api,
|
||||
normalize_legacy_system_file_args_for_service_api,
|
||||
)
|
||||
from controllers.service_api.schema import (
|
||||
InputFileList,
|
||||
expect_user_json,
|
||||
@@ -390,6 +394,7 @@ class ChatApi(Resource):
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
streaming = _resolve_agent_app_streaming(app_mode=app_mode, response_mode=payload.response_mode)
|
||||
legacy_system_file_compat = None
|
||||
|
||||
try:
|
||||
# Eagerly validate conversation to avoid hanging on invalid conversation_id
|
||||
@@ -401,6 +406,14 @@ class ChatApi(Resource):
|
||||
session=session,
|
||||
)
|
||||
|
||||
if app_mode == AppMode.ADVANCED_CHAT:
|
||||
args, legacy_system_file_compat = normalize_legacy_system_file_args_for_service_api(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
args=args,
|
||||
raw_payload=service_api_ns.payload,
|
||||
workflow_id=args.get("workflow_id"),
|
||||
)
|
||||
response = AppGenerateService.generate(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
@@ -409,6 +422,7 @@ class ChatApi(Resource):
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=streaming,
|
||||
)
|
||||
response = attach_legacy_system_file_warning_for_service_api(response, legacy_system_file_compat)
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Temporary Service API adapter for the deprecated workflow file input."""
|
||||
|
||||
from collections.abc import Generator, Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
|
||||
from core.workflow.legacy_system_files import (
|
||||
LegacySysFilesCompatVariable,
|
||||
attach_legacy_sys_files_warning,
|
||||
normalize_legacy_sys_files_args,
|
||||
)
|
||||
from models.model import App
|
||||
from services.app_generate_service import AppGenerateService
|
||||
|
||||
type ServiceAPIGenerateResponse = Mapping[str, Any] | Generator[str, None, None] | RateLimitGenerator
|
||||
|
||||
|
||||
def normalize_legacy_system_file_args_for_service_api(
|
||||
*,
|
||||
session: Session,
|
||||
app_model: App,
|
||||
args: dict[str, Any],
|
||||
raw_payload: Mapping[str, Any] | None,
|
||||
workflow_id: str | None = None,
|
||||
) -> tuple[dict[str, Any], LegacySysFilesCompatVariable | None]:
|
||||
# TODO: Remove this hidden Service API compatibility path after all persisted workflows are migrated.
|
||||
args_with_hidden_system = _copy_hidden_system_files_arg(args=args, raw_payload=raw_payload)
|
||||
if not _has_legacy_file_arg(args_with_hidden_system):
|
||||
return args, None
|
||||
|
||||
workflow = AppGenerateService.get_workflow(
|
||||
app_model,
|
||||
InvokeFrom.SERVICE_API,
|
||||
workflow_id,
|
||||
session=session,
|
||||
)
|
||||
return normalize_legacy_sys_files_args(graph=workflow.graph_dict, args=args_with_hidden_system)
|
||||
|
||||
|
||||
def attach_legacy_system_file_warning_for_service_api(
|
||||
response: ServiceAPIGenerateResponse,
|
||||
compat_variable: LegacySysFilesCompatVariable | None,
|
||||
) -> ServiceAPIGenerateResponse:
|
||||
# TODO: Remove this warning once Service API clients no longer need the legacy migration notice.
|
||||
if compat_variable is None:
|
||||
return response
|
||||
return attach_legacy_sys_files_warning(response, compat_variable)
|
||||
|
||||
|
||||
def _copy_hidden_system_files_arg(
|
||||
*,
|
||||
args: dict[str, Any],
|
||||
raw_payload: Mapping[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
system = raw_payload.get("system") if isinstance(raw_payload, Mapping) else None
|
||||
if not isinstance(system, Mapping) or "files" not in system or system["files"] is None:
|
||||
return args
|
||||
|
||||
copied_args = dict(args)
|
||||
copied_args["system"] = {"files": system["files"]}
|
||||
return copied_args
|
||||
|
||||
|
||||
def _has_legacy_file_arg(args: Mapping[str, Any]) -> bool:
|
||||
if args.get("files") is not None:
|
||||
return True
|
||||
|
||||
system = args.get("system")
|
||||
return isinstance(system, Mapping) and system.get("files") is not None
|
||||
@@ -30,6 +30,10 @@ from controllers.service_api.app.error import (
|
||||
ProviderQuotaExceededError,
|
||||
WorkflowVersionExecutionNotAllowedError,
|
||||
)
|
||||
from controllers.service_api.app.legacy_system_files import (
|
||||
attach_legacy_system_file_warning_for_service_api,
|
||||
normalize_legacy_system_file_args_for_service_api,
|
||||
)
|
||||
from controllers.service_api.schema import (
|
||||
expect_user_json,
|
||||
expect_with_user,
|
||||
@@ -344,6 +348,12 @@ class WorkflowRunApi(Resource):
|
||||
streaming = payload.response_mode == "streaming"
|
||||
|
||||
try:
|
||||
args, legacy_system_file_compat = normalize_legacy_system_file_args_for_service_api(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
args=args,
|
||||
raw_payload=service_api_ns.payload,
|
||||
)
|
||||
response = AppGenerateService.generate(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
@@ -352,6 +362,7 @@ class WorkflowRunApi(Resource):
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=streaming,
|
||||
)
|
||||
response = attach_legacy_system_file_warning_for_service_api(response, legacy_system_file_compat)
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
@@ -471,6 +482,13 @@ class WorkflowRunByIdApi(Resource):
|
||||
streaming = payload.response_mode == "streaming"
|
||||
|
||||
try:
|
||||
args, legacy_system_file_compat = normalize_legacy_system_file_args_for_service_api(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
args=args,
|
||||
raw_payload=service_api_ns.payload,
|
||||
workflow_id=workflow_id,
|
||||
)
|
||||
response = AppGenerateService.generate(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
@@ -479,6 +497,7 @@ class WorkflowRunByIdApi(Resource):
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=streaming,
|
||||
)
|
||||
response = attach_legacy_system_file_warning_for_service_api(response, legacy_system_file_compat)
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
|
||||
@@ -46,6 +46,7 @@ from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.prompt.utils.get_thread_messages_length import get_thread_messages_length
|
||||
from core.repositories import DifyCoreRepositoryFactory
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
from core.workflow.legacy_system_files import normalize_legacy_sys_files_args
|
||||
from extensions.ext_database import db
|
||||
from factories import file_factory
|
||||
from graphon.filters import ResponseStreamFilter
|
||||
@@ -147,6 +148,8 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
if not args.get("query"):
|
||||
raise ValueError("query is required")
|
||||
|
||||
# TODO: Remove this compatibility normalization after all persisted workflows are migrated.
|
||||
args, _ = normalize_legacy_sys_files_args(graph=workflow.graph_dict, args=args)
|
||||
query = args["query"]
|
||||
if not isinstance(query, str):
|
||||
raise ValueError("query must be a string")
|
||||
|
||||
@@ -155,8 +155,10 @@ class WorkflowResponseConverter:
|
||||
# TODO(@future-refactor): store system variables separately from user inputs so we don't
|
||||
# need to flatten `sys.*` entries into the input payload just for rerun/export tooling.
|
||||
if field_name == SystemVariableKey.CONVERSATION_ID:
|
||||
# Conversation IDs are session-scoped; omitting them keeps workflow inputs
|
||||
# reusable without pinning new runs to a prior conversation.
|
||||
# Conversation IDs are session-scoped; omitting them keeps workflow inputs reusable.
|
||||
continue
|
||||
if field_name == SystemVariableKey.FILES:
|
||||
# When files are exposed as an input, application inputs use the canonical `userinput.files` key.
|
||||
continue
|
||||
inputs[f"sys.{field_name}"] = value
|
||||
handled = WorkflowEntry.handle_special_values(inputs)
|
||||
|
||||
@@ -41,6 +41,7 @@ from core.helper.trace_id_helper import (
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.repositories import DifyCoreRepositoryFactory
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
from core.workflow.legacy_system_files import normalize_legacy_sys_files_args
|
||||
from extensions.ext_database import db
|
||||
from factories import file_factory
|
||||
from graphon.filters import ResponseStreamFilter
|
||||
@@ -164,6 +165,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
pause_state_config: PauseStateLayerConfig | None = None,
|
||||
) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None]:
|
||||
with self._bind_file_access_scope(tenant_id=app_model.tenant_id, user=user, invoke_from=invoke_from):
|
||||
# TODO: Remove this compatibility normalization after all persisted workflows are migrated.
|
||||
args, _ = normalize_legacy_sys_files_args(graph=workflow.graph_dict, args=args)
|
||||
files: Sequence[Mapping[str, Any]] = args.get("files") or []
|
||||
|
||||
# parse files
|
||||
|
||||
@@ -42,9 +42,9 @@ _NODE_SNIPPETS: dict[str, str] = {
|
||||
["local_file", "remote_url"]. Only when you include "custom" must you
|
||||
also set ``allowed_file_extensions`` to a non-empty list like
|
||||
[".epub", ".rtf"]; otherwise leave it [].
|
||||
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
|
||||
system variables — downstream nodes may reference them; do NOT add
|
||||
them to ``variables``.""",
|
||||
In Advanced-Chat mode ``sys.query`` is automatic. ``userinput.files`` is
|
||||
the automatic file-upload variable in both app modes. Downstream nodes
|
||||
may reference these variables; do NOT add them to ``variables``.""",
|
||||
"end": """\
|
||||
- end (Workflow mode only):
|
||||
{"outputs": [
|
||||
@@ -168,8 +168,8 @@ _NODE_SNIPPETS: dict[str, str] = {
|
||||
Single output variable ``text``: a string when ``is_array_file`` is false,
|
||||
an array of strings (one per file) when it is true. ``variable_selector``
|
||||
MUST point at a ``start`` variable declared with type "file" / "file-list"
|
||||
(or ``sys.files`` in Advanced-Chat mode). That start variable MUST set a
|
||||
non-empty ``allowed_file_types`` (use ["document"] for document text).""",
|
||||
(or the automatic ``userinput.files`` variable). A declared start variable
|
||||
MUST set a non-empty ``allowed_file_types`` (use ["document"] for document text).""",
|
||||
"variable-aggregator": """\
|
||||
- variable-aggregator (merge mutually-exclusive branches into one output):
|
||||
{"output_type": "string", # VarType of the merged value — one of
|
||||
|
||||
@@ -91,21 +91,20 @@ def format_parallel_plan(
|
||||
def format_mode_section(mode: str) -> str:
|
||||
"""Tell each builder which app mode it is configuring for.
|
||||
|
||||
Matters most in advanced-chat, where ``sys.query`` / ``sys.files`` are the
|
||||
sanctioned way to reference the user's message — without this the model
|
||||
invents start-node variables that postprocess then materializes as
|
||||
spurious form inputs.
|
||||
``sys.query`` is available in advanced-chat, while ``userinput.files`` is
|
||||
available in both app modes. Without this guidance the model invents
|
||||
start-node variables that postprocess then materializes as spurious inputs.
|
||||
"""
|
||||
if mode == "advanced-chat":
|
||||
return (
|
||||
"# App mode\n\n"
|
||||
"advanced-chat: the user's chat message is available as sys.query and uploaded files "
|
||||
'as sys.files — placeholder {{#sys.query#}}, selector ["sys", "query"]. Reference them '
|
||||
"directly; do NOT invent start-node variables for the chat message.\n\n"
|
||||
"as userinput.files. Use placeholder {{#sys.query#}} or selector "
|
||||
'["userinput", "files"] directly; do NOT invent start-node variables for them.\n\n'
|
||||
)
|
||||
return (
|
||||
"# App mode\n\n"
|
||||
"workflow: there are NO automatic system variables; reference user input only through "
|
||||
"workflow: uploaded files are available as userinput.files; all other user input must use "
|
||||
"the start node's declared variables.\n\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -96,10 +96,10 @@ minimum set of Dify workflow nodes needed to fulfil it, in execution order.
|
||||
- "text-input" for short single-line values (URLs, names),
|
||||
- "paragraph" for free-form multi-line text (descriptions, queries),
|
||||
- "number" / "select" / "file" / "file-list" for the obvious cases.
|
||||
In Advanced-Chat mode the ``sys.query`` / ``sys.files`` system
|
||||
variables are automatic — downstream nodes may reference them without
|
||||
a ``start_inputs`` entry. In Workflow mode there is NO automatic
|
||||
variable; everything the user supplies must be in ``start_inputs``.
|
||||
In Advanced-Chat mode ``sys.query`` is automatic. ``userinput.files`` is
|
||||
automatic in both app modes. Downstream nodes may reference these values
|
||||
without a ``start_inputs`` entry; every other user-supplied Workflow value
|
||||
must be declared in ``start_inputs``.
|
||||
11. Give every node a unique runtime-safe ``id`` using only letters, digits,
|
||||
and underscores. In create mode use ``node1``, ``node2``, ... in node-list
|
||||
order. In refine mode preserve the existing id for every retained node.
|
||||
|
||||
@@ -1382,8 +1382,8 @@ class WorkflowGenerator:
|
||||
multiple outputs remain untouched so validation fails closed instead
|
||||
of guessing which value the workflow should consume.
|
||||
|
||||
For Advanced-Chat mode, ``sys.query`` and ``sys.files`` are always
|
||||
treated as resolved without any declaration. Tool nodes' parameter
|
||||
``sys.query`` in Advanced-Chat mode and ``userinput.files`` in either mode
|
||||
are treated as resolved without declarations. Tool nodes' parameter
|
||||
references aren't validated here because we don't know each tool's
|
||||
schema — the run time validates those.
|
||||
"""
|
||||
@@ -1398,9 +1398,12 @@ class WorkflowGenerator:
|
||||
for node in nodes:
|
||||
cls._collect_refs_in_data(node.get("data") or {}, refs)
|
||||
|
||||
automatic_refs = {("userinput", "files")}
|
||||
if mode == "advanced-chat":
|
||||
automatic_refs.add(("sys", "query"))
|
||||
|
||||
for node_id, var in refs:
|
||||
# Advanced-Chat system variables are always resolved.
|
||||
if mode == "advanced-chat" and node_id == "sys":
|
||||
if (node_id, var) in automatic_refs:
|
||||
continue
|
||||
target = nodes_by_id.get(node_id)
|
||||
if target is None:
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Compatibility helpers for workflows that still reference deprecated `sys.files`.
|
||||
|
||||
TODO: Remove this module after all persisted Workflow and Advanced Chat graphs
|
||||
have been migrated from the deprecated system file variable to `userinput.files`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Generator, Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
_LEGACY_SYSTEM_NODE_ID = "sys"
|
||||
_USER_INPUT_NODE_ID = "userinput"
|
||||
_LEGACY_FILES_VARIABLE = "files"
|
||||
_USER_INPUT_FILE_SELECTOR = [_USER_INPUT_NODE_ID, _LEGACY_FILES_VARIABLE]
|
||||
_USER_INPUT_FILE_INPUT_KEY = ".".join(_USER_INPUT_FILE_SELECTOR)
|
||||
_LEGACY_FILES_TEMPLATE = "{{#sys.files#}}"
|
||||
_USER_INPUT_FILES_TEMPLATE = "{{#userinput.files#}}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacySysFilesCompatVariable:
|
||||
node_id: str
|
||||
variable_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LegacySysFilesGraphMigrationResult:
|
||||
graph: dict[str, Any]
|
||||
changed: bool
|
||||
|
||||
|
||||
def migrate_legacy_sys_files_graph_with_result(
|
||||
graph: Mapping[str, Any],
|
||||
) -> LegacySysFilesGraphMigrationResult:
|
||||
"""Return the migrated graph and whether any legacy reference was rewritten."""
|
||||
|
||||
graph_copy = dict(graph)
|
||||
nodes = graph_copy.get("nodes")
|
||||
if not isinstance(nodes, list):
|
||||
return LegacySysFilesGraphMigrationResult(graph=graph_copy, changed=False)
|
||||
|
||||
# Legacy references are stored in node data. Restricting both search and replacement to `nodes`
|
||||
# avoids recursively scanning graph-level metadata and edges for every workflow load.
|
||||
if not _contains_legacy_sys_files_reference(nodes):
|
||||
return LegacySysFilesGraphMigrationResult(graph=graph_copy, changed=False)
|
||||
|
||||
graph_copy["nodes"] = _replace_legacy_sys_files_references(nodes)
|
||||
return LegacySysFilesGraphMigrationResult(graph=graph_copy, changed=True)
|
||||
|
||||
|
||||
def resolve_legacy_sys_files_compat_variable(graph: Mapping[str, Any]) -> LegacySysFilesCompatVariable | None:
|
||||
"""Resolve the target variable used by the `sys.files` compatibility layer."""
|
||||
|
||||
nodes = graph.get("nodes")
|
||||
if not isinstance(nodes, list):
|
||||
return None
|
||||
if not _contains_file_input_reference(nodes):
|
||||
return None
|
||||
return LegacySysFilesCompatVariable(node_id=_USER_INPUT_NODE_ID, variable_name=_LEGACY_FILES_VARIABLE)
|
||||
|
||||
|
||||
def normalize_legacy_sys_files_args(
|
||||
*,
|
||||
graph: Mapping[str, Any],
|
||||
args: Mapping[str, Any],
|
||||
) -> tuple[dict[str, Any], LegacySysFilesCompatVariable | None]:
|
||||
"""Map Service/Web API file arguments onto the `userinput.files` system alias.
|
||||
|
||||
The top-level `files` argument and hidden `system.files` payload both feed
|
||||
the same runtime file collection. After graph references are migrated, the
|
||||
file collection is exposed in the variable pool as `userinput.files`.
|
||||
"""
|
||||
|
||||
normalized_args = dict(args)
|
||||
files_from_input, input_files_used = _extract_userinput_files(args)
|
||||
if input_files_used:
|
||||
normalized_args["files"] = files_from_input
|
||||
return normalized_args, None
|
||||
|
||||
compat_variable = resolve_legacy_sys_files_compat_variable(graph)
|
||||
if compat_variable is None:
|
||||
return normalized_args, None
|
||||
|
||||
files, legacy_files_used = _extract_legacy_files(args)
|
||||
if not legacy_files_used:
|
||||
return normalized_args, None
|
||||
|
||||
if normalized_args.get("files") is None:
|
||||
normalized_args["files"] = files
|
||||
|
||||
raw_inputs = normalized_args.get("inputs")
|
||||
inputs = dict(raw_inputs) if isinstance(raw_inputs, Mapping) else {}
|
||||
inputs.setdefault(_USER_INPUT_FILE_INPUT_KEY, files)
|
||||
normalized_args["inputs"] = inputs
|
||||
return normalized_args, compat_variable
|
||||
|
||||
|
||||
def attach_legacy_sys_files_warning(
|
||||
response: Mapping[str, Any] | Iterable[Any],
|
||||
compat_variable: LegacySysFilesCompatVariable,
|
||||
) -> Mapping[str, Any] | Generator[str, None, None]:
|
||||
warning = build_legacy_sys_files_warning(compat_variable)
|
||||
if isinstance(response, Mapping):
|
||||
response_with_warning = dict(response)
|
||||
existing_warnings = response_with_warning.get("warnings")
|
||||
warnings = list(existing_warnings) if isinstance(existing_warnings, list) else []
|
||||
warnings.append(warning)
|
||||
response_with_warning["warnings"] = warnings
|
||||
return response_with_warning
|
||||
|
||||
def _with_warning() -> Generator[str, None, None]:
|
||||
try:
|
||||
yield f"data: {json.dumps({'event': 'warning', 'warning': warning})}\n\n"
|
||||
yield from response
|
||||
finally:
|
||||
close = getattr(response, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
return _with_warning()
|
||||
|
||||
|
||||
def build_legacy_sys_files_warning(compat_variable: LegacySysFilesCompatVariable) -> str:
|
||||
variable_selector = ".".join((compat_variable.node_id, compat_variable.variable_name))
|
||||
return (
|
||||
"sys.files is deprecated. This workflow now reads files from "
|
||||
f"`{variable_selector}`; update Service API calls to pass files in "
|
||||
f"`inputs.{variable_selector}` instead of `system.files` or top-level `files`."
|
||||
)
|
||||
|
||||
|
||||
def _contains_legacy_sys_files_reference(value: Any) -> bool:
|
||||
if _is_legacy_sys_files_selector(value):
|
||||
return True
|
||||
|
||||
if isinstance(value, str):
|
||||
return _LEGACY_FILES_TEMPLATE in value
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
return any(_contains_legacy_sys_files_reference(item) for item in value.values())
|
||||
|
||||
if isinstance(value, list):
|
||||
return any(_contains_legacy_sys_files_reference(item) for item in value)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _contains_file_input_reference(value: Any) -> bool:
|
||||
if _is_legacy_sys_files_selector(value) or _is_userinput_files_selector(value):
|
||||
return True
|
||||
|
||||
if isinstance(value, str):
|
||||
return _LEGACY_FILES_TEMPLATE in value or _USER_INPUT_FILES_TEMPLATE in value
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
return any(_contains_file_input_reference(item) for item in value.values())
|
||||
|
||||
if isinstance(value, list):
|
||||
return any(_contains_file_input_reference(item) for item in value)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _replace_legacy_sys_files_references(value: Any) -> Any:
|
||||
if _is_legacy_sys_files_selector(value):
|
||||
return list(_USER_INPUT_FILE_SELECTOR)
|
||||
|
||||
if isinstance(value, str):
|
||||
return value.replace(_LEGACY_FILES_TEMPLATE, _USER_INPUT_FILES_TEMPLATE)
|
||||
|
||||
if isinstance(value, Mapping):
|
||||
return {key: _replace_legacy_sys_files_references(item) for key, item in value.items()}
|
||||
|
||||
if isinstance(value, list):
|
||||
return [_replace_legacy_sys_files_references(item) for item in value]
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _is_legacy_sys_files_selector(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, list)
|
||||
and len(value) == 2
|
||||
and value[0] == _LEGACY_SYSTEM_NODE_ID
|
||||
and value[1] == _LEGACY_FILES_VARIABLE
|
||||
)
|
||||
|
||||
|
||||
def _is_userinput_files_selector(value: Any) -> bool:
|
||||
return isinstance(value, list) and value == _USER_INPUT_FILE_SELECTOR
|
||||
|
||||
|
||||
def serialized_graph_may_contain_legacy_sys_files(serialized_graph: str) -> bool:
|
||||
"""Cheaply reject stored graphs that cannot contain a legacy file reference."""
|
||||
|
||||
return _LEGACY_FILES_TEMPLATE in serialized_graph or ('"sys"' in serialized_graph and '"files"' in serialized_graph)
|
||||
|
||||
|
||||
def _extract_legacy_files(args: Mapping[str, Any]) -> tuple[Any, bool]:
|
||||
if "files" in args and args["files"] is not None:
|
||||
return args["files"], True
|
||||
|
||||
system = args.get("system")
|
||||
if isinstance(system, Mapping) and "files" in system and system["files"] is not None:
|
||||
return system["files"], True
|
||||
|
||||
return None, False
|
||||
|
||||
|
||||
def _extract_userinput_files(args: Mapping[str, Any]) -> tuple[Any, bool]:
|
||||
inputs = args.get("inputs")
|
||||
if isinstance(inputs, Mapping) and inputs.get(_USER_INPUT_FILE_INPUT_KEY) is not None:
|
||||
return inputs[_USER_INPUT_FILE_INPUT_KEY], True
|
||||
|
||||
return None, False
|
||||
@@ -368,7 +368,7 @@ class WorkflowAgentRuntimeRequestBuilder:
|
||||
|
||||
if uploaded_files is not None:
|
||||
lines.append("- Uploaded workflow files:")
|
||||
lines.append(f" - sys.files: {uploaded_files}")
|
||||
lines.append(f" - userinput.files: {uploaded_files}")
|
||||
|
||||
if resolved_outputs:
|
||||
lines.append("- Previous node outputs:")
|
||||
|
||||
@@ -16,6 +16,7 @@ from .variable_prefixes import (
|
||||
ENVIRONMENT_VARIABLE_NODE_ID,
|
||||
RAG_PIPELINE_VARIABLE_NODE_ID,
|
||||
SYSTEM_VARIABLE_NODE_ID,
|
||||
USER_INPUT_VARIABLE_NODE_ID,
|
||||
)
|
||||
|
||||
|
||||
@@ -118,6 +119,12 @@ def build_bootstrap_variables(
|
||||
*(_with_selector(variable, ENVIRONMENT_VARIABLE_NODE_ID) for variable in environment_variables),
|
||||
*(_with_selector(variable, CONVERSATION_VARIABLE_NODE_ID) for variable in conversation_variables),
|
||||
]
|
||||
# TODO: Stop emitting the legacy `sys.files` selector after stored graphs and Service API callers are migrated.
|
||||
# `userinput.files` remains the canonical file-upload variable.
|
||||
for variable in system_variables:
|
||||
if variable.name == SystemVariableKey.FILES.value:
|
||||
variables.append(_with_selector(variable, USER_INPUT_VARIABLE_NODE_ID))
|
||||
break
|
||||
|
||||
rag_pipeline_variables_map: defaultdict[str, dict[str, Any]] = defaultdict(dict)
|
||||
for rag_var in rag_pipeline_variables:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
SYSTEM_VARIABLE_NODE_ID = "sys"
|
||||
USER_INPUT_VARIABLE_NODE_ID = "userinput"
|
||||
ENVIRONMENT_VARIABLE_NODE_ID = "env"
|
||||
CONVERSATION_VARIABLE_NODE_ID = "conversation"
|
||||
RAG_PIPELINE_VARIABLE_NODE_ID = "rag"
|
||||
|
||||
@@ -29,6 +29,7 @@ def init_app(app: DifyApp):
|
||||
install_rag_pipeline_plugins,
|
||||
migrate_data_for_plugin,
|
||||
migrate_dataset_permissions_to_rbac,
|
||||
migrate_legacy_sys_files_workflows,
|
||||
migrate_member_roles_to_rbac,
|
||||
migrate_oss,
|
||||
migration_data_wizard,
|
||||
@@ -57,6 +58,7 @@ def init_app(app: DifyApp):
|
||||
data_migrate,
|
||||
upgrade_db,
|
||||
fix_app_site_missing,
|
||||
migrate_legacy_sys_files_workflows,
|
||||
migrate_data_for_plugin,
|
||||
migrate_dataset_permissions_to_rbac,
|
||||
migrate_member_roles_to_rbac,
|
||||
|
||||
+39
-3
@@ -25,6 +25,10 @@ from typing_extensions import deprecated
|
||||
|
||||
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
|
||||
from core.workflow.human_input_adapter import adapt_node_config_for_graph
|
||||
from core.workflow.legacy_system_files import (
|
||||
migrate_legacy_sys_files_graph_with_result,
|
||||
serialized_graph_may_contain_legacy_sys_files,
|
||||
)
|
||||
from core.workflow.nodes.human_input.pause_reason import (
|
||||
HumanInputRequired,
|
||||
)
|
||||
@@ -325,7 +329,39 @@ class Workflow(Base): # bug
|
||||
# Currently, the following functions / methods would mutate the returned dict:
|
||||
#
|
||||
# - `_get_graph_and_variable_pool_for_single_node_run`.
|
||||
return json.loads(self.graph) if self.graph else {}
|
||||
if not self.graph:
|
||||
return {}
|
||||
|
||||
graph = json.loads(self.graph)
|
||||
if not self._supports_legacy_sys_files_compatibility() or not serialized_graph_may_contain_legacy_sys_files(
|
||||
self.graph
|
||||
):
|
||||
return graph
|
||||
|
||||
# TODO: Remove this load-time compatibility rewrite after all persisted workflows are migrated.
|
||||
return migrate_legacy_sys_files_graph_with_result(graph).graph
|
||||
|
||||
def migrate_legacy_sys_files_graph_in_place(self) -> bool:
|
||||
if (
|
||||
not self.graph
|
||||
or not self._supports_legacy_sys_files_compatibility()
|
||||
or not serialized_graph_may_contain_legacy_sys_files(self.graph)
|
||||
):
|
||||
return False
|
||||
|
||||
# TODO: Remove this in-place compatibility rewrite after all persisted workflows are migrated.
|
||||
migration_result = migrate_legacy_sys_files_graph_with_result(json.loads(self.graph))
|
||||
if migration_result.changed:
|
||||
self.graph = json.dumps(migration_result.graph)
|
||||
return migration_result.changed
|
||||
|
||||
def _supports_legacy_sys_files_compatibility(self) -> bool:
|
||||
return self.type in {
|
||||
WorkflowType.WORKFLOW,
|
||||
WorkflowType.CHAT,
|
||||
WorkflowType.WORKFLOW.value,
|
||||
WorkflowType.CHAT.value,
|
||||
}
|
||||
|
||||
def get_node_config_by_id(self, node_id: str) -> NodeConfigDict:
|
||||
"""Extract a node configuration from the workflow graph by node ID.
|
||||
@@ -487,7 +523,7 @@ class Workflow(Base): # bug
|
||||
"memory":
|
||||
{
|
||||
"window": { "enabled": false, "size": 10 },
|
||||
"query_prompt_template": "{{#sys.query#}}\n\n{{#sys.files#}}",
|
||||
"query_prompt_template": "{{#sys.query#}}\n\n{{#userinput.files#}}",
|
||||
"role_prefix": { "user": "", "assistant": "" },
|
||||
},
|
||||
"selected": false,
|
||||
@@ -1520,7 +1556,7 @@ class ConversationVariable(TypeBase):
|
||||
return variable_factory.build_conversation_variable_from_mapping(mapping)
|
||||
|
||||
|
||||
# Only `sys.query` and `sys.files` could be modified.
|
||||
# TODO: Remove file-system-variable editability after all persisted workflows are migrated.
|
||||
_EDITABLE_SYSTEM_VARIABLE = frozenset(("query", "files"))
|
||||
|
||||
|
||||
|
||||
@@ -488,7 +488,7 @@ class AppGenerateService:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _get_workflow(
|
||||
def get_workflow(
|
||||
cls,
|
||||
app_model: App,
|
||||
invoke_from: InvokeFrom,
|
||||
@@ -533,6 +533,17 @@ class AppGenerateService:
|
||||
|
||||
return workflow
|
||||
|
||||
@classmethod
|
||||
def _get_workflow(
|
||||
cls,
|
||||
app_model: App,
|
||||
invoke_from: InvokeFrom,
|
||||
workflow_id: str | None = None,
|
||||
*,
|
||||
session: Session,
|
||||
) -> Workflow:
|
||||
return cls.get_workflow(app_model, invoke_from, workflow_id, session=session)
|
||||
|
||||
@classmethod
|
||||
def get_response_generator(
|
||||
cls,
|
||||
|
||||
@@ -18,6 +18,7 @@ from core.app.apps.completion.app_config_manager import CompletionAppConfigManag
|
||||
from core.helper import encrypter
|
||||
from core.prompt.simple_prompt_transform import SimplePromptTransform
|
||||
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
|
||||
from core.workflow.variable_prefixes import USER_INPUT_VARIABLE_NODE_ID
|
||||
from events.app_event import app_was_created
|
||||
from graphon.file import FileUploadConfig
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
@@ -597,7 +598,7 @@ class WorkflowConverter:
|
||||
},
|
||||
"vision": {
|
||||
"enabled": file_upload is not None,
|
||||
"variable_selector": ["sys", "files"] if file_upload is not None else None,
|
||||
"variable_selector": [USER_INPUT_VARIABLE_NODE_ID, "files"] if file_upload is not None else None,
|
||||
"configs": {"detail": file_upload.image_config.detail}
|
||||
if file_upload is not None and file_upload.image_config is not None
|
||||
else None,
|
||||
|
||||
@@ -6,8 +6,10 @@ from collections.abc import Callable, Generator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy import exists, inspect, select, update
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm.attributes import set_committed_value
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||
@@ -143,6 +145,8 @@ from .human_input_delivery_test_service import (
|
||||
from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService
|
||||
from .workflow_restore import apply_published_workflow_snapshot_to_draft
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_file_access_controller = DatabaseFileAccessController()
|
||||
|
||||
|
||||
@@ -155,6 +159,7 @@ class WorkflowService:
|
||||
"""Initialize WorkflowService with repository dependencies."""
|
||||
if session_maker is None:
|
||||
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
self._session_maker = session_maker
|
||||
self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
|
||||
session_maker
|
||||
)
|
||||
@@ -211,7 +216,7 @@ class WorkflowService:
|
||||
)
|
||||
|
||||
# return draft workflow
|
||||
return workflow
|
||||
return self._persist_legacy_sys_files_migration_on_load(workflow)
|
||||
|
||||
def get_published_workflow_by_id(self, app_model: App, workflow_id: str, *, session: Session) -> Workflow | None:
|
||||
"""
|
||||
@@ -236,6 +241,7 @@ class WorkflowService:
|
||||
f"Cannot use draft workflow version. Workflow ID: {workflow_id}. "
|
||||
f"Please use a published workflow version or leave workflow_id empty."
|
||||
)
|
||||
self._persist_legacy_sys_files_migration_on_load(workflow)
|
||||
return workflow
|
||||
|
||||
def get_published_workflow(self, app_model: App, *, session: Session) -> Workflow | None:
|
||||
@@ -259,6 +265,53 @@ class WorkflowService:
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
return self._persist_legacy_sys_files_migration_on_load(workflow)
|
||||
|
||||
def _persist_legacy_sys_files_migration_on_load(self, workflow: Workflow | None) -> Workflow | None:
|
||||
"""Persist a load-time graph rewrite without joining or dirtying the caller's transaction."""
|
||||
|
||||
if workflow is None:
|
||||
return None
|
||||
if inspect(workflow, raiseerr=False) is None:
|
||||
return workflow
|
||||
|
||||
# TODO: Remove this load-time persistence path after the historical workflow migration is complete.
|
||||
original_graph = workflow.graph
|
||||
if not workflow.migrate_legacy_sys_files_graph_in_place():
|
||||
return workflow
|
||||
|
||||
migrated_graph = workflow.graph
|
||||
try:
|
||||
with self._session_maker.begin() as session:
|
||||
result = session.execute(
|
||||
update(Workflow)
|
||||
.where(
|
||||
Workflow.id == workflow.id,
|
||||
Workflow.tenant_id == workflow.tenant_id,
|
||||
Workflow.graph == original_graph,
|
||||
)
|
||||
.values(graph=migrated_graph)
|
||||
)
|
||||
if getattr(result, "rowcount", None) == 0:
|
||||
logger.warning(
|
||||
"Skipped persisting legacy sys.files workflow migration because the workflow changed "
|
||||
"concurrently, "
|
||||
"workflow_id=%s tenant_id=%s",
|
||||
workflow.id,
|
||||
workflow.tenant_id,
|
||||
)
|
||||
except SQLAlchemyError:
|
||||
logger.warning(
|
||||
"Failed to persist legacy sys.files workflow migration, workflow_id=%s tenant_id=%s",
|
||||
workflow.id,
|
||||
workflow.tenant_id,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
# The conditional update owns persistence. Mark the caller's instance clean so its later flush cannot
|
||||
# overwrite a concurrent workflow edit with the compatibility rewrite.
|
||||
set_committed_value(workflow, "graph", migrated_graph)
|
||||
|
||||
return workflow
|
||||
|
||||
def get_accessible_app_ids(self, app_ids: Sequence[str], tenant_id: str, *, session: Session) -> set[str]:
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ workflow:
|
||||
query_prompt_template: '{{#sys.query#}}
|
||||
|
||||
|
||||
{{#sys.files#}}'
|
||||
{{#userinput.files#}}'
|
||||
window:
|
||||
enabled: false
|
||||
size: 10
|
||||
|
||||
+2
-2
@@ -162,7 +162,7 @@ workflow:
|
||||
query_prompt_template: '{{#sys.query#}}
|
||||
|
||||
|
||||
{{#sys.files#}}'
|
||||
{{#userinput.files#}}'
|
||||
role_prefix:
|
||||
assistant: ''
|
||||
user: ''
|
||||
@@ -207,7 +207,7 @@ workflow:
|
||||
query_prompt_template: '{{#sys.query#}}
|
||||
|
||||
|
||||
{{#sys.files#}}'
|
||||
{{#userinput.files#}}'
|
||||
role_prefix:
|
||||
assistant: ''
|
||||
user: ''
|
||||
|
||||
+1
-1
@@ -178,7 +178,7 @@ workflow:
|
||||
query_prompt_template: '{{#sys.query#}}
|
||||
|
||||
|
||||
{{#sys.files#}}'
|
||||
{{#userinput.files#}}'
|
||||
role_prefix:
|
||||
assistant: ''
|
||||
user: ''
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from commands import system as system_commands
|
||||
from commands import app_maintenance as app_maintenance_commands
|
||||
|
||||
|
||||
def test_fix_app_site_missing_passes_loaded_session_to_signal(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -30,15 +30,15 @@ def test_fix_app_site_missing_passes_loaded_session_to_signal(monkeypatch: pytes
|
||||
engine = MagicMock()
|
||||
engine.begin.return_value.__enter__.return_value = connection
|
||||
|
||||
monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=engine, session=scoped_session))
|
||||
monkeypatch.setattr(app_maintenance_commands, "db", SimpleNamespace(engine=engine, session=scoped_session))
|
||||
send = MagicMock(side_effect=lambda *_args, **_kwargs: phase_events.append("signal"))
|
||||
monkeypatch.setattr(system_commands.app_was_created, "send", send)
|
||||
monkeypatch.setattr(app_maintenance_commands.app_was_created, "send", send)
|
||||
|
||||
system_commands.fix_app_site_missing.callback()
|
||||
app_maintenance_commands.fix_app_site_missing.callback()
|
||||
|
||||
scoped_session.assert_called_once_with()
|
||||
scalar.assert_called_once()
|
||||
get.assert_called_once_with(system_commands.Tenant, app.tenant_id)
|
||||
get.assert_called_once_with(app_maintenance_commands.Tenant, app.tenant_id)
|
||||
tenant.get_accounts.assert_called_once_with(session=session)
|
||||
send.assert_called_once_with(app, account=account, session=session)
|
||||
commit.assert_called_once_with()
|
||||
@@ -62,15 +62,19 @@ def test_fix_app_site_missing_rolls_back_when_signal_fails(monkeypatch: pytest.M
|
||||
engine = MagicMock()
|
||||
engine.begin.return_value.__enter__.return_value = connection
|
||||
|
||||
monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=engine, session=MagicMock(return_value=session)))
|
||||
monkeypatch.setattr(
|
||||
app_maintenance_commands,
|
||||
"db",
|
||||
SimpleNamespace(engine=engine, session=MagicMock(return_value=session)),
|
||||
)
|
||||
|
||||
def fail_signal(*_args, **_kwargs) -> None:
|
||||
phase_events.append("signal")
|
||||
raise RuntimeError("failed")
|
||||
|
||||
monkeypatch.setattr(system_commands.app_was_created, "send", MagicMock(side_effect=fail_signal))
|
||||
monkeypatch.setattr(app_maintenance_commands.app_was_created, "send", MagicMock(side_effect=fail_signal))
|
||||
|
||||
system_commands.fix_app_site_missing.callback()
|
||||
app_maintenance_commands.fix_app_site_missing.callback()
|
||||
|
||||
session.rollback.assert_called_once_with()
|
||||
session.commit.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from commands import migrate_legacy_sys_files_workflows
|
||||
from commands import workflow_migration as workflow_migration_commands
|
||||
|
||||
|
||||
def test_migrate_legacy_sys_files_workflows_command_passes_batch_options(mocker, capsys):
|
||||
runner = mocker.patch.object(
|
||||
workflow_migration_commands,
|
||||
"run_legacy_sys_files_workflow_migration",
|
||||
return_value=workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(
|
||||
scanned=10,
|
||||
migrated=2,
|
||||
failed=0,
|
||||
batches=1,
|
||||
last_id="workflow-10",
|
||||
),
|
||||
)
|
||||
|
||||
migrate_legacy_sys_files_workflows.callback(
|
||||
batch_size=200,
|
||||
limit=500,
|
||||
start_after_id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
runner.assert_called_once_with(
|
||||
batch_size=200,
|
||||
limit=500,
|
||||
start_after_id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
dry_run=True,
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "scanned=10" in captured.out
|
||||
assert "migrated=2" in captured.out
|
||||
assert "last_id=workflow-10" in captured.out
|
||||
|
||||
|
||||
def test_migrate_legacy_sys_files_workflows_rejects_non_positive_batch_size():
|
||||
with pytest.raises(click.UsageError, match="batch-size"):
|
||||
migrate_legacy_sys_files_workflows.callback(
|
||||
batch_size=0,
|
||||
limit=None,
|
||||
start_after_id=None,
|
||||
tenant_id=None,
|
||||
app_id=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
def test_migrate_legacy_sys_files_workflows_rejects_non_positive_limit():
|
||||
with pytest.raises(click.UsageError, match="limit"):
|
||||
migrate_legacy_sys_files_workflows.callback(
|
||||
batch_size=100,
|
||||
limit=0,
|
||||
start_after_id=None,
|
||||
tenant_id=None,
|
||||
app_id=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
def test_build_legacy_sys_files_workflow_query_uses_keyset_pagination():
|
||||
stmt = workflow_migration_commands._build_legacy_sys_files_workflow_query(
|
||||
start_after_id="workflow-1",
|
||||
batch_size=200,
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
)
|
||||
compiled = str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||
|
||||
assert "workflows.id > 'workflow-1'" in compiled
|
||||
assert "workflows.tenant_id = 'tenant-1'" in compiled
|
||||
assert "workflows.app_id = 'app-1'" in compiled
|
||||
assert "ORDER BY workflows.id" in compiled
|
||||
assert "LIMIT 200" in compiled
|
||||
assert "workflows.environment_variables" not in compiled
|
||||
|
||||
|
||||
def test_migrate_legacy_sys_files_workflow_batch_dry_run_rolls_back():
|
||||
migrated_workflow = MagicMock()
|
||||
migrated_workflow.id = "workflow-1"
|
||||
migrated_workflow.migrate_legacy_sys_files_graph_in_place.return_value = True
|
||||
untouched_workflow = MagicMock()
|
||||
untouched_workflow.id = "workflow-2"
|
||||
untouched_workflow.migrate_legacy_sys_files_graph_in_place.return_value = False
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [migrated_workflow, untouched_workflow]
|
||||
|
||||
stats = workflow_migration_commands._migrate_legacy_sys_files_workflow_batch(
|
||||
session=session,
|
||||
start_after_id=None,
|
||||
batch_size=200,
|
||||
tenant_id=None,
|
||||
app_id=None,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert stats.scanned == 2
|
||||
assert stats.migrated == 1
|
||||
assert stats.failed == 0
|
||||
assert stats.last_id == "workflow-2"
|
||||
session.rollback.assert_called_once()
|
||||
session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_migrate_legacy_sys_files_workflow_batch_commits_and_counts_failures(caplog):
|
||||
migrated_workflow = MagicMock()
|
||||
migrated_workflow.id = "workflow-1"
|
||||
migrated_workflow.migrate_legacy_sys_files_graph_in_place.return_value = True
|
||||
failing_workflow = MagicMock()
|
||||
failing_workflow.id = "workflow-2"
|
||||
failing_workflow.migrate_legacy_sys_files_graph_in_place.side_effect = RuntimeError("boom")
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [migrated_workflow, failing_workflow]
|
||||
|
||||
stats = workflow_migration_commands._migrate_legacy_sys_files_workflow_batch(
|
||||
session=session,
|
||||
start_after_id=None,
|
||||
batch_size=200,
|
||||
tenant_id=None,
|
||||
app_id=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
assert stats.scanned == 2
|
||||
assert stats.migrated == 1
|
||||
assert stats.failed == 1
|
||||
assert stats.last_id == "workflow-2"
|
||||
assert "Failed to migrate legacy" in caplog.text
|
||||
session.commit.assert_called_once()
|
||||
session.rollback.assert_not_called()
|
||||
|
||||
|
||||
def test_run_legacy_sys_files_workflow_migration_uses_keyset_batches(mocker):
|
||||
session_maker = MagicMock()
|
||||
sessions = [MagicMock(), MagicMock()]
|
||||
session_maker.side_effect = sessions
|
||||
mocker.patch.object(workflow_migration_commands, "sessionmaker", return_value=session_maker)
|
||||
mocker.patch.object(workflow_migration_commands, "db", SimpleNamespace(engine=object()))
|
||||
migrate_batch = mocker.patch.object(
|
||||
workflow_migration_commands,
|
||||
"_migrate_legacy_sys_files_workflow_batch",
|
||||
side_effect=[
|
||||
workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(
|
||||
scanned=2,
|
||||
migrated=1,
|
||||
failed=0,
|
||||
last_id="workflow-2",
|
||||
),
|
||||
workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(
|
||||
scanned=1,
|
||||
migrated=1,
|
||||
failed=0,
|
||||
last_id="workflow-3",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
stats = workflow_migration_commands.run_legacy_sys_files_workflow_migration(
|
||||
batch_size=2,
|
||||
limit=3,
|
||||
start_after_id="workflow-0",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert stats.scanned == 3
|
||||
assert stats.migrated == 2
|
||||
assert stats.batches == 2
|
||||
assert stats.last_id == "workflow-3"
|
||||
assert migrate_batch.call_args_list[0].kwargs["start_after_id"] == "workflow-0"
|
||||
assert migrate_batch.call_args_list[0].kwargs["batch_size"] == 2
|
||||
assert migrate_batch.call_args_list[1].kwargs["start_after_id"] == "workflow-2"
|
||||
assert migrate_batch.call_args_list[1].kwargs["batch_size"] == 1
|
||||
|
||||
|
||||
def test_run_legacy_sys_files_workflow_migration_stops_on_empty_batch(mocker):
|
||||
session_maker = MagicMock(return_value=MagicMock())
|
||||
mocker.patch.object(workflow_migration_commands, "sessionmaker", return_value=session_maker)
|
||||
mocker.patch.object(workflow_migration_commands, "db", SimpleNamespace(engine=object()))
|
||||
mocker.patch.object(
|
||||
workflow_migration_commands,
|
||||
"_migrate_legacy_sys_files_workflow_batch",
|
||||
return_value=workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(scanned=0),
|
||||
)
|
||||
|
||||
stats = workflow_migration_commands.run_legacy_sys_files_workflow_migration(
|
||||
batch_size=2,
|
||||
limit=None,
|
||||
start_after_id=None,
|
||||
tenant_id=None,
|
||||
app_id=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
assert stats.scanned == 0
|
||||
assert stats.batches == 0
|
||||
|
||||
|
||||
def test_run_legacy_sys_files_workflow_migration_stops_on_short_batch(mocker):
|
||||
session_maker = MagicMock(return_value=MagicMock())
|
||||
mocker.patch.object(workflow_migration_commands, "sessionmaker", return_value=session_maker)
|
||||
mocker.patch.object(workflow_migration_commands, "db", SimpleNamespace(engine=object()))
|
||||
migrate_batch = mocker.patch.object(
|
||||
workflow_migration_commands,
|
||||
"_migrate_legacy_sys_files_workflow_batch",
|
||||
return_value=workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(
|
||||
scanned=1,
|
||||
migrated=1,
|
||||
failed=0,
|
||||
last_id="workflow-1",
|
||||
),
|
||||
)
|
||||
|
||||
stats = workflow_migration_commands.run_legacy_sys_files_workflow_migration(
|
||||
batch_size=2,
|
||||
limit=None,
|
||||
start_after_id=None,
|
||||
tenant_id=None,
|
||||
app_id=None,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
assert stats.scanned == 1
|
||||
assert stats.batches == 1
|
||||
migrate_batch.assert_called_once()
|
||||
@@ -14,7 +14,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import commands
|
||||
from commands import system as system_commands
|
||||
from commands import workspace as workspace_commands
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models import Tenant
|
||||
@@ -83,11 +83,11 @@ def _encrypted_rows(tenant_id: str, *, suffix: str = "1") -> tuple[object, ...]:
|
||||
|
||||
|
||||
def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) -> None:
|
||||
monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=session.get_bind()))
|
||||
monkeypatch.setattr(workspace_commands, "db", SimpleNamespace(engine=session.get_bind()))
|
||||
|
||||
|
||||
def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys):
|
||||
monkeypatch.setattr(system_commands.dify_config, "EDITION", "CLOUD")
|
||||
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "CLOUD")
|
||||
|
||||
exit_code = _invoke_reset()
|
||||
captured = capsys.readouterr()
|
||||
@@ -106,8 +106,8 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant(
|
||||
) -> None:
|
||||
"""The command must purge LLM provider rows AND every tool provider table
|
||||
that stores ciphertext encrypted under the tenant key (#35396)."""
|
||||
monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
|
||||
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(workspace_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
|
||||
_bind_command_to_sqlite(monkeypatch, sqlite_session)
|
||||
|
||||
tenant = _tenant(TENANT_ID)
|
||||
@@ -146,8 +146,8 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant(
|
||||
)
|
||||
def test_reset_iterates_all_tenants(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
"""Multi-tenant deployments must purge every tenant, not just the first."""
|
||||
monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
|
||||
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(workspace_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
|
||||
|
||||
_bind_command_to_sqlite(monkeypatch, sqlite_session)
|
||||
tenant_ids = [f"11111111-1111-1111-1111-{index:012d}" for index in range(3)]
|
||||
|
||||
@@ -4,7 +4,7 @@ import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import commands
|
||||
from commands import system as system_commands
|
||||
from commands import database as database_commands
|
||||
from libs.db_migration_lock import LockNotOwnedError, RedisError
|
||||
|
||||
HEARTBEAT_WAIT_TIMEOUT_SECONDS = 5.0
|
||||
@@ -25,11 +25,11 @@ def _invoke_upgrade_db() -> int:
|
||||
|
||||
|
||||
def test_upgrade_db_skips_when_lock_not_acquired(monkeypatch, capsys):
|
||||
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 1234)
|
||||
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 1234)
|
||||
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = False
|
||||
system_commands.redis_client.lock.return_value = lock
|
||||
database_commands.redis_client.lock.return_value = lock
|
||||
|
||||
exit_code = _invoke_upgrade_db()
|
||||
captured = capsys.readouterr()
|
||||
@@ -37,18 +37,20 @@ def test_upgrade_db_skips_when_lock_not_acquired(monkeypatch, capsys):
|
||||
assert exit_code == 0
|
||||
assert "Database migration skipped" in captured.out
|
||||
|
||||
system_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=1234, thread_local=False)
|
||||
database_commands.redis_client.lock.assert_called_once_with(
|
||||
name="db_upgrade_lock", timeout=1234, thread_local=False
|
||||
)
|
||||
lock.acquire.assert_called_once_with(blocking=False)
|
||||
lock.release.assert_not_called()
|
||||
|
||||
|
||||
def test_upgrade_db_failure_not_masked_by_lock_release(monkeypatch, capsys):
|
||||
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 321)
|
||||
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 321)
|
||||
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
lock.release.side_effect = LockNotOwnedError("simulated")
|
||||
system_commands.redis_client.lock.return_value = lock
|
||||
database_commands.redis_client.lock.return_value = lock
|
||||
|
||||
def _upgrade():
|
||||
raise RuntimeError("boom")
|
||||
@@ -61,18 +63,18 @@ def test_upgrade_db_failure_not_masked_by_lock_release(monkeypatch, capsys):
|
||||
assert exit_code == 1
|
||||
assert "Database migration failed: boom" in captured.out
|
||||
|
||||
system_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=321, thread_local=False)
|
||||
database_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=321, thread_local=False)
|
||||
lock.acquire.assert_called_once_with(blocking=False)
|
||||
lock.release.assert_called_once()
|
||||
|
||||
|
||||
def test_upgrade_db_success_ignores_lock_not_owned_on_release(monkeypatch, capsys):
|
||||
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 999)
|
||||
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 999)
|
||||
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
lock.release.side_effect = LockNotOwnedError("simulated")
|
||||
system_commands.redis_client.lock.return_value = lock
|
||||
database_commands.redis_client.lock.return_value = lock
|
||||
|
||||
_install_fake_flask_migrate(monkeypatch, lambda: None)
|
||||
|
||||
@@ -82,7 +84,7 @@ def test_upgrade_db_success_ignores_lock_not_owned_on_release(monkeypatch, capsy
|
||||
assert exit_code == 0
|
||||
assert "Database migration successful!" in captured.out
|
||||
|
||||
system_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=999, thread_local=False)
|
||||
database_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=999, thread_local=False)
|
||||
lock.acquire.assert_called_once_with(blocking=False)
|
||||
lock.release.assert_called_once()
|
||||
|
||||
@@ -93,11 +95,11 @@ def test_upgrade_db_renews_lock_during_migration(monkeypatch, capsys):
|
||||
"""
|
||||
|
||||
# Use a small TTL so the heartbeat interval triggers quickly.
|
||||
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
|
||||
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
|
||||
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
system_commands.redis_client.lock.return_value = lock
|
||||
database_commands.redis_client.lock.return_value = lock
|
||||
|
||||
renewed = threading.Event()
|
||||
|
||||
@@ -121,11 +123,11 @@ def test_upgrade_db_renews_lock_during_migration(monkeypatch, capsys):
|
||||
|
||||
def test_upgrade_db_ignores_reacquire_errors(monkeypatch, capsys):
|
||||
# Use a small TTL so heartbeat runs during the upgrade call.
|
||||
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
|
||||
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
|
||||
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
system_commands.redis_client.lock.return_value = lock
|
||||
database_commands.redis_client.lock.return_value = lock
|
||||
|
||||
attempted = threading.Event()
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from commands import reset_encrypt_key_pair
|
||||
from commands import workspace as workspace_commands
|
||||
|
||||
|
||||
def test_reset_encrypt_key_pair_skips_non_self_hosted(monkeypatch, capsys):
|
||||
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "CLOUD")
|
||||
|
||||
reset_encrypt_key_pair.callback()
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "only for SELF_HOSTED" in captured.out
|
||||
|
||||
|
||||
def test_reset_encrypt_key_pair_rotates_keys_and_removes_custom_provider_data(monkeypatch, capsys):
|
||||
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(workspace_commands, "generate_key_pair", lambda tenant_id: f"public-key-{tenant_id}")
|
||||
tenant = MagicMock()
|
||||
tenant.id = "tenant-1"
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [tenant]
|
||||
session_manager = MagicMock()
|
||||
session_manager.begin.return_value.__enter__.return_value = session
|
||||
monkeypatch.setattr(workspace_commands, "sessionmaker", lambda *args, **kwargs: session_manager)
|
||||
monkeypatch.setattr(workspace_commands, "db", MagicMock(engine=object()))
|
||||
|
||||
reset_encrypt_key_pair.callback()
|
||||
|
||||
assert tenant.encrypt_public_key == "public-key-tenant-1"
|
||||
assert session.execute.call_count == 5
|
||||
captured = capsys.readouterr()
|
||||
assert "tenant-1 has been reset" in captured.out
|
||||
|
||||
|
||||
def test_reset_encrypt_key_pair_stops_when_workspace_record_is_missing(monkeypatch, capsys):
|
||||
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED")
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [None]
|
||||
session_manager = MagicMock()
|
||||
session_manager.begin.return_value.__enter__.return_value = session
|
||||
monkeypatch.setattr(workspace_commands, "sessionmaker", lambda *args, **kwargs: session_manager)
|
||||
monkeypatch.setattr(workspace_commands, "db", MagicMock(engine=object()))
|
||||
|
||||
reset_encrypt_key_pair.callback()
|
||||
|
||||
session.execute.assert_not_called()
|
||||
captured = capsys.readouterr()
|
||||
assert "No workspaces found" in captured.out
|
||||
@@ -0,0 +1,89 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from controllers.service_api.app.legacy_system_files import (
|
||||
attach_legacy_system_file_warning_for_service_api,
|
||||
normalize_legacy_system_file_args_for_service_api,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from services.app_generate_service import AppGenerateService
|
||||
|
||||
_LEGACY_FILE_TEMPLATE = "{{#" + ".".join(("sys", "files")) + "#}}"
|
||||
_USER_INPUT_FILE_INPUT_KEY = ".".join(("userinput", "files"))
|
||||
|
||||
|
||||
def _legacy_file_graph() -> dict:
|
||||
return {
|
||||
"nodes": [
|
||||
{"id": "start", "data": {"type": "start", "variables": []}},
|
||||
{"id": "answer", "data": {"type": "answer", "answer": _LEGACY_FILE_TEMPLATE}},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
|
||||
def test_hidden_service_api_file_payload_maps_to_userinput_files(mocker):
|
||||
workflow = MagicMock()
|
||||
workflow.graph_dict = _legacy_file_graph()
|
||||
get_workflow = mocker.patch.object(AppGenerateService, "get_workflow", return_value=workflow)
|
||||
app_model = MagicMock()
|
||||
session = MagicMock()
|
||||
files = [{"transfer_method": "remote_url", "url": "https://example.com/a.png"}]
|
||||
|
||||
args, compat_variable = normalize_legacy_system_file_args_for_service_api(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
args={"inputs": {}, "files": None},
|
||||
raw_payload={"system": {"files": files}},
|
||||
)
|
||||
|
||||
get_workflow.assert_called_once_with(app_model, InvokeFrom.SERVICE_API, None, session=session)
|
||||
assert compat_variable is not None
|
||||
assert args["files"] == files
|
||||
assert args["inputs"][_USER_INPUT_FILE_INPUT_KEY] == files
|
||||
|
||||
|
||||
def test_service_api_file_payload_is_ignored_when_absent(mocker):
|
||||
get_workflow = mocker.patch.object(AppGenerateService, "get_workflow")
|
||||
app_model = MagicMock()
|
||||
original_args = {"inputs": {}}
|
||||
|
||||
args, compat_variable = normalize_legacy_system_file_args_for_service_api(
|
||||
session=MagicMock(),
|
||||
app_model=app_model,
|
||||
args=original_args,
|
||||
raw_payload={},
|
||||
)
|
||||
|
||||
assert args is original_args
|
||||
assert compat_variable is None
|
||||
get_workflow.assert_not_called()
|
||||
|
||||
|
||||
def test_top_level_service_api_file_payload_still_checks_workflow_graph(mocker):
|
||||
workflow = MagicMock()
|
||||
workflow.graph_dict = {"nodes": []}
|
||||
get_workflow = mocker.patch.object(AppGenerateService, "get_workflow", return_value=workflow)
|
||||
app_model = MagicMock()
|
||||
session = MagicMock()
|
||||
files = [{"id": "file-1"}]
|
||||
|
||||
args, compat_variable = normalize_legacy_system_file_args_for_service_api(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
args={"inputs": {}, "files": files},
|
||||
raw_payload={},
|
||||
)
|
||||
|
||||
get_workflow.assert_called_once_with(app_model, InvokeFrom.SERVICE_API, None, session=session)
|
||||
assert args["files"] == files
|
||||
assert compat_variable is None
|
||||
|
||||
|
||||
def test_service_api_warning_is_attached_only_when_compatibility_was_used():
|
||||
compat_variable = MagicMock(node_id="userinput", variable_name="files")
|
||||
|
||||
response = attach_legacy_system_file_warning_for_service_api({"answer": "ok"}, compat_variable)
|
||||
response_without_warning = attach_legacy_system_file_warning_for_service_api({"answer": "ok"}, None)
|
||||
|
||||
assert response["warnings"]
|
||||
assert response_without_warning == {"answer": "ok"}
|
||||
@@ -46,7 +46,7 @@ class TestAdvancedChatAppGeneratorValidation:
|
||||
with pytest.raises(ValueError, match="query must be a string"):
|
||||
generator.generate(
|
||||
app_model=SimpleNamespace(),
|
||||
workflow=SimpleNamespace(),
|
||||
workflow=SimpleNamespace(graph_dict={"nodes": []}),
|
||||
user=SimpleNamespace(),
|
||||
args={"inputs": {}, "query": 123},
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
@@ -186,7 +186,7 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
|
||||
result = generator.generate(
|
||||
app_model=SimpleNamespace(id="app", tenant_id="tenant"),
|
||||
workflow=SimpleNamespace(features_dict={}),
|
||||
workflow=SimpleNamespace(features_dict={}, graph_dict={"nodes": []}),
|
||||
user=user,
|
||||
args={
|
||||
"query": "hello",
|
||||
@@ -1209,7 +1209,7 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
monkeypatch.setattr(generator, "_generate", _fake_generate)
|
||||
|
||||
app_model = SimpleNamespace(id="app", tenant_id="tenant")
|
||||
workflow = SimpleNamespace(features_dict={})
|
||||
workflow = SimpleNamespace(features_dict={}, graph_dict={"nodes": []})
|
||||
from models import Account
|
||||
|
||||
user = Account(name="Tester", email="tester@example.com")
|
||||
@@ -1289,7 +1289,7 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
monkeypatch.setattr(generator, "_generate", _fake_generate)
|
||||
|
||||
app_model = SimpleNamespace(id="app", tenant_id="tenant")
|
||||
workflow = SimpleNamespace(features_dict={})
|
||||
workflow = SimpleNamespace(features_dict={}, graph_dict={"nodes": []})
|
||||
from models.model import EndUser
|
||||
|
||||
user = EndUser(tenant_id="tenant", type="session", name="tester", session_id="session")
|
||||
|
||||
+15
-1
@@ -22,7 +22,7 @@ def _build_converter() -> WorkflowResponseConverter:
|
||||
app_config=SimpleNamespace(app_id="app-1", tenant_id="tenant-1"),
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
files=[],
|
||||
inputs={},
|
||||
inputs={"userinput.files": []},
|
||||
workflow_execution_id="run-1",
|
||||
call_depth=0,
|
||||
)
|
||||
@@ -54,3 +54,17 @@ def test_workflow_start_stream_response_carries_initial_reason():
|
||||
reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
assert resp.data.reason is WorkflowStartReason.INITIAL
|
||||
|
||||
|
||||
def test_workflow_start_stream_response_exposes_only_canonical_file_input():
|
||||
converter = _build_converter()
|
||||
|
||||
resp = converter.workflow_start_to_stream_response(
|
||||
task_id="task-1",
|
||||
workflow_run_id="run-1",
|
||||
workflow_id="wf-1",
|
||||
reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
assert resp.data.inputs["userinput.files"] == []
|
||||
assert "sys.files" not in resp.data.inputs
|
||||
|
||||
@@ -111,7 +111,7 @@ def test_generate_includes_parent_trace_context_in_extras(monkeypatch):
|
||||
|
||||
result = generator.generate(
|
||||
app_model=SimpleNamespace(tenant_id="tenant-1", id="app-1"),
|
||||
workflow=SimpleNamespace(features_dict={}),
|
||||
workflow=SimpleNamespace(features_dict={}, graph_dict={"nodes": []}),
|
||||
user=SimpleNamespace(id="user-1", session_id="session-1"),
|
||||
args={
|
||||
"inputs": {"query": "hello"},
|
||||
|
||||
@@ -377,7 +377,7 @@ class TestWorkflowAppGeneratorGenerate:
|
||||
|
||||
result = generator.generate(
|
||||
app_model=SimpleNamespace(id="app", tenant_id="tenant"),
|
||||
workflow=SimpleNamespace(features_dict={}),
|
||||
workflow=SimpleNamespace(features_dict={}, graph_dict={"nodes": []}),
|
||||
user=SimpleNamespace(id="user", session_id="session"),
|
||||
args={"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: True},
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
|
||||
@@ -26,6 +26,7 @@ class TestPlannerSystemPrompt:
|
||||
"""Auto-mode resolution rides on the planner echoing its mode choice."""
|
||||
assert '"mode": "workflow | advanced-chat"' in PLANNER_SYSTEM_PROMPT
|
||||
assert "When the ``# Mode`` section says auto, YOU decide" in PLANNER_SYSTEM_PROMPT
|
||||
assert "userinput.files" in PLANNER_SYSTEM_PROMPT
|
||||
|
||||
|
||||
class TestFormatIdealOutputSection:
|
||||
@@ -75,6 +76,9 @@ class TestNodeBuilderPrompt:
|
||||
assert '"viewport":' not in prompt
|
||||
assert '"positionAbsolute":' not in prompt
|
||||
|
||||
def test_start_uses_user_input_files(self):
|
||||
assert "userinput.files" in get_node_builder_system_prompt("start")
|
||||
|
||||
def test_supports_main_human_input_and_assigner_contracts(self):
|
||||
human_input = get_node_builder_system_prompt("human-input")
|
||||
assigner = get_node_builder_system_prompt("assigner")
|
||||
@@ -130,17 +134,19 @@ class TestNodeBuilderUserSections:
|
||||
|
||||
|
||||
class TestModeSection:
|
||||
def test_advanced_chat_documents_system_variables(self):
|
||||
def test_advanced_chat_documents_built_in_variables(self):
|
||||
out = format_mode_section("advanced-chat")
|
||||
|
||||
assert "sys.query" in out
|
||||
assert '["sys", "query"]' in out
|
||||
assert "userinput.files" in out
|
||||
assert '["userinput", "files"]' in out
|
||||
assert "do NOT invent start-node variables" in out
|
||||
|
||||
def test_workflow_mode_forbids_system_variables(self):
|
||||
def test_workflow_mode_documents_file_input(self):
|
||||
out = format_mode_section("workflow")
|
||||
|
||||
assert "NO automatic system variables" in out
|
||||
assert "userinput.files" in out
|
||||
assert "start node's declared variables" in out
|
||||
|
||||
|
||||
class TestExistingGraphSection:
|
||||
|
||||
@@ -228,12 +228,12 @@ def _previous_node_prompt_payload(result, selector: str) -> object:
|
||||
|
||||
|
||||
def _uploaded_workflow_files_prompt_payload(result) -> object:
|
||||
prefix = " - sys.files: "
|
||||
prefix = " - userinput.files: "
|
||||
user_prompt = _workflow_user_prompt(result)
|
||||
for line in user_prompt.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return json.loads(line.removeprefix(prefix))
|
||||
raise AssertionError("missing prompt payload for sys.files")
|
||||
raise AssertionError("missing prompt payload for userinput.files")
|
||||
|
||||
|
||||
def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
from core.workflow.legacy_system_files import (
|
||||
LegacySysFilesCompatVariable,
|
||||
attach_legacy_sys_files_warning,
|
||||
migrate_legacy_sys_files_graph_with_result,
|
||||
normalize_legacy_sys_files_args,
|
||||
resolve_legacy_sys_files_compat_variable,
|
||||
)
|
||||
|
||||
_LEGACY_NODE_ID = "sys"
|
||||
_LEGACY_ALIAS_NODE_ID = "userinput"
|
||||
_LEGACY_VARIABLE_NAME = "files"
|
||||
_LEGACY_SELECTOR = [_LEGACY_NODE_ID, _LEGACY_VARIABLE_NAME]
|
||||
_LEGACY_TEMPLATE = "{{#" + ".".join((_LEGACY_NODE_ID, _LEGACY_VARIABLE_NAME)) + "#}}"
|
||||
_LEGACY_ALIAS_SELECTOR = [_LEGACY_ALIAS_NODE_ID, _LEGACY_VARIABLE_NAME]
|
||||
_LEGACY_ALIAS_TEMPLATE = "{{#" + ".".join((_LEGACY_ALIAS_NODE_ID, _LEGACY_VARIABLE_NAME)) + "#}}"
|
||||
_LEGACY_ALIAS_INPUT_KEY = ".".join((_LEGACY_ALIAS_NODE_ID, _LEGACY_VARIABLE_NAME))
|
||||
|
||||
|
||||
def test_migrate_legacy_sys_files_graph_ignores_invalid_or_unrelated_graphs():
|
||||
assert not migrate_legacy_sys_files_graph_with_result({}).changed
|
||||
assert not migrate_legacy_sys_files_graph_with_result({"nodes": [], "edges": [_LEGACY_SELECTOR]}).changed
|
||||
assert not migrate_legacy_sys_files_graph_with_result({"nodes": [{"data": {"value": ["sys", "query"]}}]}).changed
|
||||
|
||||
|
||||
def test_migrate_legacy_sys_files_graph_rewrites_sys_files_to_userinput_files_without_start_variable():
|
||||
graph = {
|
||||
"nodes": [
|
||||
{"id": "start", "data": {"type": "start", "variables": [{"variable": "sys_files"}]}},
|
||||
{
|
||||
"id": "answer",
|
||||
"data": {
|
||||
"type": "answer",
|
||||
"answer": _LEGACY_SELECTOR,
|
||||
"template": _LEGACY_TEMPLATE,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = migrate_legacy_sys_files_graph_with_result(graph)
|
||||
|
||||
assert result.changed
|
||||
start_data = result.graph["nodes"][0]["data"]
|
||||
assert start_data["variables"] == [{"variable": "sys_files"}]
|
||||
assert result.graph["nodes"][1]["data"]["answer"] == _LEGACY_ALIAS_SELECTOR
|
||||
assert result.graph["nodes"][1]["data"]["template"] == _LEGACY_ALIAS_TEMPLATE
|
||||
assert graph["nodes"][1]["data"]["answer"] == _LEGACY_SELECTOR
|
||||
assert graph["nodes"][1]["data"]["template"] == _LEGACY_TEMPLATE
|
||||
|
||||
|
||||
def test_migrate_legacy_sys_files_graph_leaves_userinput_files_target_unchanged():
|
||||
graph = {
|
||||
"nodes": [
|
||||
{"id": "start", "data": {"type": "start", "variables": []}},
|
||||
{
|
||||
"id": "answer",
|
||||
"data": {
|
||||
"type": "answer",
|
||||
"answer": _LEGACY_ALIAS_SELECTOR,
|
||||
"template": _LEGACY_ALIAS_TEMPLATE,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = migrate_legacy_sys_files_graph_with_result(graph)
|
||||
|
||||
assert not result.changed
|
||||
assert result.graph == graph
|
||||
|
||||
|
||||
def test_resolve_legacy_sys_files_compat_variable_returns_userinput_files_target():
|
||||
assert resolve_legacy_sys_files_compat_variable({}) is None
|
||||
assert resolve_legacy_sys_files_compat_variable({"nodes": [{"data": {"value": ["sys", "query"]}}]}) is None
|
||||
|
||||
compat_variable = resolve_legacy_sys_files_compat_variable({"nodes": [{"data": {"value": _LEGACY_SELECTOR}}]})
|
||||
|
||||
assert compat_variable == LegacySysFilesCompatVariable(
|
||||
node_id=_LEGACY_ALIAS_NODE_ID,
|
||||
variable_name=_LEGACY_VARIABLE_NAME,
|
||||
)
|
||||
assert (
|
||||
resolve_legacy_sys_files_compat_variable({"nodes": [{"data": {"value": _LEGACY_ALIAS_SELECTOR}}]})
|
||||
== compat_variable
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_legacy_sys_files_args_handles_no_compat_and_top_level_files():
|
||||
args_without_legacy, compat_without_legacy = normalize_legacy_sys_files_args(
|
||||
graph={"nodes": []},
|
||||
args={"inputs": {}},
|
||||
)
|
||||
assert args_without_legacy == {"inputs": {}}
|
||||
assert compat_without_legacy is None
|
||||
|
||||
files = [{"id": "file-1"}]
|
||||
graph = {
|
||||
"nodes": [
|
||||
{"id": "start", "data": {"type": "start", "variables": []}},
|
||||
{"id": "answer", "data": {"type": "answer", "answer": _LEGACY_TEMPLATE}},
|
||||
],
|
||||
}
|
||||
normalized_args, compat_variable = normalize_legacy_sys_files_args(
|
||||
graph=graph,
|
||||
args={"inputs": {}, "files": files},
|
||||
)
|
||||
|
||||
assert compat_variable is not None
|
||||
assert normalized_args["files"] == files
|
||||
assert normalized_args["inputs"][".".join((compat_variable.node_id, compat_variable.variable_name))] == files
|
||||
|
||||
|
||||
def test_normalize_legacy_sys_files_args_maps_userinput_files_to_top_level_files_without_warning():
|
||||
files = [{"id": "file-1"}]
|
||||
normalized_args, compat_variable = normalize_legacy_sys_files_args(
|
||||
graph={"nodes": []},
|
||||
args={"inputs": {_LEGACY_ALIAS_INPUT_KEY: files}},
|
||||
)
|
||||
|
||||
assert compat_variable is None
|
||||
assert normalized_args["files"] == files
|
||||
assert normalized_args["inputs"] == {_LEGACY_ALIAS_INPUT_KEY: files}
|
||||
|
||||
|
||||
def test_normalize_legacy_sys_files_args_prefers_userinput_files_over_legacy_files():
|
||||
legacy_files = [{"id": "legacy-file"}]
|
||||
userinput_files = [{"id": "userinput-file"}]
|
||||
|
||||
normalized_args, compat_variable = normalize_legacy_sys_files_args(
|
||||
graph={"nodes": [{"data": {"type": "answer", "answer": _LEGACY_ALIAS_TEMPLATE}}]},
|
||||
args={
|
||||
"inputs": {_LEGACY_ALIAS_INPUT_KEY: userinput_files},
|
||||
"files": legacy_files,
|
||||
},
|
||||
)
|
||||
|
||||
assert compat_variable is None
|
||||
assert normalized_args["files"] == userinput_files
|
||||
|
||||
|
||||
def test_attach_legacy_sys_files_warning_wraps_stream_and_closes_source():
|
||||
class CloseableStream:
|
||||
closed = False
|
||||
|
||||
def __iter__(self):
|
||||
yield "data: payload\n\n"
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
stream = CloseableStream()
|
||||
wrapped = attach_legacy_sys_files_warning(
|
||||
stream,
|
||||
LegacySysFilesCompatVariable(node_id=_LEGACY_ALIAS_NODE_ID, variable_name=_LEGACY_VARIABLE_NAME),
|
||||
)
|
||||
|
||||
chunks = list(wrapped)
|
||||
|
||||
assert "warning" in chunks[0]
|
||||
assert chunks[1] == "data: payload\n\n"
|
||||
assert stream.closed
|
||||
@@ -1,6 +1,7 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.workflow.system_variables import (
|
||||
build_bootstrap_variables,
|
||||
build_system_variables,
|
||||
default_system_variables,
|
||||
get_node_creation_preload_selectors,
|
||||
@@ -56,6 +57,25 @@ def test_build_system_variables_preserves_file_values():
|
||||
assert system_values["files"] == [file]
|
||||
|
||||
|
||||
def test_build_bootstrap_variables_adds_userinput_files_alias():
|
||||
file = File(
|
||||
file_type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
related_id="file-id",
|
||||
filename="test.txt",
|
||||
extension=".txt",
|
||||
mime_type="text/plain",
|
||||
size=1,
|
||||
storage_key="storage-key",
|
||||
)
|
||||
|
||||
bootstrap_variables = build_bootstrap_variables(system_variables=build_system_variables(files=[file]))
|
||||
file_variables_by_selector = {tuple(variable.selector): variable for variable in bootstrap_variables}
|
||||
|
||||
assert file_variables_by_selector[("sys", "files")].value == [file]
|
||||
assert file_variables_by_selector[("userinput", "files")].value == [file]
|
||||
|
||||
|
||||
def test_default_system_variables_generates_workflow_run_id():
|
||||
system_variables = default_system_variables()
|
||||
system_values = system_variables_to_mapping(system_variables)
|
||||
|
||||
@@ -18,6 +18,11 @@ from models.workflow import (
|
||||
is_system_variable_editable,
|
||||
)
|
||||
|
||||
_LEGACY_FILE_TEMPLATE = "{{#" + ".".join(("sys", "files")) + "#}}"
|
||||
_LEGACY_FILE_SELECTOR = ["sys", "files"]
|
||||
_USER_INPUT_FILE_TEMPLATE = "{{#" + ".".join(("userinput", "files")) + "#}}"
|
||||
_USER_INPUT_FILE_SELECTOR = ["userinput", "files"]
|
||||
|
||||
|
||||
def test_environment_variables():
|
||||
# tenant_id context variable removed - using current_user.current_tenant_id directly
|
||||
@@ -245,6 +250,144 @@ class TestIsSystemVariableEditable:
|
||||
assert is_system_variable_editable("invalid_or_new_system_variable") == False
|
||||
|
||||
|
||||
class TestWorkflowLegacySysFilesCompatibility:
|
||||
def _make_workflow(self, graph: dict, *, features: dict | None = None) -> Workflow:
|
||||
return Workflow(
|
||||
tenant_id="tenant_id",
|
||||
app_id="app_id",
|
||||
type="workflow",
|
||||
version="draft",
|
||||
graph=json.dumps(graph),
|
||||
features=json.dumps(features or {}),
|
||||
created_by="account_id",
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
)
|
||||
|
||||
def test_graph_dict_rewrites_legacy_sys_files_references_to_userinput_files(self):
|
||||
workflow = self._make_workflow(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"data": {
|
||||
"type": "start",
|
||||
"title": "Start",
|
||||
"variables": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "llm",
|
||||
"data": {
|
||||
"type": "llm",
|
||||
"prompt_template": [{"role": "user", "text": f"files: {_LEGACY_FILE_TEMPLATE}"}],
|
||||
"context": {"variable_selector": _LEGACY_FILE_SELECTOR},
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
stored_graph_before_read = workflow.graph
|
||||
|
||||
graph = workflow.graph_dict
|
||||
start_node = next(node for node in graph["nodes"] if node["id"] == "start")
|
||||
llm_node = next(node for node in graph["nodes"] if node["id"] == "llm")
|
||||
|
||||
assert start_node["data"]["variables"] == []
|
||||
assert llm_node["data"]["prompt_template"][0]["text"] == f"files: {_USER_INPUT_FILE_TEMPLATE}"
|
||||
assert llm_node["data"]["context"]["variable_selector"] == _USER_INPUT_FILE_SELECTOR
|
||||
|
||||
assert workflow.graph == stored_graph_before_read
|
||||
|
||||
def test_migrate_legacy_sys_files_graph_in_place_updates_stored_graph(self):
|
||||
workflow = self._make_workflow(
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "answer", "data": {"type": "answer", "answer": _LEGACY_FILE_TEMPLATE}},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert workflow.migrate_legacy_sys_files_graph_in_place()
|
||||
assert _LEGACY_FILE_TEMPLATE not in workflow.graph
|
||||
assert _USER_INPUT_FILE_TEMPLATE in workflow.graph
|
||||
|
||||
def test_graph_dict_preserves_existing_start_variables_when_migrating_legacy_sys_files(self):
|
||||
workflow = self._make_workflow(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"data": {
|
||||
"type": "start",
|
||||
"title": "Start",
|
||||
"variables": [
|
||||
{"variable": "sys_files", "label": "Existing", "type": "text-input"},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "answer",
|
||||
"data": {
|
||||
"type": "answer",
|
||||
"answer": _LEGACY_FILE_TEMPLATE,
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
|
||||
graph = workflow.graph_dict
|
||||
start_node = next(node for node in graph["nodes"] if node["id"] == "start")
|
||||
answer_node = next(node for node in graph["nodes"] if node["id"] == "answer")
|
||||
|
||||
assert [variable["variable"] for variable in start_node["data"]["variables"]] == ["sys_files"]
|
||||
assert answer_node["data"]["answer"] == _USER_INPUT_FILE_TEMPLATE
|
||||
|
||||
def test_graph_dict_leaves_userinput_files_references_unchanged(self):
|
||||
workflow = self._make_workflow(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"data": {
|
||||
"type": "start",
|
||||
"title": "Start",
|
||||
"variables": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "answer",
|
||||
"data": {
|
||||
"type": "answer",
|
||||
"answer": _USER_INPUT_FILE_TEMPLATE,
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
features={
|
||||
"file_upload": {
|
||||
"enabled": True,
|
||||
"allowed_file_upload_methods": ["remote_url"],
|
||||
"allowed_file_types": ["document", "custom"],
|
||||
"allowed_file_extensions": [".pdf"],
|
||||
"number_limits": 8,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
graph = workflow.graph_dict
|
||||
start_node = next(node for node in graph["nodes"] if node["id"] == "start")
|
||||
|
||||
assert start_node["data"]["variables"] == []
|
||||
assert json.loads(workflow.graph) == graph
|
||||
|
||||
|
||||
class TestWorkflowDraftVariableGetValue:
|
||||
def test_get_value_by_case(self):
|
||||
@dataclasses.dataclass
|
||||
|
||||
@@ -50,6 +50,9 @@ from services.workflow_service import (
|
||||
_setup_variable_pool,
|
||||
)
|
||||
|
||||
_LEGACY_FILE_TEMPLATE = "{{#" + ".".join(("sys", "files")) + "#}}"
|
||||
_USER_INPUT_FILE_TEMPLATE = "{{#" + ".".join(("userinput", "files")) + "#}}"
|
||||
|
||||
|
||||
class TestWorkflowAssociatedDataFactory:
|
||||
"""
|
||||
@@ -277,6 +280,31 @@ class TestWorkflowService:
|
||||
|
||||
assert result is workflow
|
||||
|
||||
def test_get_draft_workflow_persists_legacy_system_files_migration(
|
||||
self, workflow_service: WorkflowService, sqlite_session: Session
|
||||
):
|
||||
# TODO: Remove this compatibility test after the historical workflow migration is complete.
|
||||
app = TestWorkflowAssociatedDataFactory.create_app()
|
||||
workflow = TestWorkflowAssociatedDataFactory.create_workflow(
|
||||
graph={
|
||||
"nodes": [
|
||||
{"id": "start", "data": {"type": "start", "variables": []}},
|
||||
{"id": "answer", "data": {"type": "answer", "answer": _LEGACY_FILE_TEMPLATE}},
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
)
|
||||
sqlite_session.add(workflow)
|
||||
sqlite_session.commit()
|
||||
|
||||
result = workflow_service.get_draft_workflow(app, session=sqlite_session)
|
||||
|
||||
assert result is workflow
|
||||
assert _LEGACY_FILE_TEMPLATE not in workflow.graph
|
||||
assert _USER_INPUT_FILE_TEMPLATE in workflow.graph
|
||||
sqlite_session.expire_all()
|
||||
assert _USER_INPUT_FILE_TEMPLATE in sqlite_session.get(Workflow, workflow.id).graph
|
||||
|
||||
def test_get_draft_workflow_returns_none(self, workflow_service: WorkflowService, sqlite_session: Session):
|
||||
"""Test get_draft_workflow returns None when no draft exists."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app()
|
||||
|
||||
@@ -239,6 +239,29 @@ def test__convert_to_llm_node_for_chatbot_simple_chat_model(default_variables: l
|
||||
assert "{{#start.text_input#}}" in node["data"]["prompt_template"][0]["text"]
|
||||
|
||||
|
||||
def test__convert_to_llm_node_uses_userinput_files_for_vision(default_variables: list[VariableEntity]) -> None:
|
||||
workflow_converter = WorkflowConverter()
|
||||
graph = {"nodes": [workflow_converter._convert_to_start_node(default_variables)], "edges": []}
|
||||
model_config = ModelConfigEntity(provider="openai", model="gpt-4", mode=LLMMode.CHAT.value, parameters={}, stop=[])
|
||||
prompt_template = PromptTemplateEntity(
|
||||
prompt_type=PromptTemplateEntity.PromptType.SIMPLE,
|
||||
simple_prompt_template="Describe the upload",
|
||||
)
|
||||
file_upload = MagicMock()
|
||||
file_upload.image_config = None
|
||||
|
||||
node = workflow_converter._convert_to_llm_node(
|
||||
original_app_mode=AppMode.CHAT,
|
||||
new_app_mode=AppMode.ADVANCED_CHAT,
|
||||
model_config=model_config,
|
||||
graph=graph,
|
||||
prompt_template=prompt_template,
|
||||
file_upload=file_upload,
|
||||
)
|
||||
|
||||
assert node["data"]["vision"]["variable_selector"] == ["userinput", "files"]
|
||||
|
||||
|
||||
def test__convert_to_llm_node_for_chatbot_simple_chat_model_with_empty_template(
|
||||
default_variables: list[VariableEntity],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -34,6 +34,9 @@ from services.workflow_draft_variable_service import (
|
||||
_model_to_insertion_dict,
|
||||
)
|
||||
|
||||
# TODO: Remove this compatibility key after the historical workflow migration is complete.
|
||||
_SYSTEM_FILE_OUTPUT_KEY = ".".join((SYSTEM_VARIABLE_NODE_ID, "files"))
|
||||
|
||||
SQLITE_MODELS = (Workflow, WorkflowDraftVariable, WorkflowDraftVariableFile, WorkflowNodeExecutionModel)
|
||||
pytestmark = [
|
||||
pytest.mark.usefixtures("sqlite_session"),
|
||||
@@ -127,7 +130,7 @@ class TestDraftVariableSaver:
|
||||
assert node_id == c.expected_node_id, fail_msg
|
||||
assert name == c.expected_name, fail_msg
|
||||
|
||||
def test_build_variables_from_start_mapping_rebuilds_system_files(self, sqlite_session: Session):
|
||||
def test_build_variables_from_start_mapping_rebuilds_system_file_variable(self, sqlite_session: Session):
|
||||
mock_user = MagicMock(spec=Account)
|
||||
mock_user.id = str(uuid.uuid4())
|
||||
saver = DraftVariableSaver(
|
||||
@@ -159,7 +162,7 @@ class TestDraftVariableSaver:
|
||||
"services.workflow_draft_variable_service.build_file_from_stored_mapping",
|
||||
return_value=rebuilt_file,
|
||||
) as rebuild_file:
|
||||
draft_vars = saver._build_variables_from_start_mapping({"sys.files": [raw_file]})
|
||||
draft_vars = saver._build_variables_from_start_mapping({_SYSTEM_FILE_OUTPUT_KEY: [raw_file]})
|
||||
|
||||
sys_var = draft_vars[0]
|
||||
assert sys_var.get_value().value[0] == rebuilt_file
|
||||
@@ -267,7 +270,7 @@ class TestDraftVariableSaver:
|
||||
def test_start_node_save_persists_sys_timestamp_and_workflow_run_id(
|
||||
self, mock_batch_upsert, sqlite_session: Session
|
||||
):
|
||||
"""Start node should persist common `sys.*` variables, not only `sys.files`."""
|
||||
"""Start node should persist common system variables."""
|
||||
mock_user = MagicMock(spec=Account)
|
||||
mock_user.id = "test-user-id"
|
||||
mock_user.tenant_id = "test-tenant-id"
|
||||
@@ -551,7 +554,7 @@ class TestWorkflowDraftVariableService:
|
||||
|
||||
# Create mock execution record
|
||||
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
|
||||
mock_execution.load_full_outputs.return_value = {"sys.files": "[]"}
|
||||
mock_execution.load_full_outputs.return_value = {_SYSTEM_FILE_OUTPUT_KEY: "[]"}
|
||||
|
||||
# Mock the repository to return the execution record
|
||||
service._api_node_execution_repo = Mock()
|
||||
|
||||
@@ -3883,11 +3883,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow-app/hooks/use-workflow-template.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow-app/store/workflow/workflow-slice.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -4217,9 +4212,6 @@
|
||||
"web/app/components/workflow/nodes/_base/components/memory-config.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/next-step/operator.tsx": {
|
||||
@@ -5071,21 +5063,11 @@
|
||||
"count": 8
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/start/panel.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/start/use-config.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/start/use-single-run-form-params.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/template-transform/use-config.ts": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 4
|
||||
|
||||
@@ -15,7 +15,7 @@ export const mockedWorkflowProcess = {
|
||||
predecessor_node_id: null,
|
||||
inputs: {
|
||||
'sys.query': 'hi',
|
||||
'sys.files': [],
|
||||
'userinput.files': [],
|
||||
'sys.conversation_id': '92ce0a3e-8f15-43d1-b31d-32716c4b10a7',
|
||||
'sys.user_id': 'fbff43f9-d5a4-4e85-b63b-d3a91d806c6f',
|
||||
'sys.dialogue_count': 1,
|
||||
@@ -26,7 +26,7 @@ export const mockedWorkflowProcess = {
|
||||
process_data: null,
|
||||
outputs: {
|
||||
'sys.query': 'hi',
|
||||
'sys.files': [],
|
||||
'userinput.files': [],
|
||||
'sys.conversation_id': '92ce0a3e-8f15-43d1-b31d-32716c4b10a7',
|
||||
'sys.user_id': 'fbff43f9-d5a4-4e85-b63b-d3a91d806c6f',
|
||||
'sys.dialogue_count': 1,
|
||||
|
||||
+6
-4
@@ -494,7 +494,7 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
expect(dispatchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles workflow variable selection for nested fields: sys.query, sys.files, and normal paths', async () => {
|
||||
it('handles workflow variable selection for nested fields: built-in inputs and normal paths', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -503,7 +503,7 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
makeWorkflowNodeVar('sys.query', VarType.object, [
|
||||
makeWorkflowNodeVar('q', VarType.string),
|
||||
]),
|
||||
makeWorkflowNodeVar('sys.files', VarType.object, [
|
||||
makeWorkflowNodeVar('userinput.files', VarType.object, [
|
||||
makeWorkflowNodeVar('f', VarType.string),
|
||||
]),
|
||||
makeWorkflowNodeVar('output', VarType.object, [makeWorkflowNodeVar('x', VarType.string)]),
|
||||
@@ -545,8 +545,10 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['sys.query'])
|
||||
await waitFor(() => expect(readEditorText(editor)).not.toContain('{'))
|
||||
|
||||
await openPickerAndSelectField('sys.files', 'f')
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['sys.files'])
|
||||
await openPickerAndSelectField('userinput.files', 'f')
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [
|
||||
'userinput.files',
|
||||
])
|
||||
await waitFor(() => expect(readEditorText(editor)).not.toContain('{'))
|
||||
|
||||
await openPickerAndSelectField('output', 'x')
|
||||
|
||||
@@ -191,6 +191,7 @@ const ComponentPicker = ({
|
||||
if (needRemove) needRemove.remove()
|
||||
})
|
||||
const isFlat = variables.length === 1
|
||||
const builtInVariable = variables[1]
|
||||
if (isFlat) {
|
||||
const varName = variables[0]
|
||||
if (varName === 'current')
|
||||
@@ -198,8 +199,8 @@ const ComponentPicker = ({
|
||||
else if (varName === 'error_message')
|
||||
editor.dispatchCommand(INSERT_ERROR_MESSAGE_BLOCK_COMMAND, null)
|
||||
else if (varName === 'last_run') editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, null)
|
||||
} else if (variables[1] === 'sys.query' || variables[1] === 'sys.files') {
|
||||
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [variables[1]])
|
||||
} else if (builtInVariable && ['sys.query', 'userinput.files'].includes(builtInVariable)) {
|
||||
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [builtInVariable])
|
||||
} else {
|
||||
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables)
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
"id": "a4959eb4-c852-4e0c-ac7a-348233f7f345",
|
||||
"workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -481,7 +481,7 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
"index": 1,
|
||||
"predecessor_node_id": null,
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -492,7 +492,7 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
"process_data": {},
|
||||
"process_data_truncated": false,
|
||||
"outputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -1055,7 +1055,7 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
```streaming {{ title: 'Response' }}
|
||||
event: ping
|
||||
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
|
||||
data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}}
|
||||
|
||||
@@ -1067,7 +1067,7 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
|
||||
data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}}
|
||||
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
|
||||
data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}}
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
"id": "a4959eb4-c852-4e0c-ac7a-348233f7f345",
|
||||
"workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -481,7 +481,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
"index": 1,
|
||||
"predecessor_node_id": null,
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -492,7 +492,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
"process_data": {},
|
||||
"process_data_truncated": false,
|
||||
"outputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -1056,7 +1056,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
```streaming {{ title: '応答' }}
|
||||
event: ping
|
||||
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
|
||||
data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}}
|
||||
|
||||
@@ -1068,7 +1068,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
|
||||
data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}}
|
||||
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
|
||||
data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}}
|
||||
|
||||
|
||||
@@ -436,7 +436,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
"id": "a4959eb4-c852-4e0c-ac7a-348233f7f345",
|
||||
"workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -480,7 +480,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
"index": 1,
|
||||
"predecessor_node_id": null,
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -491,7 +491,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
"process_data": {},
|
||||
"process_data_truncated": false,
|
||||
"outputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -1049,7 +1049,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
```streaming {{ title: 'Response' }}
|
||||
event: ping
|
||||
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
|
||||
data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}}
|
||||
|
||||
@@ -1061,7 +1061,7 @@ import { WorkflowVersionApiUpgradeNotice } from '../workflow-version-api-upgrade
|
||||
|
||||
data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}}
|
||||
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
|
||||
data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}}
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ Workflow applications offers non-session support and is ideal for translation, a
|
||||
"id": "a4959eb4-c852-4e0c-ac7a-348233f7f345",
|
||||
"workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -398,7 +398,7 @@ Workflow applications offers non-session support and is ideal for translation, a
|
||||
"index": 1,
|
||||
"predecessor_node_id": null,
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -409,7 +409,7 @@ Workflow applications offers non-session support and is ideal for translation, a
|
||||
"process_data": {},
|
||||
"process_data_truncated": false,
|
||||
"outputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -942,7 +942,7 @@ Workflow applications offers non-session support and is ideal for translation, a
|
||||
"id": "b1ad3277-089e-42c6-9dff-6820d94fbc76",
|
||||
"workflow_id": "19eff89f-ec03-4f75-b0fc-897e7effea02",
|
||||
"status": "succeeded",
|
||||
"inputs": "{\"sys.files\": [], \"sys.user_id\": \"abc-123\"}",
|
||||
"inputs": "{\"userinput.files\": [], \"sys.user_id\": \"abc-123\"}",
|
||||
"outputs": null,
|
||||
"error": null,
|
||||
"total_steps": 3,
|
||||
@@ -1154,7 +1154,7 @@ Workflow applications offers non-session support and is ideal for translation, a
|
||||
```streaming {{ title: 'Response' }}
|
||||
event: ping
|
||||
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
|
||||
data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}}
|
||||
|
||||
@@ -1166,7 +1166,7 @@ Workflow applications offers non-session support and is ideal for translation, a
|
||||
|
||||
data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}}
|
||||
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
|
||||
data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}}
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../w
|
||||
"id": "a4959eb4-c852-4e0c-ac7a-348233f7f345",
|
||||
"workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -398,7 +398,7 @@ import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../w
|
||||
"index": 1,
|
||||
"predecessor_node_id": null,
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -409,7 +409,7 @@ import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../w
|
||||
"process_data": {},
|
||||
"process_data_truncated": false,
|
||||
"outputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -937,7 +937,7 @@ import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../w
|
||||
"id": "b1ad3277-089e-42c6-9dff-6820d94fbc76",
|
||||
"workflow_id": "19eff89f-ec03-4f75-b0fc-897e7effea02",
|
||||
"status": "succeeded",
|
||||
"inputs": "{\"sys.files\": [], \"sys.user_id\": \"abc-123\"}",
|
||||
"inputs": "{\"userinput.files\": [], \"sys.user_id\": \"abc-123\"}",
|
||||
"outputs": null,
|
||||
"error": null,
|
||||
"total_steps": 3,
|
||||
@@ -1149,7 +1149,7 @@ import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../w
|
||||
```streaming {{ title: '応答' }}
|
||||
event: ping
|
||||
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
|
||||
data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}}
|
||||
|
||||
@@ -1161,7 +1161,7 @@ import { WorkflowVersionApiContent, WorkflowVersionApiUpgradeNotice } from '../w
|
||||
|
||||
data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}}
|
||||
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
|
||||
data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}}
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
|
||||
"id": "a4959eb4-c852-4e0c-ac7a-348233f7f345",
|
||||
"workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -388,7 +388,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
|
||||
"index": 1,
|
||||
"predecessor_node_id": null,
|
||||
"inputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -399,7 +399,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
|
||||
"process_data": {},
|
||||
"process_data_truncated": false,
|
||||
"outputs": {
|
||||
"sys.files": [],
|
||||
"userinput.files": [],
|
||||
"sys.user_id": "abc-123",
|
||||
"sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89",
|
||||
"sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36",
|
||||
@@ -930,7 +930,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
|
||||
"id": "b1ad3277-089e-42c6-9dff-6820d94fbc76",
|
||||
"workflow_id": "19eff89f-ec03-4f75-b0fc-897e7effea02",
|
||||
"status": "succeeded",
|
||||
"inputs": "{\"sys.files\": [], \"sys.user_id\": \"abc-123\"}",
|
||||
"inputs": "{\"userinput.files\": [], \"sys.user_id\": \"abc-123\"}",
|
||||
"outputs": null,
|
||||
"error": null,
|
||||
"total_steps": 3,
|
||||
@@ -1142,7 +1142,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
|
||||
```streaming {{ title: 'Response' }}
|
||||
event: ping
|
||||
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}}
|
||||
|
||||
data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}}
|
||||
|
||||
@@ -1154,7 +1154,7 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
|
||||
|
||||
data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}}
|
||||
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"userinput.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}}
|
||||
|
||||
data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}}
|
||||
|
||||
|
||||
+1
-1
@@ -149,7 +149,7 @@ describe('useCreateSnippetFromSelection', () => {
|
||||
createNode('variable-aggregator', {
|
||||
type: BlockEnum.VariableAggregator,
|
||||
variables: [
|
||||
['sys', 'files'],
|
||||
['userinput', 'files'],
|
||||
['llm', 'text'],
|
||||
],
|
||||
advanced_settings: {
|
||||
|
||||
@@ -115,6 +115,9 @@ describe('useWorkflowTemplate', () => {
|
||||
expect(generateNewNodeCalls[1]!.data).toMatchObject({
|
||||
type: 'llm',
|
||||
title: 'workflow.blocks.llm',
|
||||
memory: {
|
||||
query_prompt_template: '{{#sys.query#}}\n\n{{#userinput.files#}}',
|
||||
},
|
||||
})
|
||||
expect(generateNewNodeCalls[2]!.data).toMatchObject({
|
||||
type: 'answer',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AnswerNodeType } from '@/app/components/workflow/nodes/answer/types'
|
||||
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
@@ -31,37 +33,41 @@ export const useWorkflowTemplate = () => {
|
||||
if (isChatMode) {
|
||||
const startNode = createStartNode()
|
||||
|
||||
const llmData: LLMNodeType = {
|
||||
...(llmDefault.defaultValue as LLMNodeType),
|
||||
desc: '',
|
||||
memory: {
|
||||
window: { enabled: false, size: 10 },
|
||||
query_prompt_template: '{{#sys.query#}}\n\n{{#userinput.files#}}',
|
||||
},
|
||||
selected: true,
|
||||
type: llmDefault.metaData.type,
|
||||
title: t(($) => $[`blocks.${llmDefault.metaData.type}`], { ns: 'workflow' }),
|
||||
}
|
||||
const { newNode: llmNode } = generateNewNode({
|
||||
id: 'llm',
|
||||
data: {
|
||||
...llmDefault.defaultValue,
|
||||
memory: {
|
||||
window: { enabled: false, size: 10 },
|
||||
query_prompt_template: '{{#sys.query#}}\n\n{{#sys.files#}}',
|
||||
},
|
||||
selected: true,
|
||||
type: llmDefault.metaData.type,
|
||||
title: t(($) => $[`blocks.${llmDefault.metaData.type}`], { ns: 'workflow' }),
|
||||
},
|
||||
data: llmData,
|
||||
position: {
|
||||
x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET,
|
||||
y: START_INITIAL_POSITION.y,
|
||||
},
|
||||
} as any)
|
||||
})
|
||||
|
||||
const answerData: AnswerNodeType = {
|
||||
...(answerDefault.defaultValue as AnswerNodeType),
|
||||
answer: `{{#${llmNode.id}.text#}}`,
|
||||
desc: '',
|
||||
type: answerDefault.metaData.type,
|
||||
title: t(($) => $[`blocks.${answerDefault.metaData.type}`], { ns: 'workflow' }),
|
||||
}
|
||||
const { newNode: answerNode } = generateNewNode({
|
||||
id: 'answer',
|
||||
data: {
|
||||
...answerDefault.defaultValue,
|
||||
answer: `{{#${llmNode.id}.text#}}`,
|
||||
type: answerDefault.metaData.type,
|
||||
title: t(($) => $[`blocks.${answerDefault.metaData.type}`], { ns: 'workflow' }),
|
||||
},
|
||||
data: answerData,
|
||||
position: {
|
||||
x: START_INITIAL_POSITION.x + NODE_WIDTH_X_OFFSET * 2,
|
||||
y: START_INITIAL_POSITION.y,
|
||||
},
|
||||
} as any)
|
||||
})
|
||||
|
||||
const startToLlmEdge = {
|
||||
id: `${startNode.id}-${llmNode.id}`,
|
||||
|
||||
@@ -77,7 +77,7 @@ export const getGlobalVars = (isChatMode: boolean): Var[] => {
|
||||
|
||||
export const VAR_SHOW_NAME_MAP: Record<string, string> = {
|
||||
'sys.query': 'query',
|
||||
'sys.files': 'files',
|
||||
'userinput.files': 'files',
|
||||
}
|
||||
|
||||
export const RETRIEVAL_OUTPUT_STRUCT = `{
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('useConfigVision', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose vision capability and enable default chat configs for vision models', () => {
|
||||
it('should use userinput.files by default for vision models in chat mode', () => {
|
||||
const onChange = vi.fn()
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
|
||||
@@ -68,7 +68,7 @@ describe('useConfigVision', () => {
|
||||
enabled: true,
|
||||
configs: {
|
||||
detail: Resolution.high,
|
||||
variable_selector: ['sys', 'files'],
|
||||
variable_selector: ['userinput', 'files'],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -131,7 +131,7 @@ describe('useConfigVision', () => {
|
||||
enabled: true,
|
||||
configs: {
|
||||
detail: Resolution.high,
|
||||
variable_selector: ['sys', 'files'],
|
||||
variable_selector: ['userinput', 'files'],
|
||||
},
|
||||
}),
|
||||
onChange,
|
||||
@@ -149,6 +149,7 @@ describe('useConfigVision', () => {
|
||||
|
||||
it('should reset enabled vision configs when the model changes but still supports vision', () => {
|
||||
const onChange = vi.fn()
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
|
||||
currentModel: {
|
||||
features: [ModelFeatureEnum.vision],
|
||||
@@ -172,6 +173,34 @@ describe('useConfigVision', () => {
|
||||
result.current.handleModelChanged()
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
configs: {
|
||||
detail: Resolution.high,
|
||||
variable_selector: ['userinput', 'files'],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should require an explicit file variable in workflow mode', () => {
|
||||
const onChange = vi.fn()
|
||||
mockUseTextGenerationCurrentProviderAndModelAndModelList.mockReturnValue({
|
||||
currentModel: {
|
||||
features: [ModelFeatureEnum.vision],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConfigVision(createModel(), {
|
||||
payload: createVisionPayload(),
|
||||
onChange,
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleVisionResolutionEnabledChange(true)
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
configs: {
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('useInspectVarsCrud', () => {
|
||||
createInspectVar({
|
||||
id: 'files-var',
|
||||
name: 'files',
|
||||
// TODO: Remove this legacy API fixture when the system-variable endpoint returns userinput.files.
|
||||
selector: ['sys', 'files'],
|
||||
}),
|
||||
createInspectVar({
|
||||
@@ -134,6 +135,10 @@ describe('useInspectVarsCrud', () => {
|
||||
'query',
|
||||
'files',
|
||||
])
|
||||
expect(result.current.nodesWithInspectVars[0]?.vars.at(-1)?.selector).toEqual([
|
||||
'userinput',
|
||||
'files',
|
||||
])
|
||||
expect(result.current.hasNodeInspectVars).toBe(hasNodeInspectVars)
|
||||
expect(result.current.fetchInspectVarValue).toBe(fetchInspectVarValue)
|
||||
expect(result.current.deleteAllInspectorVars).toBe(deleteAllInspectorVars)
|
||||
|
||||
@@ -84,7 +84,7 @@ const outputVarsWithSystemVars: NodeOutPutVar[] = [
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'sys.files',
|
||||
variable: 'userinput.files',
|
||||
type: VarType.arrayFile,
|
||||
},
|
||||
] satisfies Var[],
|
||||
|
||||
@@ -41,10 +41,10 @@ const useConfigVision = (
|
||||
(enabled: boolean) => {
|
||||
const newPayload = produce(payload, (draft) => {
|
||||
draft.enabled = enabled
|
||||
if (enabled && isChatMode) {
|
||||
if (enabled) {
|
||||
draft.configs = {
|
||||
detail: Resolution.high,
|
||||
variable_selector: ['sys', 'files'],
|
||||
variable_selector: isChatMode ? ['userinput', 'files'] : [],
|
||||
}
|
||||
} else if (!enabled) {
|
||||
delete draft.configs
|
||||
@@ -76,11 +76,11 @@ const useConfigVision = (
|
||||
enabled: true,
|
||||
configs: {
|
||||
detail: Resolution.high,
|
||||
variable_selector: [],
|
||||
variable_selector: isChatMode ? ['userinput', 'files'] : [],
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [getIsVisionModel, handleVisionResolutionEnabledChange, onChange, payload.enabled])
|
||||
}, [getIsVisionModel, handleVisionResolutionEnabledChange, isChatMode, onChange, payload.enabled])
|
||||
|
||||
return {
|
||||
isVisionModel,
|
||||
|
||||
@@ -17,7 +17,14 @@ const useInspectVarsCrud = () => {
|
||||
const { varsAppendStartNode, systemVars } = (() => {
|
||||
if (allSystemVars?.length === 0) return { varsAppendStartNode: [], systemVars: [] }
|
||||
const varsAppendStartNode =
|
||||
allSystemVars?.filter(({ name }) => varsAppendStartNodeKeys.includes(name)) || []
|
||||
allSystemVars
|
||||
?.filter(({ name }) => varsAppendStartNodeKeys.includes(name))
|
||||
.map((variable) => {
|
||||
if (variable.name !== 'files') return variable
|
||||
|
||||
// TODO: Remove this normalization after the system-variable API stops returning the legacy file selector.
|
||||
return { ...variable, selector: ['userinput', 'files'] }
|
||||
}) || []
|
||||
const systemVars =
|
||||
allSystemVars?.filter(({ name }) => !varsAppendStartNodeKeys.includes(name)) || []
|
||||
return { varsAppendStartNode, systemVars }
|
||||
|
||||
@@ -55,7 +55,7 @@ type Props = Readonly<{
|
||||
|
||||
const MEMORY_DEFAULT: Memory = {
|
||||
window: { enabled: false, size: WINDOW_SIZE_DEFAULT },
|
||||
query_prompt_template: '{{#sys.query#}}\n\n{{#sys.files#}}',
|
||||
query_prompt_template: '{{#sys.query#}}\n\n{{#userinput.files#}}',
|
||||
}
|
||||
|
||||
const MemoryConfig: FC<Props> = ({
|
||||
@@ -97,7 +97,7 @@ const MemoryConfig: FC<Props> = ({
|
||||
limitedSize = null
|
||||
} else {
|
||||
limitedSize = Number.parseInt(limitedSize as string, 10)
|
||||
if (isNaN(limitedSize)) limitedSize = WINDOW_SIZE_DEFAULT
|
||||
if (Number.isNaN(limitedSize)) limitedSize = WINDOW_SIZE_DEFAULT
|
||||
|
||||
if (limitedSize < WINDOW_SIZE_MIN) limitedSize = WINDOW_SIZE_MIN
|
||||
|
||||
|
||||
+63
-1
@@ -4,6 +4,7 @@ import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-i
|
||||
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { Node, PromptItem } from '@/app/components/workflow/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createStartNode } from '@/app/components/workflow/__tests__/fixtures'
|
||||
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import {
|
||||
BlockEnum,
|
||||
@@ -13,7 +14,14 @@ import {
|
||||
VarType,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getNodeUsedVars, toNodeAvailableVars, updateNodeVars } from '../utils'
|
||||
import {
|
||||
getNodeOutputVars,
|
||||
getNodeUsedVars,
|
||||
isGlobalVar,
|
||||
isSystemVar,
|
||||
toNodeAvailableVars,
|
||||
updateNodeVars,
|
||||
} from '../utils'
|
||||
|
||||
const createNode = <T>(data: Node<T>['data']): Node<T> => ({
|
||||
id: 'node-1',
|
||||
@@ -49,6 +57,15 @@ const createLLMNodeData = (promptTemplate: PromptItem[]): LLMNodeType => ({
|
||||
})
|
||||
|
||||
describe('variable utils', () => {
|
||||
describe('virtual input variables', () => {
|
||||
it('recognizes userinput.files without treating it as a global variable', () => {
|
||||
expect(isSystemVar(['userinput', 'files'])).toBe(true)
|
||||
expect(isSystemVar(['start', 'userinput', 'files'])).toBe(true)
|
||||
expect(isGlobalVar(['start', 'userinput', 'files'])).toBe(false)
|
||||
expect(isGlobalVar(['sys', 'workflow_id'])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toNodeAvailableVars', () => {
|
||||
it('uses Agent v2 default declared outputs for agent nodes', () => {
|
||||
const node = createNode<AgentV2NodeType>({
|
||||
@@ -213,6 +230,51 @@ describe('variable utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node output variables', () => {
|
||||
it('should expose sys.query and userinput.files for start nodes in chat mode', () => {
|
||||
const startNode = createStartNode({
|
||||
id: 'start',
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
variables: [
|
||||
{
|
||||
label: 'Files',
|
||||
variable: 'files',
|
||||
type: InputVarType.multiFiles,
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(getNodeOutputVars(startNode, true)).toEqual([
|
||||
['start', 'files'],
|
||||
['start', 'sys', 'query'],
|
||||
['start', 'userinput', 'files'],
|
||||
])
|
||||
|
||||
const availableVars = toNodeAvailableVars({
|
||||
beforeNodes: [startNode],
|
||||
isChatMode: true,
|
||||
filterVar: () => true,
|
||||
allPluginInfoList: {},
|
||||
})
|
||||
|
||||
expect(availableVars).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
nodeId: 'start',
|
||||
vars: expect.arrayContaining([
|
||||
expect.objectContaining({ variable: 'files', type: VarType.arrayFile }),
|
||||
expect.objectContaining({ variable: 'sys.query', type: VarType.string }),
|
||||
expect.objectContaining({ variable: 'userinput.files', type: VarType.arrayFile }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateNodeVars', () => {
|
||||
it('should replace answer prompt references', () => {
|
||||
const node = createNode<AnswerNodeType>({
|
||||
|
||||
+3
-3
@@ -80,9 +80,9 @@ describe('var-reference-picker.helpers', () => {
|
||||
isLoopVar: false,
|
||||
iterationNode: null,
|
||||
loopNode: null,
|
||||
outputVarNodeId: 'sys',
|
||||
outputVarNodeId: 'userinput',
|
||||
startNode,
|
||||
value: ['sys', 'files'],
|
||||
value: ['userinput', 'files'],
|
||||
}),
|
||||
).toEqual(startNode.data)
|
||||
|
||||
@@ -287,7 +287,7 @@ describe('var-reference-picker.helpers', () => {
|
||||
})
|
||||
|
||||
it('should keep mapped variable names for known workflow aliases', () => {
|
||||
expect(getVarDisplayName(true, ['sys', 'files'])).toBe('files')
|
||||
expect(getVarDisplayName(true, ['userinput', 'files'])).toBe('files')
|
||||
expect(
|
||||
getVariableMeta({ type: VarType.string }, ['conversation', 'name'], 'name'),
|
||||
).toMatchObject({
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
describe('var-reference-vars helpers', () => {
|
||||
it('should derive display names for flat and mapped variables', () => {
|
||||
expect(getVariableDisplayName('sys.files', false)).toBe('files')
|
||||
expect(getVariableDisplayName('userinput.files', false)).toBe('files')
|
||||
expect(getVariableDisplayName('current', true, true)).toBe('current_code')
|
||||
expect(getVariableDisplayName('foo', true, false)).toBe('foo')
|
||||
})
|
||||
|
||||
+26
@@ -106,6 +106,32 @@ describe('VarReferenceVars', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve userinput.files as a virtual input selector', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
vars={createVars([
|
||||
{
|
||||
title: 'Start',
|
||||
nodeId: 'start-node',
|
||||
vars: [{ variable: 'userinput.files', type: VarType.arrayFile }],
|
||||
},
|
||||
])}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('files'))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
['userinput', 'files'],
|
||||
expect.objectContaining({
|
||||
variable: 'userinput.files',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render empty state and manage input action', () => {
|
||||
const onManageInputField = vi.fn()
|
||||
|
||||
|
||||
@@ -84,14 +84,15 @@ const translateWorkflowString = <const Selector extends SelectorParam<'workflow'
|
||||
}
|
||||
|
||||
export const isSystemVar = (valueSelector: ValueSelector) => {
|
||||
return valueSelector[0] === 'sys' || valueSelector[1] === 'sys'
|
||||
return valueSelector.some((part) => ['sys', 'userinput'].includes(part))
|
||||
}
|
||||
|
||||
export const isGlobalVar = (valueSelector: ValueSelector) => {
|
||||
if (!isSystemVar(valueSelector)) return false
|
||||
const second = valueSelector[1]
|
||||
const namespaceIndex = valueSelector.findIndex((part) => ['sys', 'userinput'].includes(part))
|
||||
const variableName = valueSelector[namespaceIndex + 1]
|
||||
|
||||
if (['query', 'files'].includes(second!)) return false
|
||||
if (variableName && ['query', 'files'].includes(variableName)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -109,7 +110,7 @@ export const isRagVariableVar = (valueSelector: ValueSelector) => {
|
||||
}
|
||||
|
||||
export const isSpecialVar = (prefix: string): boolean => {
|
||||
return ['sys', 'env', 'conversation', 'rag'].includes(prefix)
|
||||
return ['sys', 'userinput', 'env', 'conversation', 'rag'].includes(prefix)
|
||||
}
|
||||
|
||||
const hasValidChildren = (children: any): boolean => {
|
||||
@@ -358,7 +359,7 @@ const formatItem = (
|
||||
})
|
||||
}
|
||||
res.vars.push({
|
||||
variable: 'sys.files',
|
||||
variable: 'userinput.files',
|
||||
type: VarType.arrayFile,
|
||||
})
|
||||
break
|
||||
@@ -1963,8 +1964,8 @@ export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelecto
|
||||
|
||||
if (isChatMode) {
|
||||
res.push([id, 'sys', 'query'])
|
||||
res.push([id, 'sys', 'files'])
|
||||
}
|
||||
res.push([id, 'userinput', 'files'])
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ const resolveValueSelector = ({
|
||||
const isStructureOutput =
|
||||
itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
|
||||
const isFile = itemData.type === VarType.file && !isStructureOutput
|
||||
const isSys = itemData.variable.startsWith('sys.')
|
||||
const isSys = itemData.variable.startsWith('sys.') || itemData.variable.startsWith('userinput.')
|
||||
const isEnv = itemData.variable.startsWith('env.')
|
||||
const isChatVar = itemData.variable.startsWith('conversation.')
|
||||
const isRagVariable = itemData.isRagVariable
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ const outputVarsWithSystemVars: NodeOutPutVar[] = [
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'sys.files',
|
||||
variable: 'userinput.files',
|
||||
type: VarType.arrayFile,
|
||||
},
|
||||
] satisfies Var[],
|
||||
|
||||
@@ -22,7 +22,10 @@ export const filterSnippetSystemVars = (
|
||||
return availableVars
|
||||
.map((nodeVar) => ({
|
||||
...nodeVar,
|
||||
vars: nodeVar.vars.filter((variable) => !variable.variable.startsWith('sys.')),
|
||||
vars: nodeVar.vars.filter(
|
||||
(variable) =>
|
||||
!['sys.', 'userinput.'].some((prefix) => variable.variable.startsWith(prefix)),
|
||||
),
|
||||
}))
|
||||
.filter((nodeVar) => nodeVar.vars.length > 0)
|
||||
}
|
||||
|
||||
@@ -779,7 +779,7 @@ const useOneStepRun = <T>({
|
||||
const isStartNode = data.type === BlockEnum.Start
|
||||
const postData: Record<string, any> = {}
|
||||
if (isStartNode) {
|
||||
const { '#sys.query#': query, '#sys.files#': files, ...inputs } = submitData
|
||||
const { '#sys.query#': query, '#userinput.files#': files, ...inputs } = submitData
|
||||
if (isChatMode) postData.conversation_id = ''
|
||||
|
||||
postData.inputs = inputs
|
||||
|
||||
@@ -227,9 +227,9 @@ describe('list-operator path', () => {
|
||||
expect(screen.getByText('items')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should resolve system variables through the start node and return null without a variable', () => {
|
||||
it('should resolve built-in variables through the start node and return null without a variable', () => {
|
||||
const { rerender } = renderWorkflowFlowComponent(
|
||||
<Node id="node-2" data={createData({ variable: ['sys', 'files'] as any })} />,
|
||||
<Node id="node-2" data={createData({ variable: ['userinput', 'files'] as any })} />,
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
|
||||
@@ -96,12 +96,12 @@ describe('list-operator/node', () => {
|
||||
<Node
|
||||
id="list-node"
|
||||
data={createData({
|
||||
variable: ['sys', 'files'],
|
||||
variable: ['userinput', 'files'],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Start:start:sys.files')).toBeInTheDocument()
|
||||
expect(screen.getByText('Start:start:userinput.files')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns null when no input variable is configured', () => {
|
||||
|
||||
@@ -242,7 +242,7 @@ describe('llm/use-config', () => {
|
||||
enabled: true,
|
||||
configs: {
|
||||
detail: Resolution.high,
|
||||
variable_selector: ['sys', 'files'],
|
||||
variable_selector: ['userinput', 'files'],
|
||||
},
|
||||
})
|
||||
result.current.handleCompletionParamsChange({ top_p: 0.5 })
|
||||
@@ -260,7 +260,7 @@ describe('llm/use-config', () => {
|
||||
enabled: true,
|
||||
configs: {
|
||||
detail: Resolution.high,
|
||||
variable_selector: ['sys', 'files'],
|
||||
variable_selector: ['userinput', 'files'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -78,7 +78,7 @@ const createData = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({
|
||||
enabled: false,
|
||||
size: 50,
|
||||
},
|
||||
query_prompt_template: '{{#sys.query#}}\n{{#sys.files#}}',
|
||||
query_prompt_template: '{{#sys.query#}}\n{{#userinput.files#}}',
|
||||
},
|
||||
context: {
|
||||
enabled: false,
|
||||
@@ -121,7 +121,7 @@ describe('llm/use-single-run-form-params', () => {
|
||||
const getInputVars = vi.fn(() => [
|
||||
createInputVar('#start.query#'),
|
||||
createInputVar('#sys.query#'),
|
||||
createInputVar('#sys.files#'),
|
||||
createInputVar('#userinput.files#'),
|
||||
])
|
||||
const toVarInputs = vi.fn((_variables: Variable[]) => [
|
||||
createInputVar('#sys.workflow_id#'),
|
||||
|
||||
+5
@@ -94,6 +94,11 @@ describe('llm/panel-memory-section', () => {
|
||||
|
||||
expect(screen.getByText('workflow.nodes.common.memories.title')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('editor')).toHaveTextContent('{{#sys.query#}}')
|
||||
expect(mockEditor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: '{{#sys.query#}}\n\n{{#userinput.files#}}',
|
||||
}),
|
||||
)
|
||||
expect(screen.getByTestId('memory-config')).toHaveTextContent('cannot-set-role')
|
||||
expect(mockEditor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -32,7 +32,7 @@ const DEFAULT_MEMORY: Memory = {
|
||||
enabled: false,
|
||||
size: 50,
|
||||
},
|
||||
query_prompt_template: '{{#sys.query#}}\n\n{{#sys.files#}}',
|
||||
query_prompt_template: '{{#sys.query#}}\n\n{{#userinput.files#}}',
|
||||
}
|
||||
const SNIPPET_DEFAULT_MEMORY: Memory = {
|
||||
window: {
|
||||
|
||||
@@ -17,8 +17,12 @@ import useNodeCrud from '../_base/hooks/use-node-crud'
|
||||
import { findVariableWhenOnLLMVision } from '../utils'
|
||||
|
||||
const i18nPrefix = 'nodes.llm'
|
||||
const isSystemInputVar = (item: InputVar) => {
|
||||
return typeof item.variable === 'string' && item.variable.startsWith('#sys.')
|
||||
const isBuiltInInputVar = (item: InputVar) => {
|
||||
const { variable } = item
|
||||
return (
|
||||
typeof variable === 'string' &&
|
||||
['#sys.', '#userinput.'].some((prefix) => variable.startsWith(prefix))
|
||||
)
|
||||
}
|
||||
|
||||
type Params = {
|
||||
@@ -119,11 +123,11 @@ const useSingleRunFormParams = ({
|
||||
})()
|
||||
const varInputs = (() => {
|
||||
const vars = getVarInputs(allVarStrArr) || []
|
||||
const filteredVars = isSnippetFlow ? vars.filter((item) => !isSystemInputVar(item)) : vars
|
||||
const filteredVars = isSnippetFlow ? vars.filter((item) => !isBuiltInInputVar(item)) : vars
|
||||
if (isShowVars) {
|
||||
const jinjaVars = toVarInputs ? toVarInputs(inputs.prompt_config?.jinja2_variables || []) : []
|
||||
return isSnippetFlow
|
||||
? [...filteredVars, ...jinjaVars.filter((item) => !isSystemInputVar(item))]
|
||||
? [...filteredVars, ...jinjaVars.filter((item) => !isBuiltInInputVar(item))]
|
||||
: [...filteredVars, ...jinjaVars]
|
||||
}
|
||||
|
||||
|
||||
+18
-15
@@ -116,7 +116,6 @@ describe('use-single-run-form-params helpers', () => {
|
||||
|
||||
it('should build used output vars and pass-to-server keys while filtering loop-local selectors', () => {
|
||||
const startNode = createNode('start-node', 'Start Node', BlockEnum.Start)
|
||||
const sysNode = createNode('sys', 'System', BlockEnum.Start)
|
||||
const loopChildrenNodes = [
|
||||
createNode('tool-a', 'Tool A'),
|
||||
createNode('tool-b', 'Tool B'),
|
||||
@@ -127,7 +126,7 @@ describe('use-single-run-form-params helpers', () => {
|
||||
mockGetNodeUsedVars.mockImplementation((node: Node) => {
|
||||
switch (node.id) {
|
||||
case 'tool-a':
|
||||
return [['sys', 'files']]
|
||||
return [['userinput', 'files']]
|
||||
case 'tool-b':
|
||||
return [
|
||||
['start-node', 'answer'],
|
||||
@@ -139,12 +138,16 @@ describe('use-single-run-form-params helpers', () => {
|
||||
}
|
||||
})
|
||||
mockGetNodeUsedVarPassToServerKey.mockImplementation((_node: Node, selector: string[]) => {
|
||||
return selector[0] === 'sys' ? ['sys_files', 'sys_files_backup'] : 'answer_key'
|
||||
return selector[0] === 'userinput'
|
||||
? ['userinput_files', 'userinput_files_backup']
|
||||
: 'answer_key'
|
||||
})
|
||||
mockGetNodeInfoById.mockImplementation((nodes: Node[], id: string) =>
|
||||
nodes.find((node) => node.id === id),
|
||||
)
|
||||
mockIsSystemVar.mockImplementation((selector: string[]) => selector[0] === 'sys')
|
||||
mockIsSystemVar.mockImplementation((selector: string[]) =>
|
||||
['sys', 'userinput'].includes(selector[0]!),
|
||||
)
|
||||
|
||||
const toVarInputs = vi.fn((variables: Variable[]) =>
|
||||
variables.map((variable) =>
|
||||
@@ -155,18 +158,18 @@ describe('use-single-run-form-params helpers', () => {
|
||||
const result = buildUsedOutVars({
|
||||
loopChildrenNodes,
|
||||
currentNodeId: 'current-node',
|
||||
canChooseVarNodes: [startNode, sysNode, ...loopChildrenNodes],
|
||||
canChooseVarNodes: [startNode, ...loopChildrenNodes],
|
||||
isNodeInLoop: (nodeId) => nodeId === 'inner-node',
|
||||
toVarInputs,
|
||||
})
|
||||
|
||||
expect(toVarInputs).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
variable: 'sys.files',
|
||||
variable: 'userinput.files',
|
||||
label: {
|
||||
nodeType: BlockEnum.Start,
|
||||
nodeName: 'System',
|
||||
variable: 'sys.files',
|
||||
nodeName: 'Start Node',
|
||||
variable: 'userinput.files',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -179,10 +182,10 @@ describe('use-single-run-form-params helpers', () => {
|
||||
}),
|
||||
])
|
||||
expect(result.usedOutVars).toEqual([
|
||||
createInputVar('sys.files', {
|
||||
createInputVar('userinput.files', {
|
||||
nodeType: BlockEnum.Start,
|
||||
nodeName: 'System',
|
||||
variable: 'sys.files',
|
||||
nodeName: 'Start Node',
|
||||
variable: 'userinput.files',
|
||||
}),
|
||||
createInputVar('start-node.answer', {
|
||||
nodeType: BlockEnum.Start,
|
||||
@@ -191,11 +194,11 @@ describe('use-single-run-form-params helpers', () => {
|
||||
}),
|
||||
])
|
||||
expect(result.allVarObject).toEqual({
|
||||
[['sys.files', 'tool-a', 0].join(VALUE_SELECTOR_DELIMITER)]: {
|
||||
inSingleRunPassedKey: 'sys_files',
|
||||
[['userinput.files', 'tool-a', 0].join(VALUE_SELECTOR_DELIMITER)]: {
|
||||
inSingleRunPassedKey: 'userinput_files',
|
||||
},
|
||||
[['sys.files', 'tool-a', 1].join(VALUE_SELECTOR_DELIMITER)]: {
|
||||
inSingleRunPassedKey: 'sys_files_backup',
|
||||
[['userinput.files', 'tool-a', 1].join(VALUE_SELECTOR_DELIMITER)]: {
|
||||
inSingleRunPassedKey: 'userinput_files_backup',
|
||||
},
|
||||
[['start-node.answer', 'tool-b', 0].join(VALUE_SELECTOR_DELIMITER)]: {
|
||||
inSingleRunPassedKey: 'answer_key',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InputVar, Node, ValueSelector, Variable } from '../../types'
|
||||
import type { CaseItem, Condition, LoopVariable } from './types'
|
||||
import { ValueType } from '@/app/components/workflow/types'
|
||||
import { BlockEnum, ValueType } from '@/app/components/workflow/types'
|
||||
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
|
||||
import {
|
||||
getNodeInfoById,
|
||||
@@ -91,7 +91,10 @@ export const buildUsedOutVars = ({
|
||||
|
||||
const usedOutVars = toVarInputs(
|
||||
vars.map((valueSelector) => {
|
||||
const varInfo = getNodeInfoById(canChooseVarNodes, valueSelector[0]!)
|
||||
const varInfo =
|
||||
valueSelector[0] === 'userinput'
|
||||
? canChooseVarNodes.find((node) => node.data.type === BlockEnum.Start)
|
||||
: getNodeInfoById(canChooseVarNodes, valueSelector[0]!)
|
||||
return {
|
||||
label: {
|
||||
nodeType: varInfo?.data.type,
|
||||
|
||||
@@ -8,6 +8,7 @@ import Panel from '../panel'
|
||||
const mockUseConfig = vi.hoisted(() => vi.fn())
|
||||
const mockConfigVarModal = vi.hoisted(() => vi.fn())
|
||||
const mockRemoveEffectVarConfirm = vi.hoisted(() => vi.fn())
|
||||
const legacyFilesVariable = ['userinput', 'files'].join('.')
|
||||
|
||||
vi.mock('../use-config', () => ({
|
||||
__esModule: true,
|
||||
@@ -86,7 +87,7 @@ describe('StartPanel', () => {
|
||||
render(<Panel id="start-node" data={createData()} panelProps={{} as PanelProps} />)
|
||||
|
||||
expect(screen.getByText('userinput.query')).toBeInTheDocument()
|
||||
expect(screen.getByText('userinput.files')).toBeInTheDocument()
|
||||
expect(screen.getByText(legacyFilesVariable)).toBeInTheDocument()
|
||||
expect(screen.queryByText('LEGACY')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
@@ -114,6 +115,7 @@ describe('StartPanel', () => {
|
||||
render(<Panel id="start-node" data={createData()} panelProps={{} as PanelProps} />)
|
||||
|
||||
expect(screen.queryByText('userinput.query')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(legacyFilesVariable)).toBeInTheDocument()
|
||||
expect(screen.getByText('LEGACY')).toBeInTheDocument()
|
||||
expect(screen.getByText('remove-confirm')).toBeInTheDocument()
|
||||
|
||||
|
||||
+10
-5
@@ -45,7 +45,7 @@ describe('start/use-single-run-form-params', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should include sys.query and sys.files dependencies for chat mode', () => {
|
||||
it('should include sys.query and userinput.files for chat mode', () => {
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -66,7 +66,7 @@ describe('start/use-single-run-form-params', () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ variable: 'query' }),
|
||||
expect.objectContaining({ variable: '#sys.query#', required: true }),
|
||||
expect.objectContaining({ variable: '#sys.files#', required: false }),
|
||||
expect.objectContaining({ variable: '#userinput.files#', required: false }),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -75,13 +75,13 @@ describe('start/use-single-run-form-params', () => {
|
||||
expect(setRunInputData).toHaveBeenCalledWith({ query: 'updated' })
|
||||
expect(result.current.getDependentVars()).toEqual([
|
||||
['start-node', 'query'],
|
||||
['sys', 'files'],
|
||||
['userinput', 'files'],
|
||||
['sys', 'query'],
|
||||
])
|
||||
expect(result.current.getDependentVar('query')).toEqual(['start-node', 'query'])
|
||||
})
|
||||
|
||||
it('should omit sys.query when the workflow is not in chat mode', () => {
|
||||
it('should keep userinput.files but omit sys.query outside chat mode', () => {
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -99,9 +99,14 @@ describe('start/use-single-run-form-params', () => {
|
||||
expect(result.current.forms[0]!.inputs).toEqual(
|
||||
expect.not.arrayContaining([expect.objectContaining({ variable: '#sys.query#' })]),
|
||||
)
|
||||
expect(result.current.forms[0]!.inputs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ variable: '#userinput.files#', required: false }),
|
||||
]),
|
||||
)
|
||||
expect(result.current.getDependentVars()).toEqual([
|
||||
['start-node', 'query'],
|
||||
['sys', 'files'],
|
||||
['userinput', 'files'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,25 @@ import { useTranslation } from 'react-i18next'
|
||||
import ConfigVarModal from '@/app/components/app/configuration/config-var/config-modal'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import RemoveEffectVarConfirm from '../_base/components/remove-effect-var-confirm'
|
||||
import VarItem from './components/var-item'
|
||||
import VarList from './components/var-list'
|
||||
import useConfig from './use-config'
|
||||
|
||||
const i18nPrefix = 'nodes.start'
|
||||
const chatQueryInputVar: InputVar = {
|
||||
variable: 'userinput.query',
|
||||
label: '',
|
||||
type: InputVarType.textInput,
|
||||
required: false,
|
||||
}
|
||||
const userInputFilesVar: InputVar = {
|
||||
variable: 'userinput.files',
|
||||
label: '',
|
||||
type: InputVarType.multiFiles,
|
||||
required: false,
|
||||
}
|
||||
|
||||
const Panel: FC<NodePanelProps<StartNodeType>> = ({ id, data }) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -65,11 +78,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ id, data }) => {
|
||||
{isChatMode && (
|
||||
<VarItem
|
||||
readonly
|
||||
payload={
|
||||
{
|
||||
variable: 'userinput.query',
|
||||
} as any
|
||||
}
|
||||
payload={chatQueryInputVar}
|
||||
rightContent={
|
||||
<div className="text-xs font-normal text-text-tertiary">String</div>
|
||||
}
|
||||
@@ -79,11 +88,7 @@ const Panel: FC<NodePanelProps<StartNodeType>> = ({ id, data }) => {
|
||||
<VarItem
|
||||
readonly
|
||||
showLegacyBadge={!isChatMode}
|
||||
payload={
|
||||
{
|
||||
variable: 'userinput.files',
|
||||
} as any
|
||||
}
|
||||
payload={userInputFilesVar}
|
||||
rightContent={
|
||||
<div className="text-xs font-normal text-text-tertiary">Array[File]</div>
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ import { useIsChatMode } from '../../hooks/use-workflow'
|
||||
type Params = {
|
||||
id: string
|
||||
payload: StartNodeType
|
||||
runInputData: Record<string, any>
|
||||
runInputDataRef: RefObject<Record<string, any>>
|
||||
runInputData: FormProps['values']
|
||||
runInputDataRef: RefObject<FormProps['values']>
|
||||
getInputVars: (textList: string[]) => InputVar[]
|
||||
setRunInputData: (data: Record<string, any>) => void
|
||||
setRunInputData: FormProps['onChange']
|
||||
toVarInputs: (variables: Variable[]) => InputVar[]
|
||||
}
|
||||
const useSingleRunFormParams = ({ id, payload, runInputData, setRunInputData }: Params) => {
|
||||
@@ -38,8 +38,8 @@ const useSingleRunFormParams = ({ id, payload, runInputData, setRunInputData }:
|
||||
}
|
||||
|
||||
inputs.push({
|
||||
label: 'sys.files',
|
||||
variable: '#sys.files#',
|
||||
label: 'userinput.files',
|
||||
variable: '#userinput.files#',
|
||||
type: InputVarType.multiFiles,
|
||||
required: false,
|
||||
})
|
||||
@@ -58,7 +58,7 @@ const useSingleRunFormParams = ({ id, payload, runInputData, setRunInputData }:
|
||||
const inputVars = payload.variables.map((item) => {
|
||||
return [id, item.variable]
|
||||
})
|
||||
const vars: ValueSelector[] = [...inputVars, ['sys', 'files']]
|
||||
const vars: ValueSelector[] = [...inputVars, ['userinput', 'files']]
|
||||
|
||||
if (isChatMode) vars.push(['sys', 'query'])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user