Compare commits

...
Author SHA1 Message Date
autofix-ci[bot]andGitHub 756ce8a10e [autofix.ci] apply automated fixes 2026-07-28 05:33:30 +00:00
-LAN- e07de297bf fix(workflow): migrate sys files to user input 2026-07-28 13:29:09 +08:00
87 changed files with 1907 additions and 343 deletions
+5 -6
View File
@@ -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"""
+45
View File
@@ -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")
+173
View File
@@ -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.")
+64
View File
@@ -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.
+7 -4
View File
@@ -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:
+218
View File
@@ -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:")
+7
View File
@@ -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
View File
@@ -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"
+2
View File
@@ -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
View File
@@ -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"))
+12 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
+55 -2
View File
@@ -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
View File
@@ -61,7 +61,7 @@ workflow:
query_prompt_template: '{{#sys.query#}}
{{#sys.files#}}'
{{#userinput.files#}}'
window:
enabled: false
size: 10
@@ -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
View File
@@ -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")
@@ -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()
-18
View File
@@ -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,
@@ -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}}
@@ -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}`,
+1 -1
View File
@@ -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
@@ -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>({
@@ -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({
@@ -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')
})
@@ -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
@@ -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#'),
@@ -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]
}
@@ -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()
@@ -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'])