Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b9e03c2c1 | ||
|
|
b34eb8f24b | ||
|
|
625cfad888 | ||
|
|
3859367e3a | ||
|
|
92c0974e19 | ||
|
|
f7d859afac | ||
|
|
b522f7df40 | ||
|
|
1787145f2a | ||
|
|
f581dfc016 | ||
|
|
26291a0f42 | ||
|
|
80b9f5e083 | ||
|
|
bbebeec3dc | ||
|
|
6a69ec2a8a | ||
|
|
f874a6a019 | ||
|
|
bcdd52a27b | ||
|
|
32e9de8ae3 | ||
|
|
35c9e3e3d3 | ||
|
|
d37783c895 | ||
|
|
cceeabcb46 | ||
|
|
8536c232ba | ||
|
|
8383571c53 | ||
|
|
953b42ac67 | ||
|
|
4e069c1f23 | ||
|
|
09b5d63c39 | ||
|
|
8f2cd4e5b0 | ||
|
|
b57e5da995 | ||
|
|
daf13834a6 | ||
|
|
309777f8fc | ||
|
|
184670f49d | ||
|
|
63aa8af5c1 | ||
|
|
ee30e59871 | ||
|
|
0199589c3b |
+69
-217
@@ -1,52 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import click
|
||||
from sqlalchemy import select
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from models import TenantAccountJoin, TenantAccountRole
|
||||
from services.enterprise.rbac_service import ListOption, RBACService
|
||||
|
||||
_LEGACY_ROLE_TO_BUILTIN_TAG = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
TenantAccountRole.ADMIN.value: "admin",
|
||||
TenantAccountRole.EDITOR.value: "editor",
|
||||
TenantAccountRole.NORMAL.value: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_builtin_role_ids(tenant_id: str, operator_account_id: str) -> dict[str, str]:
|
||||
"""Resolve every legacy workspace role to the current tenant's builtin RBAC role id.
|
||||
|
||||
The migration replays the old `TenantAccountJoin.role` values onto the
|
||||
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
|
||||
identified by runtime ids, so the command must look them up per tenant.
|
||||
"""
|
||||
roles = RBACService.Roles.list(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
options=ListOption(page_number=1, results_per_page=100),
|
||||
).data
|
||||
role_id_by_tag = {
|
||||
role.role_tag: role.id
|
||||
for role in roles
|
||||
if role.is_builtin and role.category == "global_system_default" and role.role_tag
|
||||
}
|
||||
resolved: dict[str, str] = {}
|
||||
for legacy_role, expected_builtin_tag in _LEGACY_ROLE_TO_BUILTIN_TAG.items():
|
||||
role_id = role_id_by_tag.get(expected_builtin_tag)
|
||||
if expected_builtin_tag == "dataset_operator" and not dify_config.DATASET_OPERATOR_ENABLED:
|
||||
continue
|
||||
if not role_id:
|
||||
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
|
||||
resolved[legacy_role] = role_id
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_role: str) -> str:
|
||||
"""Resolve a legacy workspace role to the current tenant's builtin RBAC role id.
|
||||
@@ -55,86 +15,26 @@ def _resolve_builtin_role_id(tenant_id: str, operator_account_id: str, legacy_ro
|
||||
RBAC member-role binding API. Builtin RBAC roles are tenant-scoped and
|
||||
identified by runtime ids, so the command must look them up per tenant.
|
||||
"""
|
||||
if legacy_role not in _LEGACY_ROLE_TO_BUILTIN_TAG:
|
||||
expected_builtin_tag = {
|
||||
TenantAccountRole.OWNER.value: "owner",
|
||||
TenantAccountRole.ADMIN.value: "admin",
|
||||
TenantAccountRole.EDITOR.value: "editor",
|
||||
TenantAccountRole.NORMAL.value: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR.value: "dataset_operator",
|
||||
}.get(legacy_role)
|
||||
if not expected_builtin_tag:
|
||||
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
|
||||
|
||||
return _resolve_builtin_role_ids(tenant_id, operator_account_id)[legacy_role]
|
||||
|
||||
|
||||
def _iter_tenant_member_batches(
|
||||
tenant_id: str | None,
|
||||
*,
|
||||
db_batch_size: int,
|
||||
api_batch_size: int,
|
||||
) -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
|
||||
"""Yield legacy member roles in tenant-scoped API-sized batches.
|
||||
|
||||
Rows are projected to primitive values and streamed from the database, so
|
||||
the command never materializes every TenantAccountJoin ORM object. The
|
||||
iterator only keeps one tenant's API-sized batches in memory while it
|
||||
finds that tenant's owner account.
|
||||
"""
|
||||
with session_factory.create_session() as session:
|
||||
stmt = (
|
||||
select(TenantAccountJoin.tenant_id, TenantAccountJoin.account_id, TenantAccountJoin.role)
|
||||
.order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
|
||||
.execution_options(yield_per=db_batch_size)
|
||||
)
|
||||
if tenant_id:
|
||||
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
|
||||
current_tenant_id: str | None = None
|
||||
owner_account_id: str | None = None
|
||||
batches: list[list[tuple[str, str]]] = []
|
||||
batch: list[tuple[str, str]] = []
|
||||
|
||||
def flush_current_tenant() -> Iterator[tuple[str, str, list[tuple[str, str]]]]:
|
||||
if current_tenant_id is None:
|
||||
return
|
||||
if batch:
|
||||
batches.append(batch.copy())
|
||||
if not owner_account_id:
|
||||
raise ValueError(f"Workspace owner not found for tenant={current_tenant_id}")
|
||||
for item in batches:
|
||||
yield current_tenant_id, owner_account_id, item
|
||||
|
||||
for row in session.execute(stmt):
|
||||
workspace_id = str(row.tenant_id)
|
||||
if current_tenant_id is not None and workspace_id != current_tenant_id:
|
||||
yield from flush_current_tenant()
|
||||
owner_account_id = None
|
||||
batches = []
|
||||
batch = []
|
||||
current_tenant_id = workspace_id
|
||||
account_id = str(row.account_id)
|
||||
role = str(row.role)
|
||||
if role == TenantAccountRole.OWNER.value:
|
||||
owner_account_id = account_id
|
||||
batch.append((account_id, role))
|
||||
if len(batch) >= api_batch_size:
|
||||
batches.append(batch)
|
||||
batch = []
|
||||
|
||||
yield from flush_current_tenant()
|
||||
|
||||
|
||||
def _member_already_has_role(current_roles_by_account_id: dict[str, set[str]], account_id: str, role_id: str) -> bool:
|
||||
return current_roles_by_account_id.get(account_id) == {role_id}
|
||||
|
||||
|
||||
def _replace_member_role(
|
||||
tenant_id: str,
|
||||
operator_account_id: str,
|
||||
member_account_id: str,
|
||||
role_id: str,
|
||||
) -> str:
|
||||
RBACService.MemberRoles.replace(
|
||||
roles = RBACService.Roles.list(
|
||||
tenant_id=tenant_id,
|
||||
account_id=operator_account_id,
|
||||
member_account_id=member_account_id,
|
||||
role_ids=[role_id],
|
||||
)
|
||||
return member_account_id
|
||||
options=ListOption(page_number=1, results_per_page=100),
|
||||
).data
|
||||
for role in roles:
|
||||
if role.is_builtin and role.category == "global_system_default" and role.role_tag == expected_builtin_tag:
|
||||
return str(role.id)
|
||||
|
||||
raise ValueError(f"Builtin RBAC role not found for tenant={tenant_id}, legacy_role={legacy_role}")
|
||||
|
||||
|
||||
@click.command(
|
||||
@@ -142,16 +42,7 @@ def _replace_member_role(
|
||||
)
|
||||
@click.option("--tenant-id", help="Only migrate a single workspace.")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Preview the migration without writing RBAC bindings.")
|
||||
@click.option("--db-batch-size", default=5000, show_default=True, help="Rows fetched per database batch.")
|
||||
@click.option("--api-batch-size", default=200, show_default=True, help="Members checked per RBAC batch_get call.")
|
||||
@click.option("--workers", default=1, show_default=True, help="Concurrent member role replace calls per tenant batch.")
|
||||
def migrate_member_roles_to_rbac(
|
||||
tenant_id: str | None,
|
||||
dry_run: bool,
|
||||
db_batch_size: int,
|
||||
api_batch_size: int,
|
||||
workers: int,
|
||||
) -> None:
|
||||
def migrate_member_roles_to_rbac(tenant_id: str | None, dry_run: bool) -> None:
|
||||
"""Backfill RBAC member-role bindings from legacy `TenantAccountJoin.role` data.
|
||||
|
||||
This is an offline migration command for workspaces that already have
|
||||
@@ -159,102 +50,63 @@ def migrate_member_roles_to_rbac(
|
||||
member-role binding store.
|
||||
"""
|
||||
click.echo(click.style("Starting RBAC member-role migration.", fg="green"))
|
||||
if workers < 1:
|
||||
raise click.BadParameter("workers must be >= 1", param_hint="--workers")
|
||||
|
||||
tenant_count = 0
|
||||
scanned_count = 0
|
||||
skipped_count = 0
|
||||
migrated_count = 0
|
||||
current_tenant_id: str | None = None
|
||||
role_ids_by_legacy_role: dict[str, str] = {}
|
||||
with session_factory.create_session() as session:
|
||||
stmt = select(TenantAccountJoin).order_by(TenantAccountJoin.tenant_id.asc(), TenantAccountJoin.id.asc())
|
||||
if tenant_id:
|
||||
stmt = stmt.where(TenantAccountJoin.tenant_id == tenant_id)
|
||||
|
||||
for workspace_id, owner_account_id, batch in _iter_tenant_member_batches(
|
||||
tenant_id,
|
||||
db_batch_size=db_batch_size,
|
||||
api_batch_size=api_batch_size,
|
||||
):
|
||||
scanned_count += len(batch)
|
||||
if workspace_id != current_tenant_id:
|
||||
tenant_count += 1
|
||||
current_tenant_id = workspace_id
|
||||
role_ids_by_legacy_role = _resolve_builtin_role_ids(workspace_id, owner_account_id)
|
||||
click.echo(f"tenant={workspace_id}")
|
||||
joins = list(session.scalars(stmt).all())
|
||||
|
||||
current_roles_by_account_id: dict[str, set[str]] = {}
|
||||
if not dry_run:
|
||||
current_roles = RBACService.MemberRoles.batch_get(
|
||||
tenant_id=workspace_id,
|
||||
account_id=owner_account_id,
|
||||
member_account_ids=[account_id for account_id, _ in batch],
|
||||
)
|
||||
current_roles_by_account_id = {
|
||||
item.account_id: {str(role.id) for role in item.roles} for item in current_roles
|
||||
}
|
||||
|
||||
replace_jobs: list[tuple[str, str]] = []
|
||||
for member_account_id, legacy_role in batch:
|
||||
resolved_role_id = role_ids_by_legacy_role.get(legacy_role)
|
||||
if not resolved_role_id:
|
||||
raise ValueError(f"Unsupported legacy workspace role: {legacy_role}")
|
||||
|
||||
if dry_run:
|
||||
click.echo(
|
||||
f"tenant={workspace_id} member={member_account_id} "
|
||||
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
if _member_already_has_role(current_roles_by_account_id, member_account_id, resolved_role_id):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
replace_jobs.append((member_account_id, resolved_role_id))
|
||||
|
||||
if replace_jobs:
|
||||
if workers == 1:
|
||||
for member_account_id, resolved_role_id in replace_jobs:
|
||||
_replace_member_role(workspace_id, owner_account_id, member_account_id, resolved_role_id)
|
||||
migrated_count += 1
|
||||
else:
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
_replace_member_role,
|
||||
workspace_id,
|
||||
owner_account_id,
|
||||
member_account_id,
|
||||
resolved_role_id,
|
||||
)
|
||||
for member_account_id, resolved_role_id in replace_jobs
|
||||
]
|
||||
for future in as_completed(futures):
|
||||
future.result()
|
||||
migrated_count += 1
|
||||
|
||||
if scanned_count % 10000 == 0:
|
||||
click.echo(
|
||||
f"progress scanned={scanned_count} migrated={migrated_count} skipped={skipped_count}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if scanned_count == 0:
|
||||
if not joins:
|
||||
click.echo(click.style("No workspace members found for migration.", fg="yellow"))
|
||||
return
|
||||
|
||||
owner_account_by_tenant: dict[str, str] = {}
|
||||
resolved_role_ids: dict[tuple[str, str], str] = {}
|
||||
migrated_count = 0
|
||||
|
||||
for join in joins:
|
||||
workspace_id = str(join.tenant_id)
|
||||
member_account_id = str(join.account_id)
|
||||
legacy_role = str(join.role)
|
||||
|
||||
if workspace_id not in owner_account_by_tenant:
|
||||
owner_join = next(
|
||||
(
|
||||
item
|
||||
for item in joins
|
||||
if str(item.tenant_id) == workspace_id and str(item.role) == TenantAccountRole.OWNER.value
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not owner_join:
|
||||
raise ValueError(f"Workspace owner not found for tenant={workspace_id}")
|
||||
owner_account_by_tenant[workspace_id] = str(owner_join.account_id)
|
||||
|
||||
operator_account_id = owner_account_by_tenant[workspace_id]
|
||||
cache_key = (workspace_id, legacy_role)
|
||||
if cache_key not in resolved_role_ids:
|
||||
resolved_role_ids[cache_key] = _resolve_builtin_role_id(workspace_id, operator_account_id, legacy_role)
|
||||
|
||||
resolved_role_id = resolved_role_ids[cache_key]
|
||||
click.echo(
|
||||
f"tenant={workspace_id} member={member_account_id} "
|
||||
f"legacy_role={legacy_role} -> rbac_role_id={resolved_role_id}"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=workspace_id,
|
||||
account_id=operator_account_id,
|
||||
member_account_id=member_account_id,
|
||||
role_ids=[resolved_role_id],
|
||||
)
|
||||
migrated_count += 1
|
||||
|
||||
if dry_run:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Dry run completed. Scanned {scanned_count} members across {tenant_count} tenants. "
|
||||
"No RBAC bindings were written.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
click.echo(click.style("Dry run completed. No RBAC bindings were written.", fg="yellow"))
|
||||
else:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"RBAC member-role migration completed. Scanned {scanned_count} members across {tenant_count} tenants, "
|
||||
f"migrated {migrated_count}, skipped {skipped_count} already up-to-date.",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
click.echo(click.style(f"RBAC member-role migration completed. Migrated {migrated_count} members.", fg="green"))
|
||||
|
||||
@@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
|
||||
CAN_REPLACE_LOGO: bool = Field(
|
||||
description="Allow customization of the enterprise logo.",
|
||||
default=False,
|
||||
default=True,
|
||||
)
|
||||
|
||||
ENTERPRISE_REQUEST_TIMEOUT: int = Field(
|
||||
@@ -34,12 +34,6 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
default=False,
|
||||
)
|
||||
|
||||
ENTERPRISE_RBAC_REQUEST_TIMEOUT: int = Field(
|
||||
ge=1,
|
||||
description="Maximum timeout in seconds for inner RBAC requests.",
|
||||
default=30,
|
||||
)
|
||||
|
||||
|
||||
class EnterpriseTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from collections.abc import Generator, Sequence
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
@@ -24,10 +23,8 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
|
||||
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.workflow.generator.types import WorkflowGenerateErrorCode
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.helper import compact_generate_response
|
||||
from libs.login import login_required
|
||||
from models import App
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
@@ -67,10 +64,7 @@ class WorkflowGeneratePayload(BaseModel):
|
||||
can reuse its existing handler.
|
||||
"""
|
||||
|
||||
mode: Literal["workflow", "advanced-chat", "auto"] = Field(
|
||||
...,
|
||||
description="Target app mode for the generated graph; 'auto' lets the backend classify the instruction",
|
||||
)
|
||||
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph")
|
||||
instruction: str = Field(..., description="Natural-language workflow description")
|
||||
ideal_output: str = Field(default="", description="Optional sample output for grounding")
|
||||
model_config_data: ModelConfig = Field(
|
||||
@@ -84,19 +78,6 @@ class WorkflowGeneratePayload(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class WorkflowInstructionSuggestionsPayload(BaseModel):
|
||||
"""Payload for the workflow-generator instruction-suggestions endpoint.
|
||||
|
||||
Runs before the user picks a model, so the suggestions come from the
|
||||
tenant's default model. The underlying generator never raises — an empty
|
||||
``suggestions`` list is a valid 200 (soft-fail).
|
||||
"""
|
||||
|
||||
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the suggestions")
|
||||
language: str | None = Field(default=None, description="Optional language to write the suggestions in")
|
||||
count: int = Field(default=4, ge=1, le=6, description="Number of suggestions to return (1-6)")
|
||||
|
||||
|
||||
class GeneratorResponse(RootModel[Any]):
|
||||
root: Any
|
||||
|
||||
@@ -110,7 +91,6 @@ register_schema_models(
|
||||
InstructionGeneratePayload,
|
||||
InstructionTemplatePayload,
|
||||
WorkflowGeneratePayload,
|
||||
WorkflowInstructionSuggestionsPayload,
|
||||
ModelConfig,
|
||||
)
|
||||
register_response_schema_models(console_ns, GeneratorResponse, SimpleDataResponse)
|
||||
@@ -333,34 +313,6 @@ class InstructionGenerationTemplateApi(Resource):
|
||||
raise ValueError(f"Invalid type: {args.type}")
|
||||
|
||||
|
||||
def _workflow_instruction_guard(args: WorkflowGeneratePayload) -> tuple[dict, int] | None:
|
||||
"""Shared boundary guard for the workflow-generate endpoints.
|
||||
|
||||
Returns a ``(body, 400)`` tuple when the instruction is empty / whitespace
|
||||
or either free-text field exceeds the cap, else ``None``. Pydantic only
|
||||
validates the field is a str; a whitespace-only or pasted-document input
|
||||
would otherwise waste a slow planner+builder roundtrip on a response the
|
||||
validator rejects anyway. Both the blocking and streaming endpoints call
|
||||
this so they reject identical inputs.
|
||||
"""
|
||||
if not args.instruction.strip():
|
||||
return {
|
||||
"error": "Instruction is required",
|
||||
"errors": [{"code": WorkflowGenerateErrorCode.EMPTY_INSTRUCTION, "detail": "Instruction is required"}],
|
||||
}, 400
|
||||
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
|
||||
return {
|
||||
"error": "Instruction is too long",
|
||||
"errors": [
|
||||
{
|
||||
"code": WorkflowGenerateErrorCode.INSTRUCTION_TOO_LONG,
|
||||
"detail": f"Instruction and ideal output must each be at most {_MAX_INSTRUCTION_LENGTH} characters",
|
||||
}
|
||||
],
|
||||
}, 400
|
||||
return None
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate")
|
||||
class WorkflowGenerateApi(Resource):
|
||||
"""Generate a Workflow / Chatflow draft graph from a natural-language description.
|
||||
@@ -383,11 +335,31 @@ class WorkflowGenerateApi(Resource):
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
|
||||
|
||||
# Reject empty / over-length instructions at the boundary (shared with
|
||||
# the streaming endpoint) before spending a planner+builder roundtrip.
|
||||
guard = _workflow_instruction_guard(args)
|
||||
if guard is not None:
|
||||
return guard
|
||||
# Reject obviously-empty instructions at the boundary — Pydantic only
|
||||
# validates ``instruction`` is a str, but a whitespace-only string
|
||||
# would still hit the LLM and waste a planner+builder roundtrip on a
|
||||
# response that the postprocess validator would reject anyway.
|
||||
if not args.instruction.strip():
|
||||
return {
|
||||
"error": "Instruction is required",
|
||||
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
|
||||
}, 400
|
||||
|
||||
# Bound the prompt at the boundary too: an arbitrarily long
|
||||
# instruction (or pasted document) blows the planner/builder context
|
||||
# window and fails with an opaque provider error after two slow LLM
|
||||
# calls. The cap matches the frontend textarea's maxLength.
|
||||
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
|
||||
return {
|
||||
"error": "Instruction is too long",
|
||||
"errors": [
|
||||
{
|
||||
"code": "INSTRUCTION_TOO_LONG",
|
||||
"detail": f"Instruction and ideal output must each be at most "
|
||||
f"{_MAX_INSTRUCTION_LENGTH} characters",
|
||||
}
|
||||
],
|
||||
}, 400
|
||||
|
||||
try:
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
@@ -408,93 +380,3 @@ class WorkflowGenerateApi(Resource):
|
||||
raise CompletionRequestError(e.description)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate/suggestions")
|
||||
class WorkflowInstructionSuggestionsApi(Resource):
|
||||
"""Suggest short, buildable example instructions for the cmd+k generator.
|
||||
|
||||
Runs before a model is selected (uses the tenant's default model). The
|
||||
underlying generator never raises, so an empty list is a valid 200 — the
|
||||
frontend renders "no suggestions" rather than an error, so no provider-error
|
||||
mapping is needed here.
|
||||
"""
|
||||
|
||||
@console_ns.doc("generate_workflow_instruction_suggestions")
|
||||
@console_ns.doc(description="Suggest example workflow-generator instructions for the tenant")
|
||||
@console_ns.expect(console_ns.models[WorkflowInstructionSuggestionsPayload.__name__])
|
||||
@console_ns.response(200, "Suggestions generated successfully", console_ns.models[GeneratorResponse.__name__])
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowInstructionSuggestionsPayload.model_validate(console_ns.payload)
|
||||
suggestions = LLMGenerator.generate_workflow_instruction_suggestions(
|
||||
tenant_id=current_tenant_id,
|
||||
mode=args.mode,
|
||||
language=args.language,
|
||||
count=args.count,
|
||||
)
|
||||
return {"suggestions": suggestions}
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate/stream")
|
||||
class WorkflowGenerateStreamApi(Resource):
|
||||
"""Plan-first streaming variant of ``/workflow-generate`` (Server-Sent Events).
|
||||
|
||||
Emits a ``plan`` event (high-level node list + app metadata) as soon as the
|
||||
planner returns, then a final ``result`` event with the full graph — the
|
||||
SAME envelope ``/workflow-generate`` returns. Provider-init / invoke errors
|
||||
are surfaced as a single ``result`` event (code ``MODEL_ERROR``) so the
|
||||
frontend's stream parser always receives a result rather than a non-SSE HTTP
|
||||
error.
|
||||
"""
|
||||
|
||||
@console_ns.doc("generate_workflow_graph_stream")
|
||||
@console_ns.doc(description="Stream a Dify workflow graph (plan then result) via SSE")
|
||||
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
|
||||
@console_ns.response(200, "Server-Sent Events stream of plan/result events")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
|
||||
|
||||
# Same boundary guards as the blocking endpoint — return a normal 400
|
||||
# JSON for these BEFORE opening the stream.
|
||||
guard = _workflow_instruction_guard(args)
|
||||
if guard is not None:
|
||||
return guard
|
||||
|
||||
def generate() -> Generator[str, None, None]:
|
||||
try:
|
||||
for event_name, payload in WorkflowGeneratorService.generate_workflow_graph_stream(
|
||||
tenant_id=current_tenant_id,
|
||||
mode=args.mode,
|
||||
instruction=args.instruction,
|
||||
model_config=args.model_config_data,
|
||||
ideal_output=args.ideal_output,
|
||||
current_graph=args.current_graph,
|
||||
):
|
||||
body = {"event": event_name, **payload}
|
||||
yield f"data: {json.dumps(body)}\n\n"
|
||||
except (ProviderTokenNotInitError, QuotaExceededError, ModelCurrentlyNotSupportError, InvokeError) as e:
|
||||
# The model instance is resolved inside the service (lazily, on
|
||||
# first iteration), so a provider / init error surfaces here.
|
||||
# Emit it as a single SSE result event rather than a non-SSE
|
||||
# error response so the frontend's stream parser always gets a
|
||||
# result it can render.
|
||||
detail = getattr(e, "description", None) or str(e) or "Model invocation failed"
|
||||
error_body = {
|
||||
"event": "result",
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0.0, "y": 0.0, "zoom": 0.7}},
|
||||
"error": detail,
|
||||
"errors": [{"code": WorkflowGenerateErrorCode.MODEL_ERROR, "detail": detail}],
|
||||
}
|
||||
yield f"data: {json.dumps(error_body)}\n\n"
|
||||
|
||||
return compact_generate_response(generate())
|
||||
|
||||
@@ -8,7 +8,6 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from controllers.common.controller_schemas import WorkflowUpdatePayload
|
||||
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@@ -97,7 +96,6 @@ register_schema_models(
|
||||
SnippetLoopNodeRunPayload,
|
||||
SnippetWorkflowListQuery,
|
||||
WorkflowRunQuery,
|
||||
WorkflowUpdatePayload,
|
||||
PublishWorkflowPayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
@@ -167,6 +165,7 @@ class SnippetDraftWorkflowApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get draft workflow for snippet."""
|
||||
snippet_service = _snippet_service()
|
||||
@@ -235,6 +234,7 @@ class SnippetDraftConfigApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get snippet draft workflow configuration limits."""
|
||||
return {
|
||||
@@ -256,6 +256,7 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get published workflow for snippet."""
|
||||
if not snippet.is_published:
|
||||
@@ -320,6 +321,7 @@ class SnippetDefaultBlockConfigsApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get default block configurations for snippet workflow."""
|
||||
snippet_service = _snippet_service()
|
||||
@@ -342,9 +344,7 @@ class SnippetPublishedAllWorkflowApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, snippet: CustomizedSnippet):
|
||||
"""Get all published workflow versions for snippet."""
|
||||
args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@@ -413,49 +413,6 @@ class SnippetDraftWorkflowRestoreApi(Resource):
|
||||
}
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>")
|
||||
class SnippetWorkflowByIdApi(Resource):
|
||||
@console_ns.doc("update_snippet_workflow_by_id")
|
||||
@console_ns.doc(description="Update published snippet workflow attributes")
|
||||
@console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Workflow ID"})
|
||||
@console_ns.expect(console_ns.models[WorkflowUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Workflow updated successfully", console_ns.models[SnippetWorkflowResponse.__name__])
|
||||
@console_ns.response(400, "No valid fields to update")
|
||||
@console_ns.response(404, "Workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def patch(self, current_user: Account, snippet: CustomizedSnippet, workflow_id: str):
|
||||
"""Update a published snippet workflow version's display metadata."""
|
||||
payload = WorkflowUpdatePayload.model_validate(console_ns.payload or {})
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_data:
|
||||
return {"message": "No valid fields to update"}, 400
|
||||
|
||||
snippet_service = _snippet_service()
|
||||
with _snippet_session_maker().begin() as session:
|
||||
workflow = snippet_service.update_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
workflow_id=workflow_id,
|
||||
account=current_user,
|
||||
data=update_data,
|
||||
)
|
||||
if not workflow:
|
||||
raise NotFound("Workflow not found")
|
||||
|
||||
response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
|
||||
@console_ns.route("/snippets/<uuid:snippet_id>/workflow-runs")
|
||||
class SnippetWorkflowRunsApi(Resource):
|
||||
@console_ns.doc("list_snippet_workflow_runs")
|
||||
@@ -557,6 +514,9 @@ class SnippetDraftNodeRunApi(Resource):
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a single node in snippet draft workflow.
|
||||
@@ -645,6 +605,9 @@ class SnippetDraftRunIterationNodeApi(Resource):
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a draft workflow iteration node for snippet.
|
||||
@@ -690,6 +653,9 @@ class SnippetDraftRunLoopNodeApi(Resource):
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, current_user: Account, snippet: CustomizedSnippet, node_id: str):
|
||||
"""
|
||||
Run a draft workflow loop node for snippet.
|
||||
@@ -733,6 +699,9 @@ class SnippetDraftWorkflowRunApi(Resource):
|
||||
@with_current_user
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, current_user: Account, snippet: CustomizedSnippet):
|
||||
"""
|
||||
Run draft workflow for snippet.
|
||||
@@ -771,6 +740,9 @@ class SnippetWorkflowTaskStopApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_snippet
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def post(self, snippet: CustomizedSnippet, task_id: str):
|
||||
"""
|
||||
Stop a running snippet workflow task.
|
||||
|
||||
@@ -34,8 +34,11 @@ from controllers.console.app.workflow_draft_variable import (
|
||||
)
|
||||
from controllers.console.snippets.snippet_workflow import get_snippet
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
@@ -102,6 +105,7 @@ class SnippetWorkflowVariableCollectionApi(Resource):
|
||||
)
|
||||
@_snippet_draft_var_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_without_value_model)
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_MANAGE, resource_required=False)
|
||||
def get(self, current_user: Account, snippet: CustomizedSnippet) -> WorkflowDraftVariableList:
|
||||
args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||
|
||||
@@ -125,6 +129,9 @@ class SnippetWorkflowVariableCollectionApi(Resource):
|
||||
@console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)")
|
||||
@console_ns.response(204, "Workflow variables deleted successfully")
|
||||
@_snippet_draft_var_prerequisite
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
def delete(self, current_user: Account, snippet: CustomizedSnippet) -> Response:
|
||||
draft_var_srv = WorkflowDraftVariableService(session=db.session())
|
||||
draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id)
|
||||
|
||||
@@ -4,17 +4,12 @@ from uuid import UUID
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.wraps import enforce_rbac_access
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
@@ -23,10 +18,9 @@ from controllers.console.wraps import (
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.enums import TagType
|
||||
from models.model import Tag
|
||||
from services.tag_service import (
|
||||
SaveTagPayload,
|
||||
TagBindingCreatePayload,
|
||||
@@ -97,30 +91,6 @@ register_schema_models(
|
||||
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
|
||||
|
||||
def _enforce_snippet_tag_rbac_if_needed(tag_type: TagType | str | None) -> None:
|
||||
if tag_type != TagType.SNIPPET:
|
||||
return
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
enforce_rbac_access(
|
||||
tenant_id=current_tenant_id,
|
||||
account_id=current_user.id,
|
||||
resource_type=RBACResourceScope.WORKSPACE,
|
||||
scene=RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
|
||||
resource_required=False,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_snippet_tag_rbac_by_tag_id(tag_id: str) -> None:
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
return
|
||||
|
||||
tag_type = db.session.scalar(select(Tag.type).where(Tag.id == tag_id).limit(1))
|
||||
_enforce_snippet_tag_rbac_if_needed(tag_type)
|
||||
|
||||
|
||||
@console_ns.route("/tags")
|
||||
class TagListApi(Resource):
|
||||
@setup_required
|
||||
@@ -152,7 +122,6 @@ class TagListApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagBasePayload.model_validate(console_ns.payload or {})
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session)
|
||||
|
||||
response = TagResponse.model_validate(
|
||||
@@ -177,7 +146,6 @@ class TagUpdateDeleteApi(Resource):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
|
||||
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session)
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session)
|
||||
@@ -196,7 +164,6 @@ class TagUpdateDeleteApi(Resource):
|
||||
def delete(self, tag_id: UUID):
|
||||
tag_id_str = str(tag_id)
|
||||
|
||||
_enforce_snippet_tag_rbac_by_tag_id(tag_id_str)
|
||||
TagService.delete_tag(tag_id_str, db.session)
|
||||
|
||||
return "", 204
|
||||
@@ -217,7 +184,6 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission(current_user)
|
||||
|
||||
payload = TagBindingPayload.model_validate(console_ns.payload or {})
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
TagService.save_tag_binding(
|
||||
TagBindingCreatePayload(
|
||||
tag_ids=payload.tag_ids,
|
||||
@@ -233,7 +199,6 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
|
||||
_require_tag_binding_edit_permission(current_user)
|
||||
|
||||
payload = TagBindingRemovePayload.model_validate(console_ns.payload or {})
|
||||
_enforce_snippet_tag_rbac_if_needed(payload.type)
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(
|
||||
tag_ids=payload.tag_ids,
|
||||
|
||||
@@ -455,6 +455,9 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACResourceScope.WORKSPACE, RBACPermission.SNIPPETS_CREATE_AND_MODIFY, resource_required=False
|
||||
)
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, snippet_id: str):
|
||||
"""Increment snippet use count when it is inserted into a workflow."""
|
||||
|
||||
@@ -12,7 +12,6 @@ from flask_restx import Resource
|
||||
from flask_restx.utils import merge
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
|
||||
|
||||
from configs import dify_config
|
||||
@@ -270,8 +269,8 @@ def cloud_edition_billing_rate_limit_check[**P, R](
|
||||
subscription_plan=knowledge_rate_limit.subscription_plan,
|
||||
operation="knowledge",
|
||||
)
|
||||
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||
session.add(rate_limit_log)
|
||||
db.session.add(rate_limit_log)
|
||||
db.session.commit()
|
||||
raise Forbidden(
|
||||
"Sorry, you have reached the knowledge base request rate limit of your subscription."
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal, NotRequired, Protocol, TypedDict, cast
|
||||
from typing import Any, NotRequired, Protocol, TypedDict, cast
|
||||
|
||||
import json_repair
|
||||
from sqlalchemy import select
|
||||
@@ -69,53 +69,6 @@ def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[
|
||||
return normalized_parameters, stop
|
||||
|
||||
|
||||
# ── Workflow instruction-suggestion tuning ────────────────────────────────
|
||||
# Suggestions are a soft, pre-model-pick enhancement: short, buildable example
|
||||
# instructions proposed from the tenant's DEFAULT model. Every failure path
|
||||
# degrades to an empty list, never an error.
|
||||
_SUGGESTION_MIN_COUNT = 1
|
||||
_SUGGESTION_MAX_COUNT = 6
|
||||
_SUGGESTION_MAX_TOKENS = 512
|
||||
_SUGGESTION_TEMPERATURE = 0.8
|
||||
# Bound the grounding context so the prompt stays small regardless of how many
|
||||
# knowledge bases / tools the tenant has installed.
|
||||
_SUGGESTION_KB_LIMIT = 10
|
||||
_SUGGESTION_TOOL_SAMPLE_LINES = 20
|
||||
|
||||
_SUGGESTION_SYSTEM_PROMPT = (
|
||||
"You help a user start building a Dify app by proposing example build instructions. "
|
||||
"Each suggestion must be a SHORT (at most 8 words), concrete, and BUILDABLE instruction "
|
||||
"describing an app to generate for the given app type. Make the suggestions diverse — cover "
|
||||
"different use cases. When the listed knowledge bases or installed tools fit a suggestion, "
|
||||
"prefer them, but NEVER invent tools or knowledge bases that are not listed. "
|
||||
"Reply with ONLY a JSON array of strings and nothing else."
|
||||
)
|
||||
|
||||
|
||||
def _parse_string_list(text: str) -> list[str]:
|
||||
"""Extract a JSON array of strings from a (possibly noisy) LLM response.
|
||||
|
||||
Slices the first ``[...]`` span so surrounding prose / markdown fences are
|
||||
tolerated, parses it with ``json`` and falls back to ``json_repair``, then
|
||||
keeps only ``str`` items. Returns ``[]`` on any failure so callers can
|
||||
treat parsing as best-effort.
|
||||
"""
|
||||
match = re.search(r"\[.*\]", text.strip(), re.DOTALL)
|
||||
if not match:
|
||||
return []
|
||||
raw = match.group(0)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except Exception:
|
||||
try:
|
||||
parsed = json_repair.loads(raw)
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return [item for item in parsed if isinstance(item, str)]
|
||||
|
||||
|
||||
class WorkflowServiceInterface(Protocol):
|
||||
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
|
||||
pass
|
||||
@@ -284,170 +237,6 @@ class LLMGenerator:
|
||||
|
||||
return questions
|
||||
|
||||
@classmethod
|
||||
def generate_workflow_instruction_suggestions(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
*,
|
||||
mode: Literal["workflow", "advanced-chat"],
|
||||
language: str | None = None,
|
||||
count: int = 4,
|
||||
) -> list[str]:
|
||||
"""Propose short, buildable example instructions for the workflow generator.
|
||||
|
||||
Runs BEFORE the user picks a model, so it uses the tenant's DEFAULT LLM
|
||||
only. Suggestions are a soft enhancement, never a blocker: every failure
|
||||
path (no default model, KB / tool lookup error, LLM error, unparseable
|
||||
output) is swallowed and surfaced as an empty list — a valid result the
|
||||
caller renders as "no suggestions". This method NEVER raises.
|
||||
"""
|
||||
count = max(_SUGGESTION_MIN_COUNT, min(count, _SUGGESTION_MAX_COUNT))
|
||||
|
||||
try:
|
||||
model_instance = ModelManager.for_tenant(tenant_id=tenant_id).get_default_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
)
|
||||
except Exception:
|
||||
logger.info("Workflow instruction suggestions: no default model for tenant %s", tenant_id)
|
||||
return []
|
||||
|
||||
context_block = cls._build_suggestion_context(tenant_id)
|
||||
app_type_label = (
|
||||
"Workflow — single-shot automation" if mode == "workflow" else "Chatflow — conversational multi-turn"
|
||||
)
|
||||
|
||||
user_lines = [
|
||||
f"App type: {app_type_label}",
|
||||
context_block,
|
||||
f"Return exactly {count} distinct ideas as a JSON array of strings.",
|
||||
]
|
||||
if language:
|
||||
user_lines.append(f"Write every idea in this language: {language}.")
|
||||
user_prompt = "\n".join(line for line in user_lines if line)
|
||||
|
||||
prompt_messages: list[PromptMessage] = [
|
||||
SystemPromptMessage(content=_SUGGESTION_SYSTEM_PROMPT),
|
||||
UserPromptMessage(content=user_prompt),
|
||||
]
|
||||
|
||||
try:
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters={"max_tokens": _SUGGESTION_MAX_TOKENS, "temperature": _SUGGESTION_TEMPERATURE},
|
||||
stream=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Workflow instruction suggestions: LLM invocation failed")
|
||||
return []
|
||||
|
||||
raw_suggestions = _parse_string_list(response.message.get_text_content() or "")
|
||||
|
||||
# Strip whitespace + surrounding quotes, drop empties, dedupe
|
||||
# case-insensitively (preserving first-seen casing), cap to ``count``.
|
||||
cleaned: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in raw_suggestions:
|
||||
idea = item.strip().strip("\"'").strip()
|
||||
if not idea:
|
||||
continue
|
||||
key = idea.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cleaned.append(idea)
|
||||
if len(cleaned) >= count:
|
||||
break
|
||||
return cleaned
|
||||
|
||||
@staticmethod
|
||||
def _build_suggestion_context(tenant_id: str) -> str:
|
||||
"""Assemble an optional grounding block naming the tenant's KBs and tools.
|
||||
|
||||
Best-effort: each section is isolated in its own try/except so a failure
|
||||
enumerating one (DB hiccup, plugin daemon down) never blocks the other
|
||||
or the suggestion call itself. Returns "" when nothing is available.
|
||||
"""
|
||||
sections: list[str] = []
|
||||
|
||||
try:
|
||||
from models.dataset import Dataset
|
||||
|
||||
names = db.session.scalars(
|
||||
select(Dataset.name)
|
||||
.where(Dataset.tenant_id == tenant_id)
|
||||
.order_by(Dataset.created_at.desc())
|
||||
.limit(_SUGGESTION_KB_LIMIT)
|
||||
).all()
|
||||
kb_names = [name for name in names if name]
|
||||
if kb_names:
|
||||
sections.append("Knowledge bases:\n" + "\n".join(f"- {name}" for name in kb_names))
|
||||
except Exception:
|
||||
logger.info("Workflow instruction suggestions: failed to load knowledge bases", exc_info=True)
|
||||
|
||||
try:
|
||||
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue
|
||||
|
||||
tool_text = format_tool_catalogue(build_tool_catalogue(tenant_id))
|
||||
if tool_text:
|
||||
sample = "\n".join(tool_text.splitlines()[:_SUGGESTION_TOOL_SAMPLE_LINES])
|
||||
sections.append("Installed tools:\n" + sample)
|
||||
except Exception:
|
||||
logger.info("Workflow instruction suggestions: failed to load tool catalogue", exc_info=True)
|
||||
|
||||
if not sections:
|
||||
return ""
|
||||
return "\n\n".join(sections) + "\n\n"
|
||||
|
||||
@classmethod
|
||||
def classify_workflow_mode(
|
||||
cls,
|
||||
tenant_id: str,
|
||||
instruction: str,
|
||||
model_config: ModelConfig,
|
||||
) -> Literal["workflow", "advanced-chat"]:
|
||||
"""Classify a free-text instruction into a concrete app mode.
|
||||
|
||||
One tiny LLM call using the model the user already picked (so no extra
|
||||
provider setup is needed). Parsed leniently; defaults to
|
||||
``advanced-chat`` on anything unexpected or any error, so a
|
||||
``mode="auto"`` request never blocks generation. NEVER raises.
|
||||
"""
|
||||
default_mode: Literal["workflow", "advanced-chat"] = "advanced-chat"
|
||||
try:
|
||||
model_instance = ModelManager.for_tenant(tenant_id=tenant_id).get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=model_config.provider,
|
||||
model=model_config.name,
|
||||
)
|
||||
prompt_messages: list[PromptMessage] = [
|
||||
UserPromptMessage(
|
||||
content=(
|
||||
"Reply with exactly one word: 'workflow' (one-shot automation, no chat) "
|
||||
"or 'advanced-chat' (conversational multi-turn). "
|
||||
f"Instruction: {instruction.strip()}"
|
||||
)
|
||||
),
|
||||
]
|
||||
response: LLMResult = model_instance.invoke_llm(
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters={"max_tokens": 4, "temperature": 0},
|
||||
stream=False,
|
||||
)
|
||||
text = (response.message.get_text_content() or "").strip().lower()
|
||||
except Exception:
|
||||
logger.info("Workflow mode classification failed; defaulting to %s", default_mode, exc_info=True)
|
||||
return default_mode
|
||||
|
||||
# Lenient parse: an affirmative "workflow" wins; everything else
|
||||
# (including a truncated / empty / garbled reply) falls back to the
|
||||
# conversational default. "advanced-chat" needs no positive match
|
||||
# because it IS the default.
|
||||
if "workflow" in text:
|
||||
return "workflow"
|
||||
return default_mode
|
||||
|
||||
@classmethod
|
||||
def generate_rule_config(cls, tenant_id: str, args: RuleGeneratePayload):
|
||||
output_parser = RuleConfigGeneratorOutputParser()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import logging
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager, ExitStack
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import has_request_context, request
|
||||
|
||||
from core.mcp.client.sse_client import sse_client
|
||||
from core.mcp.client.streamable_client import streamablehttp_client
|
||||
from core.mcp.error import MCPConnectionError
|
||||
@@ -26,22 +23,10 @@ class MCPClient:
|
||||
sse_read_timeout: float | None = None,
|
||||
):
|
||||
self.server_url = server_url
|
||||
self.headers = headers.copy() if headers else {}
|
||||
self.headers = headers or {}
|
||||
self.timeout = timeout
|
||||
self.sse_read_timeout = sse_read_timeout
|
||||
|
||||
# Substitute placeholders with incoming request headers if in a request context
|
||||
if has_request_context() and self.headers:
|
||||
pattern = re.compile(r"\{\{\s*request\.headers?\.(.+?)\s*\}\}", re.IGNORECASE)
|
||||
for key, value in list(self.headers.items()):
|
||||
if isinstance(value, str):
|
||||
|
||||
def replace_func(match):
|
||||
header_name = match.group(1)
|
||||
return request.headers.get(header_name, "")
|
||||
|
||||
self.headers[key] = pattern.sub(replace_func, value)
|
||||
|
||||
# Initialize session and client objects
|
||||
self._session: ClientSession | None = None
|
||||
self._exit_stack = ExitStack()
|
||||
|
||||
@@ -1030,10 +1030,6 @@ class DatasetRetrieval:
|
||||
):
|
||||
"""
|
||||
Persist dataset query audit rows for retrieval requests.
|
||||
|
||||
Query audit logging is a side effect of retrieval. Keep it in an
|
||||
independent transaction so failures or commits here do not affect the
|
||||
request/workflow transaction that called the retriever.
|
||||
"""
|
||||
if not query and not attachment_ids:
|
||||
return
|
||||
@@ -1045,9 +1041,6 @@ class DatasetRetrieval:
|
||||
app_id,
|
||||
)
|
||||
return
|
||||
created_by_role = self._resolve_creator_user_role(user_from)
|
||||
if created_by_role is None:
|
||||
return
|
||||
dataset_queries = []
|
||||
for dataset_id in dataset_ids:
|
||||
contents = []
|
||||
@@ -1062,16 +1055,13 @@ class DatasetRetrieval:
|
||||
content=json.dumps(contents),
|
||||
source=DatasetQuerySource.APP,
|
||||
source_app_id=app_id,
|
||||
created_by_role=created_by_role,
|
||||
created_by_role=CreatorUserRole(user_from),
|
||||
created_by=created_by,
|
||||
)
|
||||
dataset_queries.append(dataset_query)
|
||||
|
||||
if not dataset_queries:
|
||||
return
|
||||
|
||||
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||
session.add_all(dataset_queries)
|
||||
if dataset_queries:
|
||||
db.session.add_all(dataset_queries)
|
||||
db.session.commit()
|
||||
|
||||
def _retriever(
|
||||
self,
|
||||
|
||||
@@ -7,7 +7,6 @@ from datetime import UTC, datetime
|
||||
from mimetypes import guess_type
|
||||
from typing import Any, Union, cast
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from yarl import URL
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@@ -339,49 +338,47 @@ class ToolEngine:
|
||||
user_id: str,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Create message files produced by a tool call.
|
||||
|
||||
Tool file persistence is a side effect of agent execution. Use an
|
||||
independent transaction so this helper never commits or closes the
|
||||
caller's request-scoped session.
|
||||
Create message file
|
||||
|
||||
:return: message file ids
|
||||
"""
|
||||
result = []
|
||||
|
||||
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||
for message in tool_messages:
|
||||
# extract tool file id from url
|
||||
tool_file_id = message.url.split("/")[-1].split(".")[0]
|
||||
message_file = MessageFile(
|
||||
message_id=agent_message.id,
|
||||
type=ToolEngine._resolve_tool_file_type(message),
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
belongs_to=MessageFileBelongsTo.ASSISTANT,
|
||||
url=message.url,
|
||||
upload_file_id=tool_file_id,
|
||||
created_by_role=(
|
||||
CreatorUserRole.ACCOUNT
|
||||
if invoke_from in {InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER}
|
||||
else CreatorUserRole.END_USER
|
||||
),
|
||||
created_by=user_id,
|
||||
)
|
||||
for message in tool_messages:
|
||||
if "image" in message.mimetype:
|
||||
file_type = FileType.IMAGE
|
||||
elif "video" in message.mimetype:
|
||||
file_type = FileType.VIDEO
|
||||
elif "audio" in message.mimetype:
|
||||
file_type = FileType.AUDIO
|
||||
elif "text" in message.mimetype or "pdf" in message.mimetype:
|
||||
file_type = FileType.DOCUMENT
|
||||
else:
|
||||
file_type = FileType.CUSTOM
|
||||
|
||||
session.add(message_file)
|
||||
result.append(message_file.id)
|
||||
# extract tool file id from url
|
||||
tool_file_id = message.url.split("/")[-1].split(".")[0]
|
||||
message_file = MessageFile(
|
||||
message_id=agent_message.id,
|
||||
type=file_type,
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
belongs_to=MessageFileBelongsTo.ASSISTANT,
|
||||
url=message.url,
|
||||
upload_file_id=tool_file_id,
|
||||
created_by_role=(
|
||||
CreatorUserRole.ACCOUNT
|
||||
if invoke_from in {InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER}
|
||||
else CreatorUserRole.END_USER
|
||||
),
|
||||
created_by=user_id,
|
||||
)
|
||||
|
||||
db.session.add(message_file)
|
||||
db.session.commit()
|
||||
db.session.refresh(message_file)
|
||||
|
||||
result.append(message_file.id)
|
||||
|
||||
db.session.close()
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _resolve_tool_file_type(message: ToolInvokeMessageBinary) -> FileType:
|
||||
if "image" in message.mimetype:
|
||||
return FileType.IMAGE
|
||||
elif "video" in message.mimetype:
|
||||
return FileType.VIDEO
|
||||
elif "audio" in message.mimetype:
|
||||
return FileType.AUDIO
|
||||
elif "text" in message.mimetype or "pdf" in message.mimetype:
|
||||
return FileType.DOCUMENT
|
||||
else:
|
||||
return FileType.CUSTOM
|
||||
|
||||
@@ -27,7 +27,6 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
import json_repair
|
||||
@@ -186,48 +185,6 @@ def _result_with_errors(
|
||||
return base
|
||||
|
||||
|
||||
def _with_mode(result: WorkflowGenerateResultDict, mode: WorkflowGenerationMode) -> WorkflowGenerateResultDict:
|
||||
"""Stamp the resolved concrete ``mode`` onto a result envelope.
|
||||
|
||||
``mode="auto"`` requests are resolved to a concrete mode before planning;
|
||||
echoing it back lets the frontend pick the right app type to create. It's
|
||||
present for explicit modes too so the response shape stays uniform.
|
||||
"""
|
||||
result["mode"] = mode
|
||||
return result
|
||||
|
||||
|
||||
def _build_plan_event(
|
||||
*,
|
||||
plan: PlannerResultDict,
|
||||
plan_nodes: list[dict[str, Any]],
|
||||
start_inputs: list[dict[str, Any]],
|
||||
mode: WorkflowGenerationMode,
|
||||
) -> dict[str, Any]:
|
||||
"""Shape the ``plan`` event emitted before the (slower) builder runs.
|
||||
|
||||
Node fields are pulled defensively: the planner schema only guarantees
|
||||
``node_type`` is present, so ``label`` / ``purpose`` may be missing on a
|
||||
terse plan and default to empty strings.
|
||||
"""
|
||||
return {
|
||||
"title": str(plan.get("title") or ""),
|
||||
"description": str(plan.get("description") or ""),
|
||||
"app_name": str(plan.get("app_name") or "").strip(),
|
||||
"icon": str(plan.get("icon") or "").strip(),
|
||||
"mode": mode,
|
||||
"nodes": [
|
||||
{
|
||||
"label": str(node.get("label") or ""),
|
||||
"node_type": str(node.get("node_type") or ""),
|
||||
"purpose": str(node.get("purpose") or ""),
|
||||
}
|
||||
for node in plan_nodes
|
||||
],
|
||||
"start_inputs": start_inputs,
|
||||
}
|
||||
|
||||
|
||||
def _stage_error_to_envelope_code(exc: Exception) -> str:
|
||||
"""Map a stage-typed exception to the result envelope's error code."""
|
||||
if isinstance(exc, _StageJSONError):
|
||||
@@ -293,100 +250,6 @@ class WorkflowGenerator:
|
||||
``errors`` and keep the previous version visible.
|
||||
"""
|
||||
|
||||
# Consume the shared event generator and keep only the final result
|
||||
# envelope — ``generate_workflow_graph_stream`` shares the exact same
|
||||
# pipeline so the two stay behaviourally identical. The plan event is
|
||||
# ignored here.
|
||||
result: WorkflowGenerateResultDict | None = None
|
||||
for event_name, payload in cls._iter_generation_events(
|
||||
model_instance=model_instance,
|
||||
model_parameters=model_parameters,
|
||||
provider=provider,
|
||||
model_name=model_name,
|
||||
model_mode=model_mode,
|
||||
mode=mode,
|
||||
instruction=instruction,
|
||||
ideal_output=ideal_output,
|
||||
tool_catalogue_text=tool_catalogue_text,
|
||||
installed_tools=installed_tools,
|
||||
current_graph=current_graph,
|
||||
):
|
||||
if event_name == "result":
|
||||
result = cast(WorkflowGenerateResultDict, payload)
|
||||
# The event generator always emits exactly one result envelope; this
|
||||
# fallback only guards against a future refactor that forgets to.
|
||||
if result is None:
|
||||
result = _with_mode(_empty_result(), mode)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def generate_workflow_graph_stream(
|
||||
cls,
|
||||
*,
|
||||
model_instance,
|
||||
model_parameters: dict[str, Any],
|
||||
provider: str,
|
||||
model_name: str,
|
||||
model_mode: str,
|
||||
mode: WorkflowGenerationMode,
|
||||
instruction: str,
|
||||
ideal_output: str = "",
|
||||
tool_catalogue_text: str = "",
|
||||
installed_tools: set[tuple[str, str]] | None = None,
|
||||
current_graph: dict[str, Any] | None = None,
|
||||
) -> Iterator[tuple[str, dict[str, Any]]]:
|
||||
"""
|
||||
Streaming sibling of ``generate_workflow_graph``.
|
||||
|
||||
Yields a ``plan`` event (title / description / app_name / icon / mode /
|
||||
high-level nodes / start_inputs) as soon as the planner returns, then a
|
||||
final ``result`` event carrying the SAME envelope dict the non-streaming
|
||||
method returns (graph / message / app_name / icon / error / errors /
|
||||
mode, plus structural errors when any). On a planner / empty-plan /
|
||||
builder failure only the ``result`` event is emitted — no ``plan``.
|
||||
"""
|
||||
yield from cls._iter_generation_events(
|
||||
model_instance=model_instance,
|
||||
model_parameters=model_parameters,
|
||||
provider=provider,
|
||||
model_name=model_name,
|
||||
model_mode=model_mode,
|
||||
mode=mode,
|
||||
instruction=instruction,
|
||||
ideal_output=ideal_output,
|
||||
tool_catalogue_text=tool_catalogue_text,
|
||||
installed_tools=installed_tools,
|
||||
current_graph=current_graph,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _iter_generation_events(
|
||||
cls,
|
||||
*,
|
||||
model_instance,
|
||||
model_parameters: dict[str, Any],
|
||||
provider: str,
|
||||
model_name: str,
|
||||
model_mode: str,
|
||||
mode: WorkflowGenerationMode,
|
||||
instruction: str,
|
||||
ideal_output: str = "",
|
||||
tool_catalogue_text: str = "",
|
||||
installed_tools: set[tuple[str, str]] | None = None,
|
||||
current_graph: dict[str, Any] | None = None,
|
||||
) -> Iterator[tuple[str, dict[str, Any]]]:
|
||||
"""
|
||||
Drive planner → builder → postprocess and yield generation events.
|
||||
|
||||
Shared core for both ``generate_workflow_graph`` (keeps only the final
|
||||
``result``) and ``generate_workflow_graph_stream`` (streams every
|
||||
event). Emits at most one ``plan`` event — only once the planner
|
||||
produced a non-empty plan — followed by exactly one ``result`` event.
|
||||
On a planner / empty-plan / builder failure it emits only the
|
||||
``result`` event carrying the error envelope. Every result envelope is
|
||||
stamped with the resolved concrete ``mode``.
|
||||
"""
|
||||
|
||||
# ── 1. PLANNER ────────────────────────────────────────────────────
|
||||
plan, plan_err = cls._run_stage(
|
||||
stage="Planner",
|
||||
@@ -402,22 +265,16 @@ class WorkflowGenerator:
|
||||
),
|
||||
)
|
||||
if plan_err is not None:
|
||||
yield "result", cast(dict[str, Any], _with_mode(_result_with_errors(_empty_result(), [plan_err]), mode))
|
||||
return
|
||||
return _result_with_errors(_empty_result(), [plan_err])
|
||||
|
||||
# The lambda return is non-None when no error fired — narrow it for type-checkers.
|
||||
plan = cast(PlannerResultDict, plan)
|
||||
plan_nodes: list[dict[str, Any]] = cast(list[dict[str, Any]], plan.get("nodes", []))
|
||||
if not plan_nodes:
|
||||
empty_plan = _with_mode(
|
||||
_result_with_errors(
|
||||
_empty_result(),
|
||||
[_err(WorkflowGenerateErrorCode.EMPTY_PLAN, "Planner returned no nodes")],
|
||||
),
|
||||
mode,
|
||||
return _result_with_errors(
|
||||
_empty_result(),
|
||||
[_err(WorkflowGenerateErrorCode.EMPTY_PLAN, "Planner returned no nodes")],
|
||||
)
|
||||
yield "result", cast(dict[str, Any], empty_plan)
|
||||
return
|
||||
|
||||
# Planner-supplied user-input declarations. The builder uses these to
|
||||
# populate ``start.data.variables`` so downstream ``{#start.<var>#}``
|
||||
@@ -429,10 +286,6 @@ class WorkflowGenerator:
|
||||
if isinstance(item, dict) and (item.get("variable") or "").strip()
|
||||
]
|
||||
|
||||
# First event the stream sees: the high-level plan, before the slower
|
||||
# builder call. Non-streaming callers ignore it.
|
||||
yield "plan", _build_plan_event(plan=plan, plan_nodes=plan_nodes, start_inputs=start_inputs, mode=mode)
|
||||
|
||||
# ── 2. BUILDER ────────────────────────────────────────────────────
|
||||
graph, build_err = cls._run_stage(
|
||||
stage="Builder",
|
||||
@@ -453,8 +306,7 @@ class WorkflowGenerator:
|
||||
),
|
||||
)
|
||||
if build_err is not None:
|
||||
yield "result", cast(dict[str, Any], _with_mode(_result_with_errors(_empty_result(), [build_err]), mode))
|
||||
return
|
||||
return _result_with_errors(_empty_result(), [build_err])
|
||||
graph = cast(GraphDict, graph)
|
||||
|
||||
# ── 3. POSTPROC + VALIDATE ────────────────────────────────────────
|
||||
@@ -470,7 +322,6 @@ class WorkflowGenerator:
|
||||
"error": "",
|
||||
"errors": [],
|
||||
}
|
||||
_with_mode(result, mode)
|
||||
|
||||
# Final structural sanity check — fail closed if start/end shape is
|
||||
# wrong, container topology is broken, a tool was hallucinated, or a
|
||||
@@ -479,9 +330,8 @@ class WorkflowGenerator:
|
||||
structural_errors = cls._validate_structure(graph=graph, mode=mode, installed_tools=installed_tools)
|
||||
if structural_errors:
|
||||
logger.warning("Workflow generator: structural validation failed: %s", structural_errors)
|
||||
yield "result", cast(dict[str, Any], _result_with_errors(result, structural_errors))
|
||||
return
|
||||
yield "result", cast(dict[str, Any], result)
|
||||
return _result_with_errors(result, structural_errors)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _run_stage(
|
||||
|
||||
@@ -11,13 +11,6 @@ from typing import Final, Literal, NotRequired, TypedDict
|
||||
|
||||
WorkflowGenerationMode = Literal["workflow", "advanced-chat"]
|
||||
|
||||
# The mode accepted at the API boundary. ``auto`` is a sentinel that asks the
|
||||
# service to classify the instruction into a concrete ``WorkflowGenerationMode``
|
||||
# (one tiny LLM call) BEFORE planning — see
|
||||
# ``WorkflowGeneratorService._resolve_mode`` and
|
||||
# ``LLMGenerator.classify_workflow_mode``.
|
||||
WorkflowGenerationModeRequest = Literal["workflow", "advanced-chat", "auto"]
|
||||
|
||||
|
||||
# Machine-readable error codes returned in ``WorkflowGenerateResultDict.errors``.
|
||||
# Frontend maps these to localised copy via ``workflow.generator.errors.<code>``
|
||||
@@ -155,7 +148,3 @@ class WorkflowGenerateResultDict(TypedDict):
|
||||
icon: str
|
||||
error: str
|
||||
errors: list[WorkflowGenerateErrorDict]
|
||||
# Resolved concrete generation mode ("workflow" / "advanced-chat"). Stamped
|
||||
# onto every envelope so a ``mode="auto"`` request can tell the frontend
|
||||
# which app type to create; present for explicit modes too for uniformity.
|
||||
mode: NotRequired[str]
|
||||
|
||||
+24
-45
@@ -45,52 +45,34 @@ def upgrade():
|
||||
# PostgreSQL 18's `uuidv7` function. This capability is rarely needed in practice, as IDs can be
|
||||
# generated and controlled within the application layer.
|
||||
conn = op.get_bind()
|
||||
|
||||
|
||||
if _is_pg(conn):
|
||||
# PostgreSQL: Create uuidv7 functions.
|
||||
# PostgreSQL 18 ships a native pg_catalog.uuidv7(), so only create our own
|
||||
# implementation when the server does not already provide one. Otherwise the
|
||||
# CREATE FUNCTION below and the unqualified COMMENT statement collide with the
|
||||
# built-in and the migration fails.
|
||||
#
|
||||
# The existence check is done server-side via a DO block rather than
|
||||
# conn.execute().scalar() because the latter returns None in offline
|
||||
# migration mode (no real database connection), causing an AttributeError.
|
||||
# PostgreSQL: Create uuidv7 functions
|
||||
op.execute(sa.text(r"""
|
||||
DO $do$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_proc p
|
||||
JOIN pg_namespace n ON p.pronamespace = n.oid
|
||||
WHERE p.proname = 'uuidv7' AND n.nspname = 'pg_catalog'
|
||||
) THEN
|
||||
/* Main function to generate a uuidv7 value with millisecond precision */
|
||||
CREATE FUNCTION public.uuidv7() RETURNS uuid
|
||||
AS
|
||||
$func$
|
||||
-- Replace the first 48 bits of a uuidv4 with the current
|
||||
-- number of milliseconds since 1970-01-01 UTC
|
||||
-- and set the "ver" field to 7 by setting additional bits
|
||||
SELECT encode(
|
||||
/* Main function to generate a uuidv7 value with millisecond precision */
|
||||
CREATE FUNCTION uuidv7() RETURNS uuid
|
||||
AS
|
||||
$$
|
||||
-- Replace the first 48 bits of a uuidv4 with the current
|
||||
-- number of milliseconds since 1970-01-01 UTC
|
||||
-- and set the "ver" field to 7 by setting additional bits
|
||||
SELECT encode(
|
||||
set_bit(
|
||||
set_bit(
|
||||
set_bit(
|
||||
overlay(uuid_send(gen_random_uuid()) placing
|
||||
substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from
|
||||
3)
|
||||
from 1 for 6),
|
||||
52, 1),
|
||||
53, 1), 'hex')::uuid;
|
||||
$func$ LANGUAGE SQL VOLATILE PARALLEL SAFE;
|
||||
overlay(uuid_send(gen_random_uuid()) placing
|
||||
substring(int8send((extract(epoch from clock_timestamp()) * 1000)::bigint) from
|
||||
3)
|
||||
from 1 for 6),
|
||||
52, 1),
|
||||
53, 1), 'hex')::uuid;
|
||||
$$ LANGUAGE SQL VOLATILE PARALLEL SAFE;
|
||||
|
||||
COMMENT ON FUNCTION public.uuidv7 IS
|
||||
'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness';
|
||||
END IF;
|
||||
END
|
||||
$do$;
|
||||
COMMENT ON FUNCTION uuidv7 IS
|
||||
'Generate a uuid-v7 value with a 48-bit timestamp (millisecond precision) and 74 bits of randomness';
|
||||
"""))
|
||||
|
||||
op.execute(sa.text(r"""
|
||||
CREATE FUNCTION public.uuidv7_boundary(timestamptz) RETURNS uuid
|
||||
CREATE FUNCTION uuidv7_boundary(timestamptz) RETURNS uuid
|
||||
AS
|
||||
$$
|
||||
/* uuid fields: version=0b0111, variant=0b10 */
|
||||
@@ -101,7 +83,7 @@ SELECT encode(
|
||||
'hex')::uuid;
|
||||
$$ LANGUAGE SQL STABLE STRICT PARALLEL SAFE;
|
||||
|
||||
COMMENT ON FUNCTION public.uuidv7_boundary(timestamptz) IS
|
||||
COMMENT ON FUNCTION uuidv7_boundary(timestamptz) IS
|
||||
'Generate a non-random uuidv7 with the given timestamp (first 48 bits) and all random bits to 0. As the smallest possible uuidv7 for that timestamp, it may be used as a boundary for partitions.';
|
||||
"""
|
||||
))
|
||||
@@ -113,10 +95,7 @@ def downgrade():
|
||||
conn = op.get_bind()
|
||||
|
||||
if _is_pg(conn):
|
||||
# IF EXISTS keeps the downgrade a no-op on PostgreSQL 18, where the native
|
||||
# pg_catalog.uuidv7() was kept and no public.uuidv7() was created. Scoping the
|
||||
# drop to the public schema avoids touching the built-in.
|
||||
op.execute(sa.text("DROP FUNCTION IF EXISTS public.uuidv7()"))
|
||||
op.execute(sa.text("DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)"))
|
||||
op.execute(sa.text("DROP FUNCTION uuidv7"))
|
||||
op.execute(sa.text("DROP FUNCTION uuidv7_boundary"))
|
||||
else:
|
||||
pass
|
||||
|
||||
@@ -8700,32 +8700,6 @@ Reset a draft workflow variable to its default value (snippet scope)
|
||||
| 200 | Workflow published successfully | **application/json**: [WorkflowPublishResponse](#workflowpublishresponse)<br> |
|
||||
| 400 | No draft workflow found | |
|
||||
|
||||
### [PATCH] /snippets/{snippet_id}/workflows/{workflow_id}
|
||||
**Update a published snippet workflow version's display metadata**
|
||||
|
||||
Update published snippet workflow attributes
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| snippet_id | path | Snippet ID | Yes | string (uuid) |
|
||||
| workflow_id | path | Workflow ID | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [WorkflowUpdatePayload](#workflowupdatepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Workflow updated successfully | **application/json**: [SnippetWorkflowResponse](#snippetworkflowresponse)<br> |
|
||||
| 400 | No valid fields to update | |
|
||||
| 404 | Workflow not found | |
|
||||
|
||||
### [POST] /snippets/{snippet_id}/workflows/{workflow_id}/restore
|
||||
**Restore a published snippet workflow version into the draft workflow**
|
||||
|
||||
@@ -9135,38 +9109,6 @@ Generate a Dify workflow graph from natural language
|
||||
| 400 | Invalid request parameters | |
|
||||
| 402 | Provider quota exceeded | |
|
||||
|
||||
### [POST] /workflow-generate/stream
|
||||
Stream a Dify workflow graph (plan then result) via SSE
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [WorkflowGeneratePayload](#workflowgeneratepayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Server-Sent Events stream of plan/result events |
|
||||
| 400 | Invalid request parameters |
|
||||
|
||||
### [POST] /workflow-generate/suggestions
|
||||
Suggest example workflow-generator instructions for the tenant
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [WorkflowInstructionSuggestionsPayload](#workflowinstructionsuggestionspayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Suggestions generated successfully | **application/json**: [GeneratorResponse](#generatorresponse)<br> |
|
||||
| 400 | Invalid request parameters | |
|
||||
|
||||
### [GET] /workflow/{workflow_run_id}/events
|
||||
**Get workflow execution events stream after resume**
|
||||
|
||||
@@ -21194,23 +21136,9 @@ can reuse its existing handler.
|
||||
| current_graph | object | Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch | No |
|
||||
| ideal_output | string | Optional sample output for grounding | No |
|
||||
| instruction | string | Natural-language workflow description | Yes |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "auto", "workflow" | Target app mode for the generated graph; 'auto' lets the backend classify the instruction<br>*Enum:* `"advanced-chat"`, `"auto"`, `"workflow"` | Yes |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | Target app mode for the generated graph<br>*Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes |
|
||||
|
||||
#### WorkflowInstructionSuggestionsPayload
|
||||
|
||||
Payload for the workflow-generator instruction-suggestions endpoint.
|
||||
|
||||
Runs before the user picks a model, so the suggestions come from the
|
||||
tenant's default model. The underlying generator never raises — an empty
|
||||
``suggestions`` list is a valid 200 (soft-fail).
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| count | integer, <br>**Default:** 4 | Number of suggestions to return (1-6) | No |
|
||||
| language | string | Optional language to write the suggestions in | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | Target app mode for the suggestions<br>*Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
|
||||
#### WorkflowListQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -144,7 +144,7 @@ class AnalyticdbVectorBySql:
|
||||
f"id text PRIMARY KEY,"
|
||||
f"vector real[], ref_doc_id text, page_content text, metadata_ jsonb, "
|
||||
f"to_tsvector TSVECTOR"
|
||||
f") DISTRIBUTED BY (id);"
|
||||
f") WITH (fillfactor=70) DISTRIBUTED BY (id);"
|
||||
)
|
||||
if embedding_dimension is not None:
|
||||
index_name = f"{self._collection_name}_embedding_idx"
|
||||
@@ -153,7 +153,7 @@ class AnalyticdbVectorBySql:
|
||||
cur.execute(
|
||||
f"CREATE INDEX {index_name} ON {self.table_name} USING ann(vector) "
|
||||
f"WITH(dim='{embedding_dimension}', distancemeasure='{self.config.metrics}', "
|
||||
f"pq_enable=0)"
|
||||
f"pq_enable=0, external_storage=0)"
|
||||
)
|
||||
cur.execute(f"CREATE INDEX ON {self.table_name} USING gin(to_tsvector)")
|
||||
except Exception as e:
|
||||
|
||||
@@ -96,6 +96,10 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
|
||||
|
||||
def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]:
|
||||
return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json")
|
||||
|
||||
|
||||
class AgentComposerService:
|
||||
@classmethod
|
||||
def load_workflow_composer(
|
||||
@@ -419,7 +423,11 @@ class AgentComposerService:
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
agent.active_config_is_published = False
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
|
||||
@@ -430,6 +438,54 @@ class AgentComposerService:
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _agent_soul_matches_active_config(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> bool:
|
||||
if not agent.active_config_snapshot_id:
|
||||
return False
|
||||
|
||||
active_version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
if not active_version:
|
||||
return False
|
||||
if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
):
|
||||
return False
|
||||
|
||||
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
|
||||
|
||||
@classmethod
|
||||
def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool:
|
||||
revisions = db.session.scalars(
|
||||
select(AgentConfigRevision.operation).where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
AgentConfigRevision.current_snapshot_id == snapshot_id,
|
||||
)
|
||||
).all()
|
||||
|
||||
return any(
|
||||
operation
|
||||
in {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
}
|
||||
for operation in revisions
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def publish_agent_app_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
|
||||
@@ -557,16 +613,21 @@ class AgentComposerService:
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
|
||||
normal_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict),
|
||||
agent_soul=applied_agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
base_snapshot_id=build_draft.base_snapshot_id,
|
||||
)
|
||||
agent.active_config_is_published = False
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=applied_agent_soul,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
|
||||
@@ -992,9 +992,10 @@ class AgentRosterService:
|
||||
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
|
||||
"""Return each Agent's stored normal-draft publish state.
|
||||
|
||||
The flag is maintained by write paths: normal shared draft writes mark it
|
||||
dirty, while publish/version creation paths mark it clean. User-scoped
|
||||
debug drafts intentionally do not affect this state.
|
||||
The flag is maintained by write paths against the normal shared draft:
|
||||
saves compare the draft content with the active snapshot, while publish
|
||||
and version creation paths mark the new active snapshot clean.
|
||||
User-scoped debug drafts intentionally do not affect this state.
|
||||
"""
|
||||
agents = [agent for agent in agents if agent.id]
|
||||
if not agents:
|
||||
|
||||
@@ -435,7 +435,6 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
|
||||
"app.acl.delete",
|
||||
"app.acl.release_and_version",
|
||||
"app.acl.monitor",
|
||||
"app.acl.log_and_annotation",
|
||||
"app.acl.access_config",
|
||||
]
|
||||
|
||||
@@ -744,7 +743,6 @@ def _inner_call(
|
||||
account_id=account_id,
|
||||
json=json,
|
||||
params=params,
|
||||
timeout=dify_config.ENTERPRISE_RBAC_REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -680,46 +680,6 @@ class SnippetService:
|
||||
|
||||
return workflows, has_more
|
||||
|
||||
def update_workflow(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
snippet: CustomizedSnippet,
|
||||
workflow_id: str,
|
||||
account: Account,
|
||||
data: dict[str, Any],
|
||||
) -> Workflow | None:
|
||||
"""
|
||||
Update a published snippet workflow version's display metadata.
|
||||
|
||||
:param session: Database session
|
||||
:param snippet: CustomizedSnippet instance
|
||||
:param workflow_id: Workflow ID
|
||||
:param account: Account making the change
|
||||
:param data: Dictionary containing fields to update
|
||||
:return: Updated workflow or None if not found
|
||||
"""
|
||||
stmt = select(Workflow).where(
|
||||
Workflow.id == workflow_id,
|
||||
Workflow.tenant_id == snippet.tenant_id,
|
||||
Workflow.app_id == snippet.id,
|
||||
self._snippet_kind_filter(),
|
||||
Workflow.version != Workflow.VERSION_DRAFT,
|
||||
)
|
||||
workflow = session.scalar(stmt)
|
||||
if not workflow:
|
||||
return None
|
||||
|
||||
allowed_fields = {"marked_name", "marked_comment"}
|
||||
for field, value in data.items():
|
||||
if field in allowed_fields:
|
||||
setattr(workflow, field, value)
|
||||
|
||||
workflow.updated_by = account.id
|
||||
workflow.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
session.add(workflow)
|
||||
return workflow
|
||||
|
||||
# --- Default Block Configs ---
|
||||
|
||||
def get_default_block_configs(self) -> list[dict]:
|
||||
|
||||
@@ -40,7 +40,6 @@ from services.errors.app import QuotaExceededError
|
||||
from services.quota_service import QuotaService
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from services.workflow.entities import WebhookTriggerData
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
try:
|
||||
import magic
|
||||
@@ -115,7 +114,6 @@ class WebhookService:
|
||||
workflow = session.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == webhook_trigger.tenant_id,
|
||||
Workflow.app_id == webhook_trigger.app_id,
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
)
|
||||
@@ -127,7 +125,6 @@ class WebhookService:
|
||||
app_trigger = session.scalar(
|
||||
select(AppTrigger)
|
||||
.where(
|
||||
AppTrigger.tenant_id == webhook_trigger.tenant_id,
|
||||
AppTrigger.app_id == webhook_trigger.app_id,
|
||||
AppTrigger.node_id == webhook_trigger.node_id,
|
||||
AppTrigger.trigger_type == AppTriggerType.TRIGGER_WEBHOOK,
|
||||
@@ -148,18 +145,16 @@ class WebhookService:
|
||||
if app_trigger.status != AppTriggerStatus.ENABLED:
|
||||
raise ValueError(f"Webhook trigger is disabled for webhook {webhook_id}")
|
||||
|
||||
app = session.scalar(
|
||||
select(App)
|
||||
# Get workflow
|
||||
workflow = session.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
App.tenant_id == webhook_trigger.tenant_id,
|
||||
App.id == webhook_trigger.app_id,
|
||||
Workflow.app_id == webhook_trigger.app_id,
|
||||
Workflow.version != Workflow.VERSION_DRAFT,
|
||||
)
|
||||
.order_by(Workflow.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if not app:
|
||||
raise ValueError(f"App not found for webhook {webhook_id}")
|
||||
|
||||
workflow = WorkflowService().get_published_workflow(app, session=session)
|
||||
if not workflow:
|
||||
raise ValueError(f"Workflow not found for app {webhook_trigger.app_id}")
|
||||
|
||||
|
||||
@@ -333,7 +333,7 @@ class VectorService:
|
||||
|
||||
# Add documents to vector store if any
|
||||
if documents and dataset.is_multimodal:
|
||||
vector.create_multimodal(documents)
|
||||
vector.add_texts(documents, duplicate_check=True)
|
||||
|
||||
# Single commit for all operations
|
||||
db.session.commit()
|
||||
|
||||
@@ -12,19 +12,13 @@ createApp) rather than from inside another workflow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.llm_generator.llm_generator import LLMGenerator
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.model_manager import ModelManager
|
||||
from core.workflow.generator import WorkflowGenerator
|
||||
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue, installed_tool_keys
|
||||
from core.workflow.generator.types import (
|
||||
WorkflowGenerateResultDict,
|
||||
WorkflowGenerationMode,
|
||||
WorkflowGenerationModeRequest,
|
||||
)
|
||||
from core.workflow.generator.types import WorkflowGenerateResultDict, WorkflowGenerationMode
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -43,7 +37,7 @@ class WorkflowGeneratorService:
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
mode: WorkflowGenerationModeRequest,
|
||||
mode: WorkflowGenerationMode,
|
||||
instruction: str,
|
||||
model_config: ModelConfig,
|
||||
ideal_output: str = "",
|
||||
@@ -52,12 +46,6 @@ class WorkflowGeneratorService:
|
||||
"""
|
||||
Resolve a model instance for the tenant and run the generator.
|
||||
|
||||
``mode`` accepts the ``"auto"`` sentinel — when set, the instruction is
|
||||
classified into a concrete ``workflow`` / ``advanced-chat`` mode (one
|
||||
tiny LLM call) before planning so the rest of the pipeline runs against
|
||||
a concrete mode. The resolved mode is echoed back under the result's
|
||||
``mode`` key.
|
||||
|
||||
``current_graph`` is the existing draft graph for the cmd+k `/refine`
|
||||
flow — when present the generator refines it instead of creating a new
|
||||
graph from scratch. ``None`` is the `/create` path.
|
||||
@@ -66,109 +54,6 @@ class WorkflowGeneratorService:
|
||||
controller can map them to existing HTTP error envelopes (same
|
||||
envelope as ``/rule-generate``).
|
||||
"""
|
||||
resolved_mode = cls._resolve_mode(
|
||||
tenant_id=tenant_id, mode=mode, instruction=instruction, model_config=model_config
|
||||
)
|
||||
model_instance, model_parameters, tool_catalogue_text, installed_tools = cls._resolve_generation_context(
|
||||
tenant_id=tenant_id, model_config=model_config
|
||||
)
|
||||
|
||||
return WorkflowGenerator.generate_workflow_graph(
|
||||
model_instance=model_instance,
|
||||
model_parameters=model_parameters,
|
||||
provider=model_config.provider,
|
||||
model_name=model_config.name,
|
||||
model_mode=model_config.mode.value,
|
||||
mode=resolved_mode,
|
||||
instruction=instruction,
|
||||
ideal_output=ideal_output,
|
||||
tool_catalogue_text=tool_catalogue_text,
|
||||
installed_tools=installed_tools,
|
||||
current_graph=current_graph,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def generate_workflow_graph_stream(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
mode: WorkflowGenerationModeRequest,
|
||||
instruction: str,
|
||||
model_config: ModelConfig,
|
||||
ideal_output: str = "",
|
||||
current_graph: dict[str, Any] | None = None,
|
||||
) -> Iterator[tuple[str, dict[str, Any]]]:
|
||||
"""
|
||||
Streaming sibling of ``generate_workflow_graph``.
|
||||
|
||||
Resolves the same model instance / tool catalogue / concrete mode, then
|
||||
delegates to ``WorkflowGenerator.generate_workflow_graph_stream`` and
|
||||
yields its ``(event_name, payload)`` tuples through to the controller's
|
||||
SSE writer. Provider-init / invoke errors raised while resolving the
|
||||
model instance propagate to the caller (the controller emits them as a
|
||||
single ``result`` SSE event).
|
||||
"""
|
||||
resolved_mode = cls._resolve_mode(
|
||||
tenant_id=tenant_id, mode=mode, instruction=instruction, model_config=model_config
|
||||
)
|
||||
model_instance, model_parameters, tool_catalogue_text, installed_tools = cls._resolve_generation_context(
|
||||
tenant_id=tenant_id, model_config=model_config
|
||||
)
|
||||
|
||||
yield from WorkflowGenerator.generate_workflow_graph_stream(
|
||||
model_instance=model_instance,
|
||||
model_parameters=model_parameters,
|
||||
provider=model_config.provider,
|
||||
model_name=model_config.name,
|
||||
model_mode=model_config.mode.value,
|
||||
mode=resolved_mode,
|
||||
instruction=instruction,
|
||||
ideal_output=ideal_output,
|
||||
tool_catalogue_text=tool_catalogue_text,
|
||||
installed_tools=installed_tools,
|
||||
current_graph=current_graph,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _resolve_mode(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
mode: WorkflowGenerationModeRequest,
|
||||
instruction: str,
|
||||
model_config: ModelConfig,
|
||||
) -> WorkflowGenerationMode:
|
||||
"""Resolve the request mode into a concrete generation mode.
|
||||
|
||||
``"auto"`` triggers a one-word LLM classification using the model the
|
||||
user already picked; everything else passes through unchanged. The
|
||||
classifier never raises (defaults to ``advanced-chat``), so ``auto``
|
||||
never blocks generation.
|
||||
"""
|
||||
if mode == "auto":
|
||||
return LLMGenerator.classify_workflow_mode(
|
||||
tenant_id=tenant_id, instruction=instruction, model_config=model_config
|
||||
)
|
||||
return mode
|
||||
|
||||
@classmethod
|
||||
def _resolve_generation_context(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
model_config: ModelConfig,
|
||||
) -> tuple[ModelInstance, dict[str, Any], str, set[tuple[str, str]] | None]:
|
||||
"""Resolve the model instance, completion params, and tool catalogue.
|
||||
|
||||
Build the installed-tool catalogue for this tenant so the planner /
|
||||
builder can pick concrete tools instead of inventing names, AND so the
|
||||
runner's validator can reject hallucinated tool names BEFORE the user
|
||||
clicks Apply. A failure here (plugin daemon unreachable, etc.) must not
|
||||
block generation — log and fall back to the no-tool path, which also
|
||||
disables tool validation in the runner (``None`` sentinel rather than
|
||||
empty set, so we don't reject every tool node just because we couldn't
|
||||
enumerate the catalogue).
|
||||
"""
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
@@ -179,6 +64,14 @@ class WorkflowGeneratorService:
|
||||
|
||||
model_parameters: dict[str, Any] = dict(model_config.completion_params or {})
|
||||
|
||||
# Build the installed-tool catalogue for this tenant so the planner/
|
||||
# builder can pick concrete tools instead of inventing names, AND so
|
||||
# the runner's validator can reject hallucinated tool names BEFORE
|
||||
# the user clicks Apply. A failure here (plugin daemon unreachable,
|
||||
# etc.) must not block generation — log and fall back to the no-tool
|
||||
# path, which also disables tool validation in the runner (None
|
||||
# sentinel rather than empty set, so we don't reject every tool
|
||||
# node just because we couldn't enumerate the catalogue).
|
||||
tool_catalogue_text = ""
|
||||
installed_tools: set[tuple[str, str]] | None = None
|
||||
try:
|
||||
@@ -188,4 +81,16 @@ class WorkflowGeneratorService:
|
||||
except Exception:
|
||||
logger.exception("Workflow generator: failed to build tool catalogue for tenant %s", tenant_id)
|
||||
|
||||
return model_instance, model_parameters, tool_catalogue_text, installed_tools
|
||||
return WorkflowGenerator.generate_workflow_graph(
|
||||
model_instance=model_instance,
|
||||
model_parameters=model_parameters,
|
||||
provider=model_config.provider,
|
||||
model_name=model_config.name,
|
||||
model_mode=model_config.mode.value,
|
||||
mode=mode,
|
||||
instruction=instruction,
|
||||
ideal_output=ideal_output,
|
||||
tool_catalogue_text=tool_catalogue_text,
|
||||
installed_tools=installed_tools,
|
||||
current_graph=current_graph,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
@@ -35,7 +35,7 @@ from models.enums import (
|
||||
WorkflowRunTriggeredFrom,
|
||||
WorkflowTriggerStatus,
|
||||
)
|
||||
from models.model import App, EndUser
|
||||
from models.model import EndUser
|
||||
from models.provider_ids import TriggerProviderID
|
||||
from models.trigger import TriggerSubscription, WorkflowPluginTrigger, WorkflowTriggerLog
|
||||
from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun
|
||||
@@ -99,26 +99,24 @@ def dispatch_trigger_debug_event(
|
||||
return 0
|
||||
|
||||
|
||||
def _get_published_workflows_by_app_ids(
|
||||
def _get_latest_workflows_by_app_ids(
|
||||
session: Session, subscribers: Sequence[WorkflowPluginTrigger]
|
||||
) -> Mapping[str, Workflow]:
|
||||
"""Get current published workflows through apps.workflow_id."""
|
||||
app_ids = {trigger.app_id for trigger in subscribers}
|
||||
tenant_ids = {trigger.tenant_id for trigger in subscribers}
|
||||
if not app_ids or not tenant_ids:
|
||||
return {}
|
||||
|
||||
workflows = session.scalars(
|
||||
select(Workflow)
|
||||
.join(App, App.workflow_id == Workflow.id)
|
||||
"""Get the latest workflows by app_ids"""
|
||||
workflow_query = (
|
||||
select(Workflow.app_id, func.max(Workflow.created_at).label("max_created_at"))
|
||||
.where(
|
||||
App.id.in_(app_ids),
|
||||
App.tenant_id.in_(tenant_ids),
|
||||
App.workflow_id.isnot(None),
|
||||
Workflow.app_id == App.id,
|
||||
Workflow.tenant_id == App.tenant_id,
|
||||
Workflow.app_id.in_({t.app_id for t in subscribers}),
|
||||
Workflow.version != Workflow.VERSION_DRAFT,
|
||||
)
|
||||
.group_by(Workflow.app_id)
|
||||
.subquery()
|
||||
)
|
||||
workflows = session.scalars(
|
||||
select(Workflow).join(
|
||||
workflow_query,
|
||||
(Workflow.app_id == workflow_query.c.app_id) & (Workflow.created_at == workflow_query.c.max_created_at),
|
||||
)
|
||||
).all()
|
||||
return {w.app_id: w for w in workflows}
|
||||
|
||||
@@ -264,7 +262,7 @@ def dispatch_triggered_workflow(
|
||||
|
||||
# Ensure expire_on_commit is set to False to remain workflows available
|
||||
with session_factory.create_session() as session:
|
||||
workflows: Mapping[str, Workflow] = _get_published_workflows_by_app_ids(session, subscribers)
|
||||
workflows: Mapping[str, Workflow] = _get_latest_workflows_by_app_ids(session, subscribers)
|
||||
|
||||
end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch(
|
||||
type=EndUserType.TRIGGER,
|
||||
|
||||
@@ -127,9 +127,6 @@ class TestWebhookService:
|
||||
db_session_with_containers.add(workflow)
|
||||
db_session_with_containers.flush()
|
||||
|
||||
app.workflow_id = workflow.id
|
||||
db_session_with_containers.flush()
|
||||
|
||||
# Create webhook trigger
|
||||
webhook_id = fake.uuid4()[:16]
|
||||
webhook_trigger = WorkflowWebhookTrigger(
|
||||
|
||||
-35
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
@@ -241,40 +240,6 @@ class TestWebhookServiceLookupWithContainers:
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_uses_app_workflow_id(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
current_workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
newer_workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-15.001"
|
||||
)
|
||||
current_workflow.created_at = datetime(2026, 4, 14)
|
||||
newer_workflow.created_at = datetime(2026, 4, 15)
|
||||
app.workflow_id = current_workflow.id
|
||||
db_session_with_containers.commit()
|
||||
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.ENABLED
|
||||
)
|
||||
|
||||
got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow(
|
||||
webhook_trigger.webhook_id
|
||||
)
|
||||
|
||||
assert got_trigger.id == webhook_trigger.id
|
||||
assert got_workflow.id == current_workflow.id
|
||||
assert got_workflow.id != newer_workflow.id
|
||||
assert got_node_config["id"] == "node-1"
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_returns_debug_draft_workflow(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers: Flask
|
||||
):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
@@ -453,214 +452,3 @@ def test_workflow_generate_current_graph_defaults_to_none(app: Flask, monkeypatc
|
||||
method(api, "t1")
|
||||
|
||||
assert captured["current_graph"] is None
|
||||
|
||||
|
||||
def test_workflow_generate_accepts_auto_mode(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 3: the payload Literal must accept ``auto``; the controller forwards
|
||||
it unchanged (the service resolves it) and returns the resolved ``mode``."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
"mode": "advanced-chat",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
payload = _workflow_generate_payload()
|
||||
payload["mode"] = "auto"
|
||||
with app.test_request_context("/console/api/workflow-generate", method="POST", json=payload):
|
||||
response = method(api, "t1")
|
||||
|
||||
assert captured["mode"] == "auto"
|
||||
assert response["mode"] == "advanced-chat"
|
||||
|
||||
|
||||
# ─ /workflow-generate/suggestions ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_generate_instruction_suggestions_parses_and_cleans(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 1c (i): a mocked default model returning a JSON array is parsed + cleaned."""
|
||||
from core.llm_generator import llm_generator as llm_gen_module
|
||||
|
||||
instance = MagicMock()
|
||||
instance.invoke_llm.return_value.message.get_text_content.return_value = '["Summarize a URL", "Translate text"]'
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance
|
||||
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
|
||||
monkeypatch.setattr(llm_gen_module.LLMGenerator, "_build_suggestion_context", staticmethod(lambda _tenant: ""))
|
||||
|
||||
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(
|
||||
tenant_id="t1", mode="workflow", count=4
|
||||
)
|
||||
|
||||
assert result == ["Summarize a URL", "Translate text"]
|
||||
|
||||
|
||||
def test_generate_instruction_suggestions_dedupes_and_caps(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Whitespace / surrounding quotes are stripped, case-insensitive dupes dropped, capped to count."""
|
||||
from core.llm_generator import llm_generator as llm_gen_module
|
||||
|
||||
instance = MagicMock()
|
||||
instance.invoke_llm.return_value.message.get_text_content.return_value = (
|
||||
'[" Summarize a URL ", "summarize a URL", "\'Translate text\'", "Draft an email", "Extra idea"]'
|
||||
)
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance
|
||||
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
|
||||
monkeypatch.setattr(llm_gen_module.LLMGenerator, "_build_suggestion_context", staticmethod(lambda _tenant: ""))
|
||||
|
||||
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(
|
||||
tenant_id="t1", mode="advanced-chat", count=3
|
||||
)
|
||||
|
||||
assert result == ["Summarize a URL", "Translate text", "Draft an email"]
|
||||
|
||||
|
||||
def test_generate_instruction_suggestions_no_default_model_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 1c (ii): a missing default model degrades to an empty list, never raising."""
|
||||
from core.llm_generator import llm_generator as llm_gen_module
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.for_tenant.return_value.get_default_model_instance.side_effect = ProviderTokenNotInitError(
|
||||
"no default model"
|
||||
)
|
||||
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
|
||||
|
||||
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(tenant_id="t1", mode="workflow")
|
||||
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_workflow_instruction_suggestions_route_returns_list(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 1c (iii): the route wraps the generator output in {"suggestions": [...]}."""
|
||||
api = generator_module.WorkflowInstructionSuggestionsApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _suggest(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return ["Summarize a URL", "Translate text"]
|
||||
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "generate_workflow_instruction_suggestions", _suggest)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate/suggestions",
|
||||
method="POST",
|
||||
json={"mode": "workflow", "language": "French", "count": 3},
|
||||
):
|
||||
response = method(api, "t1")
|
||||
|
||||
assert response == {"suggestions": ["Summarize a URL", "Translate text"]}
|
||||
assert captured["mode"] == "workflow"
|
||||
assert captured["language"] == "French"
|
||||
assert captured["count"] == 3
|
||||
|
||||
|
||||
def test_workflow_instruction_suggestions_route_empty_is_valid_200(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 1c (iii): an empty list is a valid soft-fail response."""
|
||||
api = generator_module.WorkflowInstructionSuggestionsApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
monkeypatch.setattr(
|
||||
generator_module.LLMGenerator,
|
||||
"generate_workflow_instruction_suggestions",
|
||||
lambda **_kwargs: [],
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate/suggestions",
|
||||
method="POST",
|
||||
json={"mode": "advanced-chat"},
|
||||
):
|
||||
response = method(api, "t1")
|
||||
|
||||
assert response == {"suggestions": []}
|
||||
|
||||
|
||||
# ─ /workflow-generate/stream ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _read_sse_frames(response) -> list[dict]:
|
||||
"""Decode an SSE Response body into its parsed ``data:`` JSON frames."""
|
||||
body = response.get_data(as_text=True)
|
||||
frames = []
|
||||
for chunk in body.strip().split("\n\n"):
|
||||
chunk = chunk.strip()
|
||||
if chunk.startswith("data: "):
|
||||
frames.append(json.loads(chunk[len("data: ") :]))
|
||||
return frames
|
||||
|
||||
|
||||
def test_workflow_generate_stream_emits_plan_then_result(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 2c: the stream endpoint writes one SSE frame per service event."""
|
||||
api = generator_module.WorkflowGenerateStreamApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
def _stream(**_kwargs):
|
||||
yield ("plan", {"title": "Summarizer", "mode": "workflow", "nodes": []})
|
||||
yield ("result", {"graph": {"nodes": []}, "error": "", "mode": "workflow"})
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate/stream",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
response = method(api, "t1")
|
||||
assert response.mimetype == "text/event-stream"
|
||||
frames = _read_sse_frames(response)
|
||||
|
||||
assert [f["event"] for f in frames] == ["plan", "result"]
|
||||
assert frames[0]["title"] == "Summarizer"
|
||||
assert frames[1]["mode"] == "workflow"
|
||||
|
||||
|
||||
def test_workflow_generate_stream_provider_error_emits_result_event(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Task 2c: a provider-init error becomes a single MODEL_ERROR result frame, not a non-SSE error."""
|
||||
api = generator_module.WorkflowGenerateStreamApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
def _stream(**_kwargs):
|
||||
raise ProviderTokenNotInitError("missing token")
|
||||
yield # pragma: no cover - marks this a generator
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate/stream",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
response = method(api, "t1")
|
||||
frames = _read_sse_frames(response)
|
||||
|
||||
assert len(frames) == 1
|
||||
assert frames[0]["event"] == "result"
|
||||
assert frames[0]["errors"][0]["code"] == "MODEL_ERROR"
|
||||
assert frames[0]["graph"]["nodes"] == []
|
||||
|
||||
|
||||
def test_workflow_generate_stream_rejects_empty_instruction(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 2c: empty instructions get a normal 400 JSON BEFORE the stream opens."""
|
||||
api = generator_module.WorkflowGenerateStreamApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = _workflow_generate_payload()
|
||||
payload["instruction"] = " "
|
||||
with app.test_request_context("/console/api/workflow-generate/stream", method="POST", json=payload):
|
||||
response, status = method(api, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["errors"][0]["code"] == "EMPTY_INSTRUCTION"
|
||||
|
||||
@@ -361,117 +361,6 @@ def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_gra
|
||||
assert exc.value.description == "invalid snippet workflow graph"
|
||||
|
||||
|
||||
def test_update_published_snippet_workflow_returns_updated_workflow(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workflow = SimpleNamespace(
|
||||
id="workflow-1",
|
||||
graph_dict={"nodes": [], "edges": []},
|
||||
features_dict={},
|
||||
unique_hash="hash-1",
|
||||
version="2024-01-01 00:00:00",
|
||||
marked_name="v1",
|
||||
marked_comment="first version",
|
||||
created_by_account=None,
|
||||
created_at=datetime(2024, 1, 1),
|
||||
updated_by_account=None,
|
||||
updated_at=datetime(2024, 1, 1),
|
||||
tool_published=False,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
user = _account("account-1")
|
||||
input_fields = [{"variable": "query", "type": "text"}]
|
||||
snippet = _snippet(input_fields=json.dumps(input_fields))
|
||||
session = SimpleNamespace()
|
||||
update_workflow = Mock(return_value=workflow)
|
||||
|
||||
class TransactionContext:
|
||||
def __enter__(self):
|
||||
return session
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
class SessionMaker:
|
||||
def begin(self):
|
||||
return TransactionContext()
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker()))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(update_workflow=update_workflow),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetWorkflowByIdApi()
|
||||
handler = unwrap(api.patch)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/workflow-1",
|
||||
method="PATCH",
|
||||
json={"marked_name": "v1", "marked_comment": "first version"},
|
||||
):
|
||||
response = handler(api, user, snippet, workflow_id="workflow-1")
|
||||
|
||||
update_workflow.assert_called_once_with(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
workflow_id="workflow-1",
|
||||
account=user,
|
||||
data={"marked_name": "v1", "marked_comment": "first version"},
|
||||
)
|
||||
assert response["marked_name"] == "v1"
|
||||
assert response["marked_comment"] == "first version"
|
||||
assert response["input_fields"] == input_fields
|
||||
|
||||
|
||||
def test_update_published_snippet_workflow_returns_400_when_no_fields(app: Flask) -> None:
|
||||
api = snippet_workflow_module.SnippetWorkflowByIdApi()
|
||||
handler = unwrap(api.patch)
|
||||
|
||||
with app.test_request_context("/snippets/snippet-1/workflows/workflow-1", method="PATCH", json={}):
|
||||
response, status_code = handler(api, _account("account-1"), _snippet(), workflow_id="workflow-1")
|
||||
|
||||
assert status_code == 400
|
||||
assert response == {"message": "No valid fields to update"}
|
||||
|
||||
|
||||
def test_update_published_snippet_workflow_raises_not_found(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
user = _account("account-1")
|
||||
snippet = _snippet()
|
||||
|
||||
class TransactionContext:
|
||||
def __enter__(self):
|
||||
return SimpleNamespace()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
class SessionMaker:
|
||||
def begin(self):
|
||||
return TransactionContext()
|
||||
|
||||
monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=SessionMaker()))
|
||||
monkeypatch.setattr(
|
||||
snippet_workflow_module,
|
||||
"SnippetService",
|
||||
lambda: SimpleNamespace(update_workflow=Mock(return_value=None)),
|
||||
)
|
||||
|
||||
api = snippet_workflow_module.SnippetWorkflowByIdApi()
|
||||
handler = unwrap(api.patch)
|
||||
|
||||
with app.test_request_context(
|
||||
"/snippets/snippet-1/workflows/missing-workflow",
|
||||
method="PATCH",
|
||||
json={"marked_name": "v1"},
|
||||
):
|
||||
with pytest.raises(NotFound, match="Workflow not found"):
|
||||
handler(api, user, snippet, workflow_id="missing-workflow")
|
||||
|
||||
|
||||
def test_workflow_run_detail_raises_not_found_when_run_missing(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
snippet = _snippet()
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -155,36 +155,6 @@ class TestTagListApi:
|
||||
assert result["name"] == "test-tag"
|
||||
assert result["binding_count"] == "0"
|
||||
|
||||
def test_post_snippet_tag_checks_snippet_rbac_when_enabled(self, app: Flask, admin_user, tag, payload_patch):
|
||||
api = TagListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
payload = {"name": "snippet-tag", "type": "snippet"}
|
||||
|
||||
with app.test_request_context("/", json=payload):
|
||||
with (
|
||||
payload_patch(payload),
|
||||
patch("controllers.console.tag.tags.dify_config.RBAC_ENABLED", True),
|
||||
patch(
|
||||
"controllers.console.tag.tags.current_account_with_tenant",
|
||||
return_value=(SimpleNamespace(id="user-1"), "tenant-1"),
|
||||
),
|
||||
patch("controllers.console.tag.tags.enforce_rbac_access") as enforce_mock,
|
||||
patch(
|
||||
"controllers.console.tag.tags.TagService.save_tags",
|
||||
return_value=tag,
|
||||
),
|
||||
):
|
||||
method(api, admin_user)
|
||||
|
||||
enforce_mock.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
account_id="user-1",
|
||||
resource_type=module.RBACResourceScope.WORKSPACE,
|
||||
scene=module.RBACPermission.SNIPPETS_CREATE_AND_MODIFY,
|
||||
resource_required=False,
|
||||
)
|
||||
|
||||
def test_post_forbidden(self, app: Flask, readonly_user, payload_patch):
|
||||
api = TagListApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
@@ -3,7 +3,7 @@ Unit tests for Service API wraps (authentication decorators)
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
@@ -469,10 +469,7 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.FeatureService.get_knowledge_rate_limit")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch("controllers.service_api.wraps.sessionmaker")
|
||||
def test_rejects_over_rate_limit(
|
||||
self, mock_sessionmaker, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask
|
||||
):
|
||||
def test_rejects_over_rate_limit(self, mock_db, mock_get_rate_limit, mock_validate_token, app: Flask):
|
||||
"""Test that Forbidden is raised when over rate limit."""
|
||||
# Arrange
|
||||
mock_validate_token.return_value = Mock(tenant_id="tenant123")
|
||||
@@ -482,10 +479,6 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
mock_rate_limit.limit = 10
|
||||
mock_rate_limit.subscription_plan = "pro"
|
||||
mock_get_rate_limit.return_value = mock_rate_limit
|
||||
rate_limit_log_session = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.begin.return_value.__enter__.return_value = rate_limit_log_session
|
||||
mock_sessionmaker.return_value = session_factory
|
||||
|
||||
with patch("controllers.service_api.wraps.redis_client") as mock_redis:
|
||||
mock_redis.zcard.return_value = 15 # Over limit
|
||||
@@ -499,9 +492,6 @@ class TestCloudEditionBillingRateLimitCheck:
|
||||
with pytest.raises(Forbidden) as exc_info:
|
||||
knowledge_request()
|
||||
assert "rate limit" in str(exc_info.value)
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
rate_limit_log_session.add.assert_called_once()
|
||||
mock_db.session.commit.assert_not_called()
|
||||
|
||||
|
||||
class TestValidateDatasetToken:
|
||||
|
||||
@@ -683,75 +683,3 @@ class TestLLMGenerator:
|
||||
"tenant_id", "flow_id", "current", "instruction", model_config_entity, "ideal"
|
||||
)
|
||||
assert "An unexpected error occurred" in result["error"]
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager")
|
||||
def test_generate_workflow_instruction_suggestions_success(self, mock_model_manager):
|
||||
mock_model_instance = MagicMock()
|
||||
mock_model_manager.for_tenant.return_value.get_default_model_instance.return_value = mock_model_instance
|
||||
|
||||
mock_response = MagicMock(spec=LLMResult)
|
||||
mock_response.message.get_text_content.return_value = '["Idea 1", "Idea 2", "Idea 3", "Idea 4"]'
|
||||
mock_model_instance.invoke_llm.return_value = mock_response
|
||||
|
||||
with patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context", return_value="context"):
|
||||
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant_id", mode="workflow", language="en", count=4)
|
||||
|
||||
assert result == ["Idea 1", "Idea 2", "Idea 3", "Idea 4"]
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager")
|
||||
def test_generate_workflow_instruction_suggestions_no_model(self, mock_model_manager):
|
||||
mock_model_manager.for_tenant.return_value.get_default_model_instance.side_effect = Exception("No default model")
|
||||
|
||||
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant_id", mode="workflow")
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager")
|
||||
def test_generate_workflow_instruction_suggestions_invoke_error(self, mock_model_manager):
|
||||
mock_model_instance = MagicMock()
|
||||
mock_model_manager.for_tenant.return_value.get_default_model_instance.return_value = mock_model_instance
|
||||
mock_model_instance.invoke_llm.side_effect = Exception("Invoke error")
|
||||
|
||||
with patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context", return_value="context"):
|
||||
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant_id", mode="workflow")
|
||||
|
||||
assert result == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager")
|
||||
def test_generate_workflow_instruction_suggestions_with_chatflow(self, mock_model_manager):
|
||||
mock_model_instance = MagicMock()
|
||||
mock_model_manager.for_tenant.return_value.get_default_model_instance.return_value = mock_model_instance
|
||||
|
||||
mock_response = MagicMock(spec=LLMResult)
|
||||
mock_response.message.get_text_content.return_value = '["Idea 1", "Idea 2"]'
|
||||
mock_model_instance.invoke_llm.return_value = mock_response
|
||||
|
||||
with patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context", return_value="context"):
|
||||
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant_id", mode="advanced-chat", count=2)
|
||||
|
||||
assert result == ["Idea 1", "Idea 2"]
|
||||
|
||||
@patch("core.llm_generator.llm_generator.db")
|
||||
@patch("core.llm_generator.llm_generator.build_tool_catalogue")
|
||||
@patch("core.llm_generator.llm_generator.format_tool_catalogue")
|
||||
def test_build_suggestion_context_success(self, mock_format, mock_build, mock_db):
|
||||
mock_db.session.scalars.return_value.all.return_value = ["kb1", "kb2"]
|
||||
mock_format.return_value = "tool1\ntool2"
|
||||
|
||||
result = LLMGenerator._build_suggestion_context("tenant_id")
|
||||
|
||||
assert "Knowledge bases:" in result
|
||||
assert "- kb1" in result
|
||||
assert "- kb2" in result
|
||||
assert "Installed tools:" in result
|
||||
assert "tool1" in result
|
||||
|
||||
@patch("core.llm_generator.llm_generator.db")
|
||||
@patch("core.llm_generator.llm_generator.build_tool_catalogue")
|
||||
def test_build_suggestion_context_errors(self, mock_build, mock_db):
|
||||
mock_db.session.scalars.side_effect = Exception("DB Error")
|
||||
mock_build.side_effect = Exception("Tool Error")
|
||||
|
||||
result = LLMGenerator._build_suggestion_context("tenant_id")
|
||||
|
||||
assert result == ""
|
||||
|
||||
@@ -43,57 +43,6 @@ class TestMCPClient:
|
||||
assert client.timeout is None
|
||||
assert client.sse_read_timeout is None
|
||||
|
||||
def test_init_with_dynamic_request_headers(self):
|
||||
"""Test client initialization with dynamic request headers."""
|
||||
from flask import Flask
|
||||
|
||||
app = Flask("test")
|
||||
with app.test_request_context(headers={"X-Custom-Auth": "my-secret-token", "X-User-Id": "user123"}):
|
||||
client = MCPClient(
|
||||
server_url="http://test.example.com",
|
||||
headers={
|
||||
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
|
||||
"X-Request-User": "{{request.header.X-User-Id}}",
|
||||
"X-Static-Header": "static-val",
|
||||
},
|
||||
)
|
||||
|
||||
assert client.headers == {
|
||||
"Authorization": "Bearer my-secret-token",
|
||||
"X-Request-User": "user123",
|
||||
"X-Static-Header": "static-val",
|
||||
}
|
||||
|
||||
def test_init_with_dynamic_request_headers_missing(self):
|
||||
"""Test client initialization with dynamic request headers that are missing."""
|
||||
from flask import Flask
|
||||
|
||||
app = Flask("test")
|
||||
with app.test_request_context(headers={}):
|
||||
client = MCPClient(
|
||||
server_url="http://test.example.com",
|
||||
headers={
|
||||
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
|
||||
},
|
||||
)
|
||||
|
||||
assert client.headers == {
|
||||
"Authorization": "Bearer ",
|
||||
}
|
||||
|
||||
def test_init_with_dynamic_request_headers_no_context(self):
|
||||
"""Test client initialization with dynamic request headers but no request context."""
|
||||
client = MCPClient(
|
||||
server_url="http://test.example.com",
|
||||
headers={
|
||||
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
|
||||
},
|
||||
)
|
||||
|
||||
assert client.headers == {
|
||||
"Authorization": "Bearer {{request.headers.X-Custom-Auth}}",
|
||||
}
|
||||
|
||||
@patch("core.mcp.mcp_client.streamablehttp_client")
|
||||
@patch("core.mcp.mcp_client.ClientSession")
|
||||
def test_initialize_with_mcp_url(self, mock_client_session, mock_streamable_client):
|
||||
|
||||
@@ -3888,17 +3888,7 @@ class TestDatasetRetrievalAdditionalHelpers:
|
||||
trace_manager.add_trace_task.assert_not_called()
|
||||
|
||||
def test_on_query(self, retrieval: DatasetRetrieval) -> None:
|
||||
db_mock = Mock()
|
||||
audit_session = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.begin.return_value.__enter__.return_value = audit_session
|
||||
|
||||
with (
|
||||
patch("core.rag.retrieval.dataset_retrieval.db", db_mock),
|
||||
patch(
|
||||
"core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=session_factory
|
||||
) as sessionmaker_mock,
|
||||
):
|
||||
with patch("core.rag.retrieval.dataset_retrieval.db.session") as mock_session:
|
||||
retrieval._on_query(
|
||||
query=None,
|
||||
attachment_ids=None,
|
||||
@@ -3907,7 +3897,7 @@ class TestDatasetRetrievalAdditionalHelpers:
|
||||
user_from="account",
|
||||
user_id="u1",
|
||||
)
|
||||
audit_session.add_all.assert_not_called()
|
||||
mock_session.add_all.assert_not_called()
|
||||
|
||||
retrieval._on_query(
|
||||
query="python",
|
||||
@@ -3917,22 +3907,11 @@ class TestDatasetRetrievalAdditionalHelpers:
|
||||
user_from="account",
|
||||
user_id="u1",
|
||||
)
|
||||
sessionmaker_mock.assert_called_once_with(bind=db_mock.engine, expire_on_commit=False)
|
||||
audit_session.add_all.assert_called_once()
|
||||
added_queries = audit_session.add_all.call_args.args[0]
|
||||
assert len(added_queries) == 2
|
||||
db_mock.session.commit.assert_not_called()
|
||||
mock_session.add_all.assert_called()
|
||||
mock_session.commit.assert_called()
|
||||
|
||||
def test_on_query_normalizes_workflow_end_user_role(self, retrieval: DatasetRetrieval) -> None:
|
||||
db_mock = Mock()
|
||||
audit_session = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.begin.return_value.__enter__.return_value = audit_session
|
||||
|
||||
with (
|
||||
patch("core.rag.retrieval.dataset_retrieval.db", db_mock),
|
||||
patch("core.rag.retrieval.dataset_retrieval.sessionmaker", return_value=session_factory),
|
||||
):
|
||||
with patch("core.rag.retrieval.dataset_retrieval.db.session") as mock_session:
|
||||
retrieval._on_query(
|
||||
query="python",
|
||||
attachment_ids=None,
|
||||
@@ -3942,11 +3921,12 @@ class TestDatasetRetrievalAdditionalHelpers:
|
||||
user_id="u1",
|
||||
)
|
||||
|
||||
audit_session.add_all.assert_called_once()
|
||||
added_queries = audit_session.add_all.call_args.args[0]
|
||||
mock_session.add_all.assert_called_once()
|
||||
added_queries = mock_session.add_all.call_args.args[0]
|
||||
|
||||
assert len(added_queries) == 1
|
||||
assert added_queries[0].created_by_role == CreatorUserRole.END_USER
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
def test_handle_invoke_result(self, retrieval: DatasetRetrieval) -> None:
|
||||
usage = LLMUsage.empty_usage()
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Generator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -131,25 +131,18 @@ def test_create_message_files_and_invoke_generator():
|
||||
created.append(obj)
|
||||
return obj
|
||||
|
||||
file_session = MagicMock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.begin.return_value.__enter__.return_value = file_session
|
||||
with (
|
||||
patch("core.tools.tool_engine.MessageFile", side_effect=_message_file_factory),
|
||||
patch("core.tools.tool_engine.db") as mock_db,
|
||||
patch("core.tools.tool_engine.sessionmaker", return_value=session_factory) as mock_sessionmaker,
|
||||
):
|
||||
ids = ToolEngine._create_message_files(
|
||||
tool_messages=binaries,
|
||||
agent_message=SimpleNamespace(id="msg-1"),
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
user_id="user-1",
|
||||
)
|
||||
with patch("core.tools.tool_engine.MessageFile", side_effect=_message_file_factory):
|
||||
with patch("core.tools.tool_engine.db") as mock_db:
|
||||
ids = ToolEngine._create_message_files(
|
||||
tool_messages=binaries,
|
||||
agent_message=SimpleNamespace(id="msg-1"),
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
assert ids == ["mf-1", "mf-2"]
|
||||
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
|
||||
assert file_session.add.call_count == 2
|
||||
mock_db.session.close.assert_not_called()
|
||||
assert mock_db.session.add.call_count == 2
|
||||
mock_db.session.close.assert_called_once()
|
||||
|
||||
tool = _build_tool()
|
||||
invoked = list(ToolEngine._invoke(tool, {"a": 1}, user_id="u"))
|
||||
|
||||
@@ -2921,168 +2921,3 @@ class TestWorkflowGeneratorDuplicateNodeIds:
|
||||
|
||||
codes = {e["code"] for e in result["errors"]}
|
||||
assert "DUPLICATE_NODE_ID" in codes
|
||||
|
||||
|
||||
def _stream_planner_json() -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"title": "URL Summarizer",
|
||||
"description": "Fetch a URL, summarize it, return the summary.",
|
||||
"app_name": "Summarizer",
|
||||
"icon": "🔗",
|
||||
"nodes": [
|
||||
{"label": "Start", "node_type": "start", "purpose": "User submits URL."},
|
||||
{"label": "Summarize", "node_type": "llm", "purpose": "Summarize the page."},
|
||||
{"label": "End", "node_type": "end", "purpose": "Return summary."},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _stream_builder_json() -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node1",
|
||||
"type": "custom",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {"type": "start", "title": "Start", "desc": "", "variables": []},
|
||||
},
|
||||
{
|
||||
"id": "node2",
|
||||
"type": "custom",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"type": "llm",
|
||||
"title": "Summarize",
|
||||
"desc": "",
|
||||
"prompt_template": [{"role": "user", "text": "{{#node1.url#}}"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "node3",
|
||||
"type": "custom",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"type": "end",
|
||||
"title": "End",
|
||||
"desc": "",
|
||||
"outputs": [{"variable": "summary", "value_selector": ["node2", "text"]}],
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "x", "source": "node1", "target": "node2", "type": "custom"},
|
||||
{"id": "y", "source": "node2", "target": "node3", "type": "custom"},
|
||||
],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowGeneratorStream:
|
||||
"""``generate_workflow_graph_stream`` yields a ``plan`` event then a ``result`` event."""
|
||||
|
||||
def test_stream_emits_plan_then_result(self):
|
||||
model_instance = MagicMock()
|
||||
model_instance.invoke_llm.side_effect = [
|
||||
_llm_result(_stream_planner_json()),
|
||||
_llm_result(_stream_builder_json()),
|
||||
]
|
||||
|
||||
events = list(
|
||||
WorkflowGenerator.generate_workflow_graph_stream(
|
||||
model_instance=model_instance,
|
||||
model_parameters={},
|
||||
provider="openai",
|
||||
model_name="gpt-4o",
|
||||
model_mode="chat",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
)
|
||||
)
|
||||
|
||||
assert [name for name, _ in events] == ["plan", "result"]
|
||||
|
||||
plan = events[0][1]
|
||||
assert plan["title"] == "URL Summarizer"
|
||||
assert plan["app_name"] == "Summarizer"
|
||||
assert plan["mode"] == "workflow"
|
||||
assert [n["node_type"] for n in plan["nodes"]] == ["start", "llm", "end"]
|
||||
assert plan["nodes"][0]["label"] == "Start"
|
||||
assert plan["nodes"][0]["purpose"] == "User submits URL."
|
||||
|
||||
result = events[1][1]
|
||||
assert result["error"] == ""
|
||||
assert result["mode"] == "workflow"
|
||||
assert [n["data"]["type"] for n in result["graph"]["nodes"]] == ["start", "llm", "end"]
|
||||
|
||||
def test_stream_planner_failure_emits_only_result(self):
|
||||
model_instance = MagicMock()
|
||||
model_instance.invoke_llm.side_effect = RuntimeError("planner exploded")
|
||||
|
||||
events = list(
|
||||
WorkflowGenerator.generate_workflow_graph_stream(
|
||||
model_instance=model_instance,
|
||||
model_parameters={},
|
||||
provider="openai",
|
||||
model_name="gpt-4o",
|
||||
model_mode="chat",
|
||||
mode="workflow",
|
||||
instruction="x",
|
||||
)
|
||||
)
|
||||
|
||||
assert [name for name, _ in events] == ["result"]
|
||||
result = events[0][1]
|
||||
assert "planner exploded" in result["error"]
|
||||
assert result["graph"]["nodes"] == []
|
||||
assert result["mode"] == "workflow"
|
||||
|
||||
def test_stream_and_blocking_results_match(self):
|
||||
"""The streaming ``result`` event must equal the blocking return value."""
|
||||
stream_instance = MagicMock()
|
||||
stream_instance.invoke_llm.side_effect = [
|
||||
_llm_result(_stream_planner_json()),
|
||||
_llm_result(_stream_builder_json()),
|
||||
]
|
||||
blocking_instance = MagicMock()
|
||||
blocking_instance.invoke_llm.side_effect = [
|
||||
_llm_result(_stream_planner_json()),
|
||||
_llm_result(_stream_builder_json()),
|
||||
]
|
||||
|
||||
kwargs = {
|
||||
"model_parameters": {},
|
||||
"provider": "openai",
|
||||
"model_name": "gpt-4o",
|
||||
"model_mode": "chat",
|
||||
"mode": "advanced-chat",
|
||||
"instruction": "Greet me",
|
||||
}
|
||||
stream_events = list(WorkflowGenerator.generate_workflow_graph_stream(model_instance=stream_instance, **kwargs))
|
||||
stream_result = next(payload for name, payload in stream_events if name == "result")
|
||||
blocking_result = WorkflowGenerator.generate_workflow_graph(model_instance=blocking_instance, **kwargs)
|
||||
|
||||
assert stream_result == blocking_result
|
||||
|
||||
def test_blocking_result_includes_resolved_mode(self):
|
||||
"""Task 3: the non-streaming envelope carries the resolved ``mode`` too."""
|
||||
model_instance = MagicMock()
|
||||
model_instance.invoke_llm.side_effect = [
|
||||
_llm_result(_stream_planner_json()),
|
||||
_llm_result(_stream_builder_json()),
|
||||
]
|
||||
|
||||
result = WorkflowGenerator.generate_workflow_graph(
|
||||
model_instance=model_instance,
|
||||
model_parameters={},
|
||||
provider="openai",
|
||||
model_name="gpt-4o",
|
||||
model_mode="chat",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
)
|
||||
|
||||
assert result["mode"] == "workflow"
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Tests for the uuidv7 SQL migration's PostgreSQL 18 compatibility guard.
|
||||
|
||||
The migration file name is not a valid Python identifier (it starts with a date and
|
||||
contains hyphens), so it is loaded directly from its path. The ``models`` import at the
|
||||
top of the migration is stubbed because the migration never uses it during
|
||||
``upgrade()``/``downgrade()`` and pulling in the real package would require a full app
|
||||
context.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "migrations"
|
||||
/ "versions"
|
||||
/ "2025_07_02_2332-1c9ba48be8e4_add_uuidv7_function_in_sql.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_migration():
|
||||
# The migration does `import models as models` but never references it, so a stub is
|
||||
# enough and keeps the test free of any database/app configuration.
|
||||
sys.modules.setdefault("models", types.ModuleType("models"))
|
||||
spec = importlib.util.spec_from_file_location("uuidv7_pg18_migration_under_test", MIGRATION_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _make_bind(dialect_name):
|
||||
bind = mock.MagicMock()
|
||||
bind.dialect.name = dialect_name
|
||||
return bind
|
||||
|
||||
|
||||
def _executed_sql(fake_op):
|
||||
return [str(call.args[0]) for call in fake_op.execute.call_args_list]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def migration():
|
||||
return _load_migration()
|
||||
|
||||
|
||||
def test_upgrade_creates_both_functions_when_native_uuidv7_absent(migration):
|
||||
# PostgreSQL 13 to 17: no native pg_catalog.uuidv7(), so both functions are created.
|
||||
# The DO block contains the CREATE FUNCTION guarded by IF NOT EXISTS, and
|
||||
# uuidv7_boundary is created unconditionally.
|
||||
bind = _make_bind("postgresql")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.upgrade()
|
||||
|
||||
sql = _executed_sql(fake_op)
|
||||
assert any("CREATE FUNCTION public.uuidv7()" in stmt for stmt in sql)
|
||||
assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql)
|
||||
|
||||
|
||||
def test_upgrade_skips_uuidv7_but_keeps_boundary_when_native_present(migration):
|
||||
# PostgreSQL 18: native pg_catalog.uuidv7() exists, so the DO block must guard
|
||||
# the CREATE FUNCTION with an IF NOT EXISTS check against pg_catalog.
|
||||
# uuidv7_boundary is still missing and has to be created unconditionally.
|
||||
bind = _make_bind("postgresql")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.upgrade()
|
||||
|
||||
sql = _executed_sql(fake_op)
|
||||
# The DO block must contain the pg_catalog existence check.
|
||||
do_block = next((stmt for stmt in sql if "DO $do$" in stmt), None)
|
||||
assert do_block is not None
|
||||
assert "pg_catalog" in do_block
|
||||
assert "uuidv7" in do_block
|
||||
assert "IF NOT EXISTS" in do_block
|
||||
# uuidv7_boundary is always created (not guarded by the DO block).
|
||||
assert any("CREATE FUNCTION public.uuidv7_boundary(timestamptz)" in stmt for stmt in sql)
|
||||
|
||||
|
||||
def test_upgrade_is_noop_on_non_postgres(migration):
|
||||
bind = _make_bind("sqlite")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.upgrade()
|
||||
|
||||
fake_op.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_downgrade_uses_if_exists_and_public_schema(migration):
|
||||
bind = _make_bind("postgresql")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.downgrade()
|
||||
|
||||
sql = _executed_sql(fake_op)
|
||||
assert "DROP FUNCTION IF EXISTS public.uuidv7()" in sql
|
||||
assert "DROP FUNCTION IF EXISTS public.uuidv7_boundary(timestamptz)" in sql
|
||||
|
||||
|
||||
def test_downgrade_is_noop_on_non_postgres(migration):
|
||||
bind = _make_bind("sqlite")
|
||||
with mock.patch.object(migration, "op") as fake_op:
|
||||
fake_op.get_bind.return_value = bind
|
||||
migration.downgrade()
|
||||
|
||||
fake_op.execute.assert_not_called()
|
||||
@@ -437,10 +437,12 @@ def test_save_agent_app_composer_rejects_version_save_strategy():
|
||||
def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
updated_by=None,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent])
|
||||
saved = {}
|
||||
|
||||
@@ -451,6 +453,7 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
|
||||
"_save_agent_draft",
|
||||
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
@@ -473,6 +476,43 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = _agent_soul_with_model()
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=False,
|
||||
updated_by=None,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=agent_soul.model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent], scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_save_agent_draft",
|
||||
lambda **_kwargs: SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"agent_soul": agent_soul.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
|
||||
)
|
||||
|
||||
assert agent.active_config_is_published is True
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
@@ -565,7 +605,11 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
|
||||
assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict
|
||||
assert fake_session.commits == 1
|
||||
|
||||
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft])
|
||||
active_version = SimpleNamespace(config_snapshot_dict=build_draft.config_snapshot_dict)
|
||||
fake_session = FakeSession(
|
||||
scalar=[agent, build_draft, normal_draft, active_version],
|
||||
scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]],
|
||||
)
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
|
||||
applied = AgentComposerService.apply_agent_app_build_draft(
|
||||
@@ -576,6 +620,62 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
|
||||
|
||||
assert applied["result"] == "success"
|
||||
assert applied["draft"]["id"] == normal_draft.id
|
||||
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
|
||||
assert agent.active_config_is_published is True
|
||||
assert fake_session.deleted == [build_draft]
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_agent_app_build_draft_apply_marks_unpublished_when_build_draft_differs(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Iris",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
active_agent_soul = _agent_soul_with_model()
|
||||
build_agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
**active_agent_soul.model_dump(mode="json"),
|
||||
"prompt": {
|
||||
"system_prompt": "Build draft prompt",
|
||||
},
|
||||
}
|
||||
)
|
||||
build_draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id="account-1",
|
||||
draft_owner_key="account-1",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=build_agent_soul,
|
||||
)
|
||||
normal_draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=active_agent_soul,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=active_agent_soul.model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft, active_version])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
|
||||
AgentComposerService.apply_agent_app_build_draft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
|
||||
assert agent.active_config_is_published is False
|
||||
assert fake_session.deleted == [build_draft]
|
||||
@@ -1611,6 +1711,52 @@ def test_composer_create_roster_agent_raises_when_backing_agent_missing(monkeypa
|
||||
)
|
||||
|
||||
|
||||
def test_agent_app_draft_match_does_not_mark_create_version_as_published(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = AgentSoulConfig()
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
|
||||
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.CREATE_VERSION]])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
|
||||
|
||||
assert (
|
||||
AgentComposerService._agent_soul_matches_active_config(
|
||||
tenant_id="tenant-1",
|
||||
agent=agent,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_agent_app_draft_match_marks_publish_visible_revision_as_published(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = AgentSoulConfig()
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
|
||||
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
|
||||
|
||||
assert (
|
||||
AgentComposerService._agent_soul_matches_active_config(
|
||||
tenant_id="tenant-1",
|
||||
agent=agent,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch):
|
||||
fake_session = FakeSession(
|
||||
scalar=[
|
||||
|
||||
@@ -633,8 +633,6 @@ class TestMyPermissions:
|
||||
assert "dataset.acl.preview" in out.workspace.permission_keys
|
||||
assert "app.acl.preview" in out.app.default_permission_keys
|
||||
assert "dataset.acl.preview" in out.dataset.default_permission_keys
|
||||
if role == "editor":
|
||||
assert "app.acl.log_and_annotation" in out.app.default_permission_keys
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "expected_snippet_keys"),
|
||||
|
||||
@@ -273,45 +273,6 @@ def test_sync_draft_workflow_updates_existing_draft_and_clears_variables(monkeyp
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_update_workflow_updates_marked_fields() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
workflow = SimpleNamespace(marked_name="", marked_comment="", updated_by=None, updated_at=None)
|
||||
session = SimpleNamespace(scalar=Mock(return_value=workflow), add=Mock())
|
||||
snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
result = service.update_workflow(
|
||||
session=session,
|
||||
snippet=snippet,
|
||||
workflow_id="workflow-1",
|
||||
account=account,
|
||||
data={"marked_name": "v1", "marked_comment": "first version", "ignored": "value"},
|
||||
)
|
||||
|
||||
assert result is workflow
|
||||
assert workflow.marked_name == "v1"
|
||||
assert workflow.marked_comment == "first version"
|
||||
assert workflow.updated_by == "account-1"
|
||||
session.scalar.assert_called_once()
|
||||
session.add.assert_called_once_with(workflow)
|
||||
|
||||
|
||||
def test_update_workflow_returns_none_when_missing() -> None:
|
||||
service = SnippetService.__new__(SnippetService)
|
||||
session = SimpleNamespace(scalar=Mock(return_value=None), add=Mock())
|
||||
|
||||
result = service.update_workflow(
|
||||
session=session,
|
||||
snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"),
|
||||
workflow_id="missing-workflow",
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
data={"marked_name": "v1"},
|
||||
)
|
||||
|
||||
assert result is None
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
def test_get_default_block_configs_skips_empty_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
node_with_default = SimpleNamespace(get_default_config=Mock(return_value={"type": "llm"}))
|
||||
node_without_default = SimpleNamespace(get_default_config=Mock(return_value=None))
|
||||
|
||||
@@ -639,8 +639,8 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up
|
||||
assert len(bindings) == 1
|
||||
assert bindings[0]["attachment_id"] == "file-1"
|
||||
|
||||
vector_instance.create_multimodal.assert_called_once()
|
||||
documents = vector_instance.create_multimodal.call_args.args[0]
|
||||
vector_instance.add_texts.assert_called_once()
|
||||
documents = vector_instance.add_texts.call_args.args[0]
|
||||
assert len(documents) == 1
|
||||
assert documents[0].page_content == "img.png"
|
||||
assert documents[0].metadata["doc_id"] == "file-1"
|
||||
|
||||
@@ -199,111 +199,3 @@ class TestWorkflowGeneratorService:
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["current_graph"] is None
|
||||
|
||||
@patch("services.workflow_generator_service.LLMGenerator")
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_auto_mode_resolves_via_classifier(
|
||||
self,
|
||||
mock_format_catalogue: MagicMock,
|
||||
mock_build_catalogue: MagicMock,
|
||||
mock_model_manager: MagicMock,
|
||||
mock_workflow_generator: MagicMock,
|
||||
mock_llm_generator: MagicMock,
|
||||
):
|
||||
"""Task 3: ``mode="auto"`` is classified before planning; the concrete mode reaches the runner."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_llm_generator.classify_workflow_mode.return_value = "workflow"
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="auto",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
mock_llm_generator.classify_workflow_mode.assert_called_once()
|
||||
classify_kwargs = mock_llm_generator.classify_workflow_mode.call_args.kwargs
|
||||
assert classify_kwargs["tenant_id"] == "t-1"
|
||||
assert classify_kwargs["instruction"] == "Summarize a URL"
|
||||
assert mock_workflow_generator.generate_workflow_graph.call_args.kwargs["mode"] == "workflow"
|
||||
|
||||
@patch("services.workflow_generator_service.LLMGenerator")
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_explicit_mode_skips_classifier(
|
||||
self,
|
||||
mock_format_catalogue: MagicMock,
|
||||
mock_build_catalogue: MagicMock,
|
||||
mock_model_manager: MagicMock,
|
||||
mock_workflow_generator: MagicMock,
|
||||
mock_llm_generator: MagicMock,
|
||||
):
|
||||
"""A concrete mode passes through unchanged without an extra classification call."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="advanced-chat",
|
||||
instruction="A chat bot",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
mock_llm_generator.classify_workflow_mode.assert_not_called()
|
||||
assert mock_workflow_generator.generate_workflow_graph.call_args.kwargs["mode"] == "advanced-chat"
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_stream_delegates_to_runner_stream(
|
||||
self,
|
||||
mock_format_catalogue: MagicMock,
|
||||
mock_build_catalogue: MagicMock,
|
||||
mock_model_manager: MagicMock,
|
||||
mock_workflow_generator: MagicMock,
|
||||
):
|
||||
"""Task 2b: the streaming facade resolves context and yields the runner's events through."""
|
||||
instance = MagicMock(name="model_instance")
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = instance
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
|
||||
def _runner_stream(**_kwargs):
|
||||
yield ("plan", {"mode": "workflow"})
|
||||
yield ("result", {"error": "", "mode": "workflow"})
|
||||
|
||||
mock_workflow_generator.generate_workflow_graph_stream.side_effect = _runner_stream
|
||||
|
||||
events = list(
|
||||
WorkflowGeneratorService.generate_workflow_graph_stream(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
)
|
||||
|
||||
assert [name for name, _ in events] == ["plan", "result"]
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph_stream.call_args.kwargs
|
||||
assert call_kwargs["model_instance"] is instance
|
||||
assert call_kwargs["mode"] == "workflow"
|
||||
assert call_kwargs["provider"] == "openai"
|
||||
|
||||
@@ -98,7 +98,7 @@ class TestDispatchTriggeredWorkflow:
|
||||
),
|
||||
patch.object(
|
||||
trigger_processing_tasks_module,
|
||||
"_get_published_workflows_by_app_ids",
|
||||
"_get_latest_workflows_by_app_ids",
|
||||
) as get_workflows,
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.EndUserService,
|
||||
|
||||
+5
-25
@@ -14,31 +14,11 @@
|
||||
"binName": "difyctl",
|
||||
"checksumsSuffix": "-checksums.txt",
|
||||
"targets": [
|
||||
{
|
||||
"id": "linux-x64",
|
||||
"bunTarget": "bun-linux-x64",
|
||||
"exe": false
|
||||
},
|
||||
{
|
||||
"id": "linux-arm64",
|
||||
"bunTarget": "bun-linux-arm64",
|
||||
"exe": false
|
||||
},
|
||||
{
|
||||
"id": "darwin-x64",
|
||||
"bunTarget": "bun-darwin-x64",
|
||||
"exe": false
|
||||
},
|
||||
{
|
||||
"id": "darwin-arm64",
|
||||
"bunTarget": "bun-darwin-arm64",
|
||||
"exe": false
|
||||
},
|
||||
{
|
||||
"id": "windows-x64",
|
||||
"bunTarget": "bun-windows-x64",
|
||||
"exe": true
|
||||
}
|
||||
{ "id": "linux-x64", "bunTarget": "bun-linux-x64", "exe": false },
|
||||
{ "id": "linux-arm64", "bunTarget": "bun-linux-arm64", "exe": false },
|
||||
{ "id": "darwin-x64", "bunTarget": "bun-darwin-x64", "exe": false },
|
||||
{ "id": "darwin-arm64", "bunTarget": "bun-darwin-arm64", "exe": false },
|
||||
{ "id": "windows-x64", "bunTarget": "bun-windows-x64", "exe": true }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Provider-agnostic shell provisioning/execution adapter for the Dify agent.
|
||||
|
||||
The boundary protocols live in ``protocols``; the default shellctl backend in
|
||||
``shellctl``; and env-var-driven provider selection in ``config``/``factory``.
|
||||
``create_shell_provisioner`` is the recommended entry point for callers.
|
||||
"""
|
||||
|
||||
from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER, ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provisioner
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
ShellEnvironmentDescriptor,
|
||||
ShellExecutionResult,
|
||||
ShellExecutorProtocol,
|
||||
ShellFileTransferProtocol,
|
||||
ShellHandle,
|
||||
ShellProvisionProtocol,
|
||||
)
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlEnvironmentDescriptor,
|
||||
ShellctlProvisioner,
|
||||
ShellFileTransferError,
|
||||
ShellProvisionError,
|
||||
create_default_shellctl_client_factory,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SHELL_PROVIDER",
|
||||
"ShellAdapterSettings",
|
||||
"ShellEnvironmentDescriptor",
|
||||
"ShellExecutionResult",
|
||||
"ShellExecutorProtocol",
|
||||
"ShellFileTransferError",
|
||||
"ShellFileTransferProtocol",
|
||||
"ShellHandle",
|
||||
"ShellProvisionError",
|
||||
"ShellProvisionProtocol",
|
||||
"ShellctlEnvironmentDescriptor",
|
||||
"ShellctlProvisioner",
|
||||
"create_default_shellctl_client_factory",
|
||||
"create_shell_provisioner",
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
DEFAULT_SHELL_PROVIDER = "shellctl"
|
||||
|
||||
|
||||
class ShellAdapterSettings(BaseSettings):
|
||||
"""Env-backed settings used to construct a shell provisioner.
|
||||
|
||||
``shellctl_auth_token`` defaults to ``None``; the factory forwards an empty
|
||||
string to the shellctl client so it does not fall back to ambient process
|
||||
credentials. Deployments that enable shellctl bearer auth must set
|
||||
``DIFY_AGENT_SHELLCTL_AUTH_TOKEN`` explicitly.
|
||||
"""
|
||||
|
||||
shell_provider: str = DEFAULT_SHELL_PROVIDER
|
||||
shellctl_entrypoint: str | None = None
|
||||
shellctl_auth_token: str | None = None
|
||||
|
||||
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
||||
env_prefix="DIFY_AGENT_",
|
||||
env_file=(".env", "dify-agent/.env"),
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_SHELL_PROVIDER", "ShellAdapterSettings"]
|
||||
@@ -1,36 +0,0 @@
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlEnvironmentDescriptor,
|
||||
ShellctlProvisioner,
|
||||
create_default_shellctl_client_factory,
|
||||
)
|
||||
|
||||
|
||||
def create_shell_provisioner(
|
||||
settings: ShellAdapterSettings | None = None,
|
||||
) -> ShellProvisionProtocol[ShellctlEnvironmentDescriptor]:
|
||||
"""Return the shell provisioner selected by ``DIFY_AGENT_SHELL_PROVIDER``.
|
||||
|
||||
Raises:
|
||||
ValueError: if the provider name is unknown, or if the ``shellctl``
|
||||
provider is selected without a non-empty ``DIFY_AGENT_SHELLCTL_ENTRYPOINT``.
|
||||
"""
|
||||
resolved = settings or ShellAdapterSettings()
|
||||
provider = resolved.shell_provider.strip().lower()
|
||||
match provider:
|
||||
case "shellctl":
|
||||
entrypoint = (resolved.shellctl_entrypoint or "").strip()
|
||||
if not entrypoint:
|
||||
raise ValueError("DIFY_AGENT_SHELLCTL_ENTRYPOINT is required for the 'shellctl' shell provider.")
|
||||
return ShellctlProvisioner(
|
||||
client_factory=create_default_shellctl_client_factory(
|
||||
entrypoint=entrypoint,
|
||||
token=resolved.shellctl_auth_token or "",
|
||||
),
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unknown shell provider: {resolved.shell_provider!r}.")
|
||||
|
||||
|
||||
__all__ = ["create_shell_provisioner"]
|
||||
@@ -1,128 +0,0 @@
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class ShellEnvironmentDescriptor(BaseModel):
|
||||
"""Minimal, serializable seed used to re-derive a provisioned environment.
|
||||
|
||||
Holds only the provider-agnostic identity needed to reattach to an existing
|
||||
shell environment across a snapshot/resume cycle — never live resources
|
||||
(clients, handles, executors). Callers persist this in their snapshot and
|
||||
pass it back to ``ShellProvisionProtocol.reattach`` to reconstruct an
|
||||
equivalent ``ShellHandle`` without allocating a new environment.
|
||||
|
||||
Each provider defines its own concrete subclass carrying the fields it needs
|
||||
to reattach (e.g. workspace path + session id for shellctl). Validation runs
|
||||
at instantiation time via Pydantic so a malicious or corrupt snapshot cannot
|
||||
escape the workspace root or inject shell syntax into lifecycle commands,
|
||||
even if a future caller uses the provider directly.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _run_validate(self) -> Self:
|
||||
self.validate()
|
||||
return self
|
||||
|
||||
def validate(self) -> None:
|
||||
"""validate the correctness of the object.
|
||||
|
||||
Advanced validations that requires remote procedure calls,
|
||||
for example access control, quota checks, should be implemented in
|
||||
provision and reattach.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
DescriptorT = TypeVar("DescriptorT", bound=ShellEnvironmentDescriptor)
|
||||
|
||||
|
||||
class ShellExecutionResult(Protocol):
|
||||
"""Completed shell command result.
|
||||
|
||||
``stdout``/``stderr``/``exit_code`` are reserved fields. Backends that
|
||||
cannot distinguish a stream return an empty string for it, and return
|
||||
``None`` from ``exit_code()`` when no exit status is available.
|
||||
"""
|
||||
|
||||
def stdout(self) -> str: ...
|
||||
|
||||
def stderr(self) -> str: ...
|
||||
|
||||
def exit_code(self) -> int | None: ...
|
||||
|
||||
def truncated(self) -> bool: ...
|
||||
|
||||
|
||||
class ShellExecutorProtocol(Protocol):
|
||||
"""Runs commands inside an already-provisioned shell environment.
|
||||
|
||||
``execute`` drains the command to completion before returning — there is no
|
||||
separate ``wait`` step. This suits the current server-side callers (sandbox
|
||||
file helpers, workspace bootstrap) that always run a script to completion.
|
||||
|
||||
If a future use case needs to start a command, interact with its stdin, or
|
||||
interrupt it before completion, split this protocol into ``execute`` →
|
||||
``ShellExecutionHandle`` plus ``wait`` / ``input`` / ``interrupt`` optional
|
||||
capabilities, mirroring the shape that was prototyped here before
|
||||
simplification.
|
||||
"""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> ShellExecutionResult: ...
|
||||
|
||||
|
||||
class ShellFileTransferProtocol(Protocol):
|
||||
"""Moves file bytes between the caller and a provisioned shell environment.
|
||||
|
||||
``remote_path`` is interpreted by the backend relative to the provisioned
|
||||
environment (for the shellctl backend, the session workspace). Higher-level
|
||||
Dify/skill transfers are layered on top of this primitive, not implemented
|
||||
here. Implementations raise on transfer failure (missing path, decode error,
|
||||
or a non-zero transfer command).
|
||||
"""
|
||||
|
||||
async def upload(self, *, content: bytes, remote_path: str) -> None: ...
|
||||
|
||||
async def download(self, *, remote_path: str) -> bytes: ...
|
||||
|
||||
|
||||
# pyrefly: ignore [variance-mismatch]
|
||||
# intended to be invariant
|
||||
class ShellHandle(Protocol[DescriptorT]):
|
||||
"""Live reference to one provisioned shell environment.
|
||||
|
||||
The handle itself is not serialized. ``descriptor()`` returns the minimal
|
||||
seed needed to reconstruct an equivalent handle after a snapshot/resume.
|
||||
"""
|
||||
|
||||
def descriptor(self) -> DescriptorT: ...
|
||||
|
||||
async def get_executor(self) -> ShellExecutorProtocol: ...
|
||||
|
||||
async def get_file_transfer(self) -> ShellFileTransferProtocol: ...
|
||||
|
||||
|
||||
class ShellProvisionProtocol(Protocol[DescriptorT]):
|
||||
"""Creates, reattaches to, and destroys shell environments.
|
||||
|
||||
``provision`` allocates a fresh environment; ``reattach`` rebuilds a live
|
||||
handle for an environment that already exists (from a persisted descriptor)
|
||||
without allocating a new one, so resumed runs can keep executing and
|
||||
eventually clean up. ``destroy`` tears an environment down.
|
||||
"""
|
||||
|
||||
async def provision(self) -> ShellHandle[DescriptorT]: ...
|
||||
|
||||
async def reattach(self, descriptor: DescriptorT) -> ShellHandle[DescriptorT]: ...
|
||||
|
||||
async def destroy(self, handle: ShellHandle[DescriptorT]) -> None: ...
|
||||
@@ -1,486 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from dify_agent.adapters.shell.protocols import ShellEnvironmentDescriptor, ShellHandle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKSPACE_ROOT = "~/workspace"
|
||||
_DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
_SESSION_ID_PATTERN = re.compile(r"[0-9a-f]{7,16}")
|
||||
# Drains at most this many shellctl output windows per wait so a stuck or
|
||||
# pathologically chatty job cannot loop forever inside one wait() call.
|
||||
_MAX_OUTPUT_WINDOWS = 64
|
||||
_DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
|
||||
_FILE_TRANSFER_TIMEOUT_SECONDS = 60.0
|
||||
# Sentinels frame base64 download payloads so prompt/tmux noise around the
|
||||
# shellctl merged output stream can be stripped before decoding.
|
||||
_TRANSFER_BEGIN = "<<<DIFY_SHELL_FILE_BEGIN>>>"
|
||||
_TRANSFER_END = "<<<DIFY_SHELL_FILE_END>>>"
|
||||
_DOWNLOAD_MISSING_EXIT_CODE = 66
|
||||
|
||||
|
||||
class ShellProvisionError(RuntimeError):
|
||||
"""Raised when a shell environment cannot be provisioned."""
|
||||
|
||||
|
||||
class ShellFileTransferError(RuntimeError):
|
||||
"""Raised when a file cannot be uploaded to or downloaded from the workspace."""
|
||||
|
||||
|
||||
class ShellctlEnvironmentDescriptor(ShellEnvironmentDescriptor):
|
||||
"""Shellctl-specific descriptor carrying the workspace path and session id."""
|
||||
|
||||
workspace_cwd: str
|
||||
session_id: str
|
||||
|
||||
def validate(self) -> None:
|
||||
if not _SESSION_ID_PATTERN.fullmatch(self.session_id):
|
||||
raise ValueError(f"Invalid session_id in reattach descriptor: {self.session_id!r}.")
|
||||
expected_workspace = f"{_WORKSPACE_ROOT}/{self.session_id}"
|
||||
if self.workspace_cwd != expected_workspace:
|
||||
raise ValueError(
|
||||
f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}, "
|
||||
f"got {self.workspace_cwd!r}."
|
||||
)
|
||||
|
||||
|
||||
class ShellctlJobResult(Protocol):
|
||||
"""Structural shape of one shellctl job result the adapter relies on.
|
||||
|
||||
Mirrors the fields the adapter reads from ``shell_session_manager`` job
|
||||
results without importing the concrete type, so the merged output stream,
|
||||
paging offset, completion flag, and exit status stay duck-typed.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
done: bool
|
||||
output: str
|
||||
offset: int
|
||||
truncated: bool
|
||||
exit_code: int | None
|
||||
|
||||
|
||||
class ShellctlJobStatus(Protocol):
|
||||
"""Structural shape of one shellctl status-only result (no output stream).
|
||||
|
||||
Returned by ``terminate``; carries completion and exit status plus the
|
||||
latest paging offset so the adapter can drain any remaining output.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
done: bool
|
||||
offset: int
|
||||
exit_code: int | None
|
||||
|
||||
|
||||
class ShellctlClientProtocol(Protocol):
|
||||
"""Boundary the shellctl adapter needs from a shell-session-manager client."""
|
||||
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> ShellctlJobResult: ...
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> ShellctlJobResult: ...
|
||||
|
||||
async def input(
|
||||
self,
|
||||
job_id: str,
|
||||
text: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> ShellctlJobResult: ...
|
||||
|
||||
async def terminate(
|
||||
self,
|
||||
job_id: str,
|
||||
grace_seconds: float = _DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
) -> ShellctlJobStatus: ...
|
||||
|
||||
async def delete(self, job_id: str, *, force: bool = False) -> object: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
type ShellctlClientFactory = Callable[[], ShellctlClientProtocol]
|
||||
|
||||
|
||||
class ShellctlExecutionResult:
|
||||
"""Completed shellctl command result.
|
||||
|
||||
shellctl merges stderr into a single output stream, so ``stderr()`` is
|
||||
always empty and the merged stream is reported as ``stdout()``.
|
||||
"""
|
||||
|
||||
_stdout: str
|
||||
_stderr: str
|
||||
_exit_code: int | None
|
||||
_truncated: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
stdout: str,
|
||||
stderr: str = "",
|
||||
exit_code: int | None,
|
||||
truncated: bool = False,
|
||||
) -> None:
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
self._exit_code = exit_code
|
||||
self._truncated = truncated
|
||||
|
||||
def stdout(self) -> str:
|
||||
return self._stdout
|
||||
|
||||
def stderr(self) -> str:
|
||||
return self._stderr
|
||||
|
||||
def exit_code(self) -> int | None:
|
||||
return self._exit_code
|
||||
|
||||
def truncated(self) -> bool:
|
||||
"""Whether the returned ``stdout`` may be incomplete.
|
||||
|
||||
``True`` means shellctl still reported more output past what was
|
||||
captured when draining stopped (its per-window ``truncated`` flag on the
|
||||
final window, e.g. the output-window cap was hit). Callers that need the
|
||||
command's *entire* output must treat a truncated result as a failure or
|
||||
re-read, rather than trusting ``stdout()`` as complete.
|
||||
"""
|
||||
return self._truncated
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellctlExecutor:
|
||||
"""Runs commands in one provisioned shellctl workspace.
|
||||
|
||||
Conforms structurally to ``ShellExecutorProtocol``. ``execute`` drains the
|
||||
command to completion (accumulating shellctl's paged output windows) and
|
||||
best-effort deletes the finished job before returning. The executor is
|
||||
single-environment and is not safe to share across workspaces.
|
||||
"""
|
||||
|
||||
client: ShellctlClientProtocol
|
||||
workspace_cwd: str
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> ShellctlExecutionResult:
|
||||
result = await self.client.run(
|
||||
command,
|
||||
cwd=cwd if cwd is not None else self.workspace_cwd,
|
||||
env=env,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
output_parts = [result.output]
|
||||
done = result.done
|
||||
truncated = result.truncated
|
||||
offset = result.offset
|
||||
exit_code = result.exit_code
|
||||
job_id = result.job_id
|
||||
windows = 1
|
||||
while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS:
|
||||
result = await self.client.wait(job_id, offset=offset, timeout=self.timeout)
|
||||
output_parts.append(result.output)
|
||||
done = result.done
|
||||
truncated = result.truncated
|
||||
offset = result.offset
|
||||
exit_code = result.exit_code
|
||||
windows += 1
|
||||
if done:
|
||||
await _delete_job_best_effort(self.client, job_id)
|
||||
return ShellctlExecutionResult(
|
||||
stdout="".join(output_parts),
|
||||
exit_code=exit_code,
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellctlFileTransfer:
|
||||
"""Moves file bytes in and out of one provisioned shellctl workspace.
|
||||
|
||||
Conforms structurally to ``ShellFileTransferProtocol``. Transfers run as
|
||||
workspace-scoped shellctl jobs over the merged text channel: uploads embed
|
||||
base64 in the command and pipe it through ``base64 -d``; downloads emit the
|
||||
file's base64 framed by sentinels so prompt/tmux noise can be stripped
|
||||
before decoding. Because the encoded payload is embedded in the upload
|
||||
command, very large files can exceed the shell argument limit; this
|
||||
primitive targets ordinary control-plane file sizes, not bulk binary
|
||||
transfer.
|
||||
"""
|
||||
|
||||
client: ShellctlClientProtocol
|
||||
workspace_cwd: str
|
||||
timeout: float = _FILE_TRANSFER_TIMEOUT_SECONDS
|
||||
|
||||
async def upload(self, *, content: bytes, remote_path: str) -> None:
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
completed = await _run_to_completion(
|
||||
self.client,
|
||||
_upload_script(remote_path=remote_path, encoded=encoded),
|
||||
cwd=self.workspace_cwd,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if completed.exit_code != 0:
|
||||
raise ShellFileTransferError(
|
||||
f"Failed to upload to {remote_path!r}: exit_code={completed.exit_code}, "
|
||||
f"output={_output_tail(completed.output)!r}"
|
||||
)
|
||||
|
||||
async def download(self, *, remote_path: str) -> bytes:
|
||||
completed = await _run_to_completion(
|
||||
self.client,
|
||||
_download_script(remote_path=remote_path),
|
||||
cwd=self.workspace_cwd,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if completed.exit_code == _DOWNLOAD_MISSING_EXIT_CODE:
|
||||
raise ShellFileTransferError(f"File not found in workspace: {remote_path!r}.")
|
||||
if completed.exit_code != 0:
|
||||
raise ShellFileTransferError(
|
||||
f"Failed to download {remote_path!r}: exit_code={completed.exit_code}, "
|
||||
f"output={_output_tail(completed.output)!r}"
|
||||
)
|
||||
framed = _extract_framed_payload(completed.output)
|
||||
try:
|
||||
return base64.b64decode("".join(framed.split()), validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ShellFileTransferError(f"Failed to decode downloaded file {remote_path!r}: {exc}") from exc
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellctlHandle:
|
||||
"""Live reference to one provisioned shellctl workspace.
|
||||
|
||||
Conforms structurally to ``ShellHandle``. Owns the shellctl ``client`` and
|
||||
the allocated ``workspace_cwd`` until the provisioner destroys it.
|
||||
``get_executor`` returns a fresh executor bound to this workspace each call.
|
||||
"""
|
||||
|
||||
client: ShellctlClientProtocol
|
||||
workspace_cwd: str
|
||||
session_id: str
|
||||
|
||||
def descriptor(self) -> ShellctlEnvironmentDescriptor:
|
||||
return ShellctlEnvironmentDescriptor(workspace_cwd=self.workspace_cwd, session_id=self.session_id)
|
||||
|
||||
async def get_executor(self) -> ShellctlExecutor:
|
||||
return ShellctlExecutor(client=self.client, workspace_cwd=self.workspace_cwd)
|
||||
|
||||
async def get_file_transfer(self) -> ShellctlFileTransfer:
|
||||
return ShellctlFileTransfer(client=self.client, workspace_cwd=self.workspace_cwd)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellctlProvisioner:
|
||||
"""Provisions isolated shellctl workspaces, one client per environment.
|
||||
|
||||
Conforms structurally to ``ShellProvisionProtocol``.
|
||||
"""
|
||||
|
||||
client_factory: ShellctlClientFactory
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
async def provision(self) -> ShellctlHandle:
|
||||
client = self.client_factory()
|
||||
session_id = _generate_session_id()
|
||||
workspace_cwd = f"{_WORKSPACE_ROOT}/{session_id}"
|
||||
try:
|
||||
completed = await _run_to_completion(client, _mkdir_script(session_id), cwd=None, timeout=self.timeout)
|
||||
except BaseException:
|
||||
await client.close()
|
||||
raise
|
||||
if completed.exit_code != 0:
|
||||
await client.close()
|
||||
raise ShellProvisionError(
|
||||
f"Failed to create shell workspace {workspace_cwd}: mkdir exited with code {completed.exit_code}."
|
||||
)
|
||||
return ShellctlHandle(client=client, workspace_cwd=workspace_cwd, session_id=session_id)
|
||||
|
||||
async def reattach(self, descriptor: ShellctlEnvironmentDescriptor) -> ShellctlHandle:
|
||||
"""Rebuild a live handle for an existing workspace without re-allocating it.
|
||||
|
||||
Opens a fresh shellctl client and points it at the workspace recorded in
|
||||
``descriptor``. No ``mkdir`` is issued: the workspace is assumed to still
|
||||
exist from the original ``provision``. Used on snapshot resume so a run
|
||||
can keep executing in and eventually clean up its prior workspace.
|
||||
"""
|
||||
client = self.client_factory()
|
||||
return ShellctlHandle(
|
||||
client=client,
|
||||
workspace_cwd=descriptor.workspace_cwd,
|
||||
session_id=descriptor.session_id,
|
||||
)
|
||||
|
||||
async def destroy(self, handle: ShellHandle[ShellctlEnvironmentDescriptor]) -> None:
|
||||
if not isinstance(handle, ShellctlHandle):
|
||||
raise TypeError("ShellctlProvisioner can only destroy handles it provisioned.")
|
||||
try:
|
||||
completed = await _run_to_completion(
|
||||
handle.client, _cleanup_script(handle.session_id), cwd=None, timeout=self.timeout
|
||||
)
|
||||
if completed.exit_code != 0:
|
||||
logger.warning(
|
||||
"Shell workspace cleanup for session %s exited with code %s.",
|
||||
handle.session_id,
|
||||
completed.exit_code,
|
||||
)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
logger.warning("Failed to remove shell workspace for session %s: %s", handle.session_id, exc)
|
||||
finally:
|
||||
await handle.client.close()
|
||||
|
||||
|
||||
def create_default_shellctl_client_factory(*, entrypoint: str, token: str) -> ShellctlClientFactory:
|
||||
"""Return a factory that builds a real shell-session-manager shellctl client.
|
||||
|
||||
The concrete client is imported lazily so importing this module does not
|
||||
require the private ``shell-session-manager`` package. An explicit empty
|
||||
``token`` is forwarded as-is to avoid the client falling back to ambient
|
||||
process credentials.
|
||||
"""
|
||||
|
||||
def factory() -> ShellctlClientProtocol:
|
||||
from shell_session_manager.shellctl.client import ShellctlClient
|
||||
|
||||
return ShellctlClient(entrypoint, token=token)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _CompletedJob:
|
||||
"""Drained result of one internal shellctl job: merged output plus exit code."""
|
||||
|
||||
output: str
|
||||
exit_code: int | None
|
||||
|
||||
|
||||
async def _run_to_completion(
|
||||
client: ShellctlClientProtocol,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None,
|
||||
timeout: float,
|
||||
) -> _CompletedJob:
|
||||
"""Run one internal lifecycle script to completion, returning output and exit code."""
|
||||
result = await client.run(script, cwd=cwd, env=None, timeout=timeout)
|
||||
output_parts = [result.output]
|
||||
done = result.done
|
||||
truncated = result.truncated
|
||||
offset = result.offset
|
||||
exit_code = result.exit_code
|
||||
job_id = result.job_id
|
||||
windows = 1
|
||||
while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS:
|
||||
result = await client.wait(job_id, offset=offset, timeout=timeout)
|
||||
output_parts.append(result.output)
|
||||
done = result.done
|
||||
truncated = result.truncated
|
||||
offset = result.offset
|
||||
exit_code = result.exit_code
|
||||
windows += 1
|
||||
if done:
|
||||
await _delete_job_best_effort(client, job_id)
|
||||
return _CompletedJob(output="".join(output_parts), exit_code=exit_code)
|
||||
|
||||
|
||||
async def _delete_job_best_effort(client: ShellctlClientProtocol, job_id: str) -> None:
|
||||
"""Force-delete one shellctl job, never failing the caller on cleanup errors."""
|
||||
try:
|
||||
_ = await client.delete(job_id, force=True)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort teardown must not surface cleanup errors
|
||||
logger.warning("Failed to delete shellctl job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def _generate_session_id() -> str:
|
||||
"""Return a shell-safe random session id used as the workspace directory name."""
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
def _mkdir_script(session_id: str) -> str:
|
||||
return f'mkdir -p "$HOME/workspace/{session_id}"'
|
||||
|
||||
|
||||
def _cleanup_script(session_id: str) -> str:
|
||||
return f'rm -rf -- "$HOME/workspace/{session_id}"'
|
||||
|
||||
|
||||
def _upload_script(*, remote_path: str, encoded: str) -> str:
|
||||
"""Return a script that recreates a file from embedded base64 in the workspace."""
|
||||
quoted = _shquote(remote_path)
|
||||
return f"mkdir -p \"$(dirname -- {quoted})\" && printf %s '{encoded}' | base64 -d > {quoted}"
|
||||
|
||||
|
||||
def _download_script(*, remote_path: str) -> str:
|
||||
"""Return a script that emits a file's base64 between transfer sentinels."""
|
||||
quoted = _shquote(remote_path)
|
||||
return (
|
||||
f"if [ ! -f {quoted} ]; then exit {_DOWNLOAD_MISSING_EXIT_CODE}; fi; "
|
||||
f"printf %s {_shquote(_TRANSFER_BEGIN)}; "
|
||||
f'base64 < {quoted} | tr -d "\\n"; '
|
||||
f"printf %s {_shquote(_TRANSFER_END)}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_framed_payload(output: str) -> str:
|
||||
"""Return the base64 text framed by the transfer sentinels in shellctl output."""
|
||||
begin = output.find(_TRANSFER_BEGIN)
|
||||
end = output.find(_TRANSFER_END, begin + len(_TRANSFER_BEGIN)) if begin != -1 else -1
|
||||
if begin == -1 or end == -1:
|
||||
raise ShellFileTransferError("download command returned no framed payload")
|
||||
return output[begin + len(_TRANSFER_BEGIN) : end]
|
||||
|
||||
|
||||
def _shquote(value: str) -> str:
|
||||
"""Single-quote a value for POSIX shells, escaping embedded single quotes."""
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def _output_tail(output: str, *, limit: int = 500) -> str:
|
||||
"""Return the trailing slice of command output for compact error messages."""
|
||||
return output[-limit:]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ShellFileTransferError",
|
||||
"ShellProvisionError",
|
||||
"ShellctlClientFactory",
|
||||
"ShellctlClientProtocol",
|
||||
"ShellctlEnvironmentDescriptor",
|
||||
"ShellctlExecutionResult",
|
||||
"ShellctlExecutor",
|
||||
"ShellctlFileTransfer",
|
||||
"ShellctlHandle",
|
||||
"ShellctlJobResult",
|
||||
"ShellctlJobStatus",
|
||||
"ShellctlProvisioner",
|
||||
"create_default_shellctl_client_factory",
|
||||
]
|
||||
@@ -1,20 +1,28 @@
|
||||
"""Shell layer backed by the shell adapter provisioner/executor mechanism.
|
||||
"""Shellctl-backed Dify shell layer.
|
||||
|
||||
``DifyShellLayer`` is a stateful pydantic-ai tool layer that exposes exactly
|
||||
``shell_run``, ``shell_wait``, ``shell_input``, and ``shell_interrupt``. The
|
||||
layer persists only JSON-safe shell session state in ``runtime_state`` and keeps
|
||||
its live ``ShellctlHandle`` on the layer instance only while
|
||||
its live shellctl HTTP client on the layer instance only while
|
||||
``resource_context()`` is active. Agenton enters that resource scope before
|
||||
``on_context_create`` or ``on_context_resume`` and exits it after
|
||||
``on_context_suspend`` or ``on_context_delete``, so business hooks and shell
|
||||
tools can rely on live resources without ever serializing them into snapshots.
|
||||
tools can rely on a live client without ever serializing it into snapshots.
|
||||
|
||||
The layer delegates workspace lifecycle to ``ShellProvisionProtocol``:
|
||||
``provision`` allocates a fresh workspace, ``reattach`` rebuilds a live handle
|
||||
for an existing workspace from a serialized descriptor, and ``destroy`` tears
|
||||
the workspace down. User-facing shell tools call the shellctl client obtained
|
||||
from the handle directly; trusted server-owned scripts go through
|
||||
``ShellctlExecutor`` which auto-cleans completed jobs.
|
||||
The runtime state tracks shellctl job ids for both user-visible shell jobs and
|
||||
internal lifecycle jobs such as workspace mkdir/cleanup commands. Those internal
|
||||
jobs are intentionally not deleted ad hoc; shellctl job-state deletion is
|
||||
centralized in ``on_context_delete`` so one lifecycle hook owns exit-time
|
||||
cleanup for successful create/resume flows. If ``on_context_create`` or a later
|
||||
side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs,
|
||||
Agenton still exits ``resource_context()`` but never transitions the layer to
|
||||
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
|
||||
so the enter hook itself must perform best-effort business compensation before
|
||||
re-raising the failure. Agent Soul shell env is injected into user-visible
|
||||
commands and CLI bootstrap commands without persisting a workspace env file.
|
||||
Agent Stub env injection uses shellctl's native per-run ``env`` argument for
|
||||
user-visible ``shell.run`` and for trusted server-owned fixed scripts executed
|
||||
through ``run_remote_script()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,23 +32,24 @@ from contextlib import asynccontextmanager
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, NotRequired, Protocol, TypedDict, cast
|
||||
from typing import ClassVar, NotRequired, Protocol, TypedDict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
|
||||
from pydantic_ai import Tool
|
||||
from shell_session_manager.shellctl.client import ShellctlClientError
|
||||
from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError
|
||||
from shell_session_manager.shellctl.shared import (
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DeleteJobResponse,
|
||||
JobResult,
|
||||
JobStatusView,
|
||||
)
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
|
||||
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlEnvironmentDescriptor, ShellctlExecutor, ShellctlHandle
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
@@ -49,6 +58,12 @@ from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellL
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKSPACE_ROOT = "~/workspace"
|
||||
_WORKSPACE_COLLISION_EXIT_CODE = 17
|
||||
_SESSION_TIME_HEX_MASK = 0xFFFFF
|
||||
_SESSION_RANDOM_HEX_LENGTH = 2
|
||||
_SESSION_ID_ATTEMPT_LIMIT = 256
|
||||
_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$")
|
||||
_REMOTE_COMMAND_MAX_OUTPUT_WINDOWS = 64
|
||||
_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools:
|
||||
|
||||
1. shell_run
|
||||
@@ -206,7 +221,7 @@ class ShellctlClientProtocol(Protocol):
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> object: ...
|
||||
) -> DeleteJobResponse: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
@@ -222,9 +237,12 @@ class DifyShellRuntimeState(BaseModel):
|
||||
created before suspension. Callers should replace the stored list/dict values
|
||||
rather than mutating them in place so Pydantic assignment validation keeps
|
||||
guarding the serialized state. Hydrated public snapshots must keep
|
||||
``session_id`` and ``workspace_cwd`` consistent with the descriptor returned
|
||||
by the shell provisioner, so resume and delete paths cannot escape the
|
||||
isolated workspace root or inject shell syntax into lifecycle commands.
|
||||
``session_id`` in the proposal's safe lowercase-hex format and must keep
|
||||
``workspace_cwd`` exactly aligned with ``~/workspace/<session_id>`` so resume
|
||||
and delete paths cannot escape the isolated workspace root or inject shell
|
||||
syntax into lifecycle commands. Shellctl job ids remain opaque strings here;
|
||||
the layer only enforces uniqueness plus the invariant that any stored offset
|
||||
entry must belong to a tracked job id in the same runtime state.
|
||||
"""
|
||||
|
||||
session_id: str | None = None
|
||||
@@ -237,12 +255,10 @@ class DifyShellRuntimeState(BaseModel):
|
||||
@field_validator("session_id")
|
||||
@classmethod
|
||||
def validate_session_id(cls, value: str | None) -> str | None:
|
||||
"""Reject session ids that could escape the workspace root or inject shell syntax."""
|
||||
"""Accept only the short lowercase-hex session ids defined by the proposal."""
|
||||
if value is None:
|
||||
return value
|
||||
if not re.fullmatch(r"[0-9a-f]{7,16}", value):
|
||||
raise ValueError("session_id must be 7 to 16 lowercase hex characters (got an invalid value).")
|
||||
return value
|
||||
return _validated_session_id(value)
|
||||
|
||||
@field_validator("job_ids")
|
||||
@classmethod
|
||||
@@ -270,28 +286,24 @@ class DifyShellRuntimeState(BaseModel):
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteCommandResult:
|
||||
"""Completed remote sandbox command returned to server-owned callers.
|
||||
|
||||
Only fields with live consumers are kept: ``output``/``exit_code`` (read by
|
||||
every caller), ``truncated`` (drive pull treats a truncated result as a
|
||||
failure because it needs the command's full output), and ``status`` (used in
|
||||
drive's human-readable error message). shellctl paging details such as the
|
||||
job id, completion flag, byte offset, and output path are intentionally not
|
||||
surfaced here, since no caller reads them.
|
||||
"""
|
||||
"""Completed remote sandbox command returned to server-owned callers."""
|
||||
|
||||
job_id: str
|
||||
status: str
|
||||
done: bool
|
||||
exit_code: int | None
|
||||
output: str
|
||||
offset: int
|
||||
truncated: bool
|
||||
output_path: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]):
|
||||
"""Shell tool layer backed by the shell provisioner/executor mechanism.
|
||||
"""Shell tool layer backed by a live shellctl client while active.
|
||||
|
||||
The mutable serializable state lives in ``runtime_state``; the live
|
||||
``ShellctlHandle`` is intentionally kept off-snapshot. Tool methods update
|
||||
The mutable serializable state lives in ``runtime_state``; the live client is
|
||||
intentionally kept off-snapshot in ``_shellctl_client``. Tool methods update
|
||||
tracked job ids and output offsets after every successful shellctl response so
|
||||
later ``shell_wait``/``shell_input`` calls can resume from the last known
|
||||
offset without exposing offsets as model-controlled inputs.
|
||||
@@ -300,35 +312,39 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
type_id: ClassVar[str | None] = DIFY_SHELL_LAYER_TYPE_ID
|
||||
|
||||
config: DifyShellLayerConfig
|
||||
shell_provisioner: ShellProvisionProtocol[ShellctlEnvironmentDescriptor]
|
||||
shellctl_entrypoint: str
|
||||
shellctl_client_factory: ShellctlClientFactory
|
||||
agent_stub_api_base_url: str | None = None
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
_shell_handle: ShellctlHandle | None = None
|
||||
_shellctl_client: ShellctlClientProtocol | None = None
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyShellLayerConfig) -> Self:
|
||||
"""Reject construction that omits the shell provisioner."""
|
||||
"""Reject construction that omits server-injected shellctl settings."""
|
||||
del config
|
||||
raise TypeError("DifyShellLayer requires a shell provisioner and must use a provider factory.")
|
||||
raise TypeError("DifyShellLayer requires server-side shellctl settings and must use a provider factory.")
|
||||
|
||||
@classmethod
|
||||
def from_config_with_settings(
|
||||
cls,
|
||||
config: DifyShellLayerConfig,
|
||||
*,
|
||||
shell_provisioner: ShellProvisionProtocol[ShellctlEnvironmentDescriptor] | None,
|
||||
shellctl_entrypoint: str | None,
|
||||
shellctl_client_factory: ShellctlClientFactory,
|
||||
agent_stub_api_base_url: str | None = None,
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
|
||||
) -> Self:
|
||||
"""Create the layer from public config plus shell provisioner settings."""
|
||||
if shell_provisioner is None:
|
||||
"""Create the layer from public config plus server-only shell settings."""
|
||||
normalized_entrypoint = (shellctl_entrypoint or "").strip()
|
||||
if not normalized_entrypoint:
|
||||
raise ValueError(
|
||||
"DifyShellLayer requires a non-null shell provisioner when the 'dify.shell' layer is used."
|
||||
"DifyShellLayer requires a non-empty DIFY_AGENT_SHELLCTL_ENTRYPOINT when the 'dify.shell' layer is used."
|
||||
)
|
||||
layer = cls(
|
||||
config=config,
|
||||
shell_provisioner=shell_provisioner,
|
||||
shellctl_entrypoint=normalized_entrypoint,
|
||||
shellctl_client_factory=shellctl_client_factory,
|
||||
agent_stub_api_base_url=agent_stub_api_base_url,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
)
|
||||
@@ -353,90 +369,107 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncGenerator[None]:
|
||||
"""Hold the live shell handle scope.
|
||||
"""Hold one live shellctl client for one active Agenton layer scope.
|
||||
|
||||
The actual handle is set in ``on_context_create`` /
|
||||
``on_context_resume``. This scope ensures cleanup if a lifecycle hook
|
||||
fails before the handle is set.
|
||||
The shellctl client is a non-serializable live resource, so Agenton owns
|
||||
only the timing of this scope, not the client itself. Business hooks and
|
||||
tools should call ``_require_client()`` to ensure they are running inside
|
||||
an active resource scope.
|
||||
"""
|
||||
if self._shellctl_client is not None:
|
||||
raise RuntimeError("DifyShellLayer resource_context() is already active for this layer instance.")
|
||||
|
||||
client = self.shellctl_client_factory(self.shellctl_entrypoint)
|
||||
self._shellctl_client = client
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._shell_handle = None
|
||||
self._shellctl_client = None
|
||||
await client.close()
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
"""Provision a new workspace session using the shell provisioner.
|
||||
"""Allocate a new workspace session using the active live shellctl client.
|
||||
|
||||
The provisioner allocates the workspace directory and returns a
|
||||
``ShellctlHandle``. The layer then bootstraps the workspace with Agent
|
||||
Soul env exports and CLI tool install commands. If workspace setup
|
||||
partially succeeds and this hook later raises, the layer never becomes
|
||||
``ACTIVE``. In that path Agenton still exits ``resource_context()``, but
|
||||
``on_context_delete()`` will not run, so this hook must clean up any
|
||||
tracked artifacts before re-raising.
|
||||
If workspace setup partially succeeds and this hook later raises, the
|
||||
layer never becomes ``ACTIVE``. In that path Agenton still exits
|
||||
``resource_context()``, but ``on_context_delete()`` will not run, so this
|
||||
hook must clean up any tracked shellctl job artifacts before re-raising.
|
||||
"""
|
||||
try:
|
||||
handle = cast(ShellctlHandle, await self.shell_provisioner.provision())
|
||||
self._shell_handle = handle
|
||||
descriptor = handle.descriptor()
|
||||
await self._bootstrap_workspace(descriptor.workspace_cwd)
|
||||
_ = self._require_client()
|
||||
session_id, workspace_cwd = await self._allocate_workspace()
|
||||
await self._bootstrap_workspace(workspace_cwd)
|
||||
except BaseException:
|
||||
await self._cleanup_create_failure()
|
||||
raise
|
||||
self.runtime_state = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
**self.runtime_state.model_dump(mode="python"),
|
||||
"session_id": descriptor.session_id,
|
||||
"workspace_cwd": descriptor.workspace_cwd,
|
||||
"session_id": session_id,
|
||||
"workspace_cwd": workspace_cwd,
|
||||
}
|
||||
)
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
"""Reattach to an existing serialized shell session.
|
||||
"""Resume an existing serialized shell session inside an active resource scope.
|
||||
|
||||
Builds a ``ShellEnvironmentDescriptor`` from the persisted runtime state
|
||||
and asks the provisioner to reattach without allocating a new workspace.
|
||||
If a future resume path adds self-heal side effects before raising, this
|
||||
hook must compensate for them itself because failed resume attempts never
|
||||
transition the slot back to ``ACTIVE``.
|
||||
transition the slot back to ``ACTIVE`` and therefore do not receive a
|
||||
normal suspend/delete hook.
|
||||
"""
|
||||
session_id, workspace_cwd = self._require_session_identity()
|
||||
descriptor = ShellctlEnvironmentDescriptor(
|
||||
workspace_cwd=workspace_cwd,
|
||||
session_id=session_id,
|
||||
)
|
||||
handle = cast(ShellctlHandle, await self.shell_provisioner.reattach(descriptor))
|
||||
self._shell_handle = handle
|
||||
_ = self._require_client()
|
||||
_ = self._require_session_identity()
|
||||
|
||||
@override
|
||||
async def on_context_suspend(self) -> None:
|
||||
"""Close the live client so it does not leak across snapshot boundaries.
|
||||
"""Preserve workspace and job state while the live client remains active.
|
||||
|
||||
``reattach`` on the next resume creates a fresh client pointing at the
|
||||
same workspace. ``resource_context()`` clears the handle reference after
|
||||
this hook returns.
|
||||
``resource_context()`` owns client teardown after this hook returns.
|
||||
"""
|
||||
handle = self._shell_handle
|
||||
if handle is not None:
|
||||
await handle.client.close()
|
||||
_ = self._require_client()
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
"""Best-effort cleanup for tracked shellctl jobs and workspace deletion.
|
||||
"""Best-effort cleanup for workspace deletion and tracked shellctl jobs.
|
||||
|
||||
Tracked shellctl jobs are force-deleted on a best-effort basis before the
|
||||
handle is destroyed, since job records may outlive the workspace. The
|
||||
provisioner's ``destroy`` handles workspace removal and client close.
|
||||
Workspace removal must happen before tracked shellctl job deletion because
|
||||
the cleanup itself is implemented as an internal shellctl run. That means
|
||||
deleting job state first would prevent the layer from issuing the
|
||||
proposal-required ``rm -rf`` cleanup job and then cleaning up that final
|
||||
job record along with the rest of the session's tracked shellctl state.
|
||||
``resource_context()`` closes the live client only after this hook
|
||||
finishes.
|
||||
"""
|
||||
handle = self._shell_handle
|
||||
if handle is None:
|
||||
return
|
||||
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
|
||||
_ = self._require_client()
|
||||
|
||||
cleanup_job_id: str | None = None
|
||||
identity = self._try_session_identity()
|
||||
if identity is not None:
|
||||
session_id, _workspace_cwd = identity
|
||||
try:
|
||||
cleanup_result = await self._run_internal_job_to_completion(
|
||||
_workspace_cleanup_script(session_id=session_id),
|
||||
cwd=None,
|
||||
)
|
||||
cleanup_job_id = cleanup_result["job_id"]
|
||||
if cleanup_result["exit_code"] != 0:
|
||||
logger.warning(
|
||||
"Shell workspace cleanup job %s for session %s exited with code %s.",
|
||||
cleanup_job_id,
|
||||
session_id,
|
||||
cleanup_result["exit_code"],
|
||||
)
|
||||
except (RuntimeError, ValueError, ShellctlClientError) as exc:
|
||||
logger.warning("Failed to remove shell workspace for session %s: %s", session_id, exc)
|
||||
|
||||
tracked_job_ids = _deduplicate_preserving_order(
|
||||
[*self.runtime_state.job_ids, *([cleanup_job_id] if cleanup_job_id is not None else [])]
|
||||
)
|
||||
await self._delete_tracked_jobs_best_effort(tracked_job_ids)
|
||||
self._clear_tracked_jobs()
|
||||
await self.shell_provisioner.destroy(handle)
|
||||
self._shell_handle = None
|
||||
|
||||
async def _tool_run(self, script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
|
||||
"""Start a new shell job inside the session workspace."""
|
||||
@@ -500,10 +533,8 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
"""Run one trusted server-side script inside the sandbox workspace.
|
||||
|
||||
The sandbox file service uses this boundary for fixed list/read/upload
|
||||
helpers. Execution, output draining, and transient shellctl job cleanup
|
||||
are delegated to ``ShellctlExecutor`` from the shell adapter; the layer
|
||||
owns only the optional Agent Stub env injection and the
|
||||
``RemoteCommandResult`` mapping.
|
||||
helpers. The layer owns output paging, transient shellctl job cleanup,
|
||||
and optional Agent Stub env injection.
|
||||
|
||||
Unlike model-visible ``shell.run``, this server-owned boundary does not
|
||||
inject Agent Soul shell env. Keeping the user-controlled shell env out
|
||||
@@ -515,32 +546,28 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
env = self._build_user_shell_run_env()
|
||||
if env is None:
|
||||
raise RuntimeError("Agent Stub environment injection is not available for this shell session.")
|
||||
handle = self._require_handle()
|
||||
executor = ShellctlExecutor(
|
||||
client=handle.client, # pyright: ignore[reportArgumentType]
|
||||
workspace_cwd=self._require_workspace_cwd(),
|
||||
return await self._run_remote_job_to_completion(
|
||||
script,
|
||||
timeout=timeout,
|
||||
)
|
||||
result = await executor.execute(script, env=env)
|
||||
return RemoteCommandResult(
|
||||
status="exited" if not result.truncated() else "running",
|
||||
exit_code=result.exit_code(),
|
||||
output=result.stdout(),
|
||||
truncated=result.truncated(),
|
||||
env=env,
|
||||
)
|
||||
|
||||
def environment_descriptor(self) -> ShellctlEnvironmentDescriptor:
|
||||
"""Return the serializable workspace seed for the shell adapter.
|
||||
|
||||
Bridges this layer's ``runtime_state`` to
|
||||
``dify_agent.adapters.shell``: the returned descriptor identifies the
|
||||
session workspace so an adapter ``ShellProvisionProtocol.reattach`` can
|
||||
rebuild a live handle pointing at it without re-allocating, and without
|
||||
re-entering this layer. Raises ``ValueError`` if the session identity is
|
||||
missing or inconsistent.
|
||||
"""
|
||||
session_id, workspace_cwd = self._require_session_identity()
|
||||
return ShellctlEnvironmentDescriptor(workspace_cwd=workspace_cwd, session_id=session_id)
|
||||
async def _allocate_workspace(self) -> tuple[str, str]:
|
||||
"""Allocate a unique ``~/workspace/<session_id>`` directory by mkdir collision checks."""
|
||||
for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT):
|
||||
session_id = _generate_session_id()
|
||||
mkdir_result = await self._run_internal_job_to_completion(
|
||||
_workspace_mkdir_script(session_id=session_id),
|
||||
cwd=None,
|
||||
)
|
||||
if mkdir_result["exit_code"] == _WORKSPACE_COLLISION_EXIT_CODE:
|
||||
continue
|
||||
if mkdir_result["exit_code"] != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to create shell workspace {_workspace_cwd(session_id)}: {mkdir_result['status']} exit_code={mkdir_result['exit_code']}"
|
||||
)
|
||||
return session_id, _workspace_cwd(session_id)
|
||||
raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.")
|
||||
|
||||
async def _bootstrap_workspace(self, workspace_cwd: str) -> None:
|
||||
"""Apply Agent Soul shell config to the freshly-created workspace."""
|
||||
@@ -554,24 +581,21 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
)
|
||||
|
||||
async def _cleanup_create_failure(self) -> None:
|
||||
"""Best-effort cleanup for create failures before ACTIVE state.
|
||||
"""Best-effort shellctl job cleanup for create failures before ACTIVE state.
|
||||
|
||||
Agenton only calls ``on_context_delete`` for layers that successfully
|
||||
entered ``ACTIVE``. If ``on_context_create`` fails after issuing
|
||||
internal jobs, those tracked job artifacts would otherwise leak because
|
||||
no later lifecycle hook owns them. The provisioner's ``destroy`` handles
|
||||
workspace removal and client close.
|
||||
entered ``ACTIVE``. If ``on_context_create`` fails after issuing one or
|
||||
more internal shellctl jobs, those tracked job artifacts would otherwise
|
||||
leak because no later lifecycle hook owns them. ``resource_context()``
|
||||
still closes the live client for this failed enter attempt after the hook
|
||||
unwinds.
|
||||
"""
|
||||
handle = self._shell_handle
|
||||
if handle is None:
|
||||
if not self.runtime_state.job_ids:
|
||||
return
|
||||
if self.runtime_state.job_ids:
|
||||
try:
|
||||
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
|
||||
finally:
|
||||
self._clear_tracked_jobs()
|
||||
await self.shell_provisioner.destroy(handle)
|
||||
self._shell_handle = None
|
||||
try:
|
||||
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
|
||||
finally:
|
||||
self._clear_tracked_jobs()
|
||||
|
||||
async def _run_internal_job_to_completion(
|
||||
self,
|
||||
@@ -592,18 +616,56 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
self._track_job_result(result)
|
||||
return _job_result_observation(result)
|
||||
|
||||
def _require_handle(self) -> ShellctlHandle:
|
||||
"""Return the live handle or reject tool/lifecycle use without one."""
|
||||
if self._shell_handle is None:
|
||||
raise RuntimeError(
|
||||
"DifyShellLayer requires an active shell handle inside resource_context(); "
|
||||
+ "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()."
|
||||
async def _run_remote_job_to_completion(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
timeout: float,
|
||||
env: dict[str, str] | None,
|
||||
) -> RemoteCommandResult:
|
||||
"""Run a workspace-scoped script to completion and delete its job state.
|
||||
|
||||
Shellctl's ``truncated`` flag is per output window: it means the caller
|
||||
should continue from the returned offset. After this helper drains those
|
||||
windows, only the final window can describe whether output is still
|
||||
unread, usually because the safety window cap was reached.
|
||||
"""
|
||||
client = self._require_client()
|
||||
job_id: str | None = None
|
||||
try:
|
||||
result = await client.run(script, cwd=self._require_workspace_cwd(), env=env, timeout=timeout)
|
||||
job_id = result.job_id
|
||||
self._track_job_result(result)
|
||||
output_parts = [result.output]
|
||||
windows = 1
|
||||
while (result.truncated or not result.done) and windows < _REMOTE_COMMAND_MAX_OUTPUT_WINDOWS:
|
||||
result = await client.wait(result.job_id, offset=self._tracked_offset(result.job_id), timeout=timeout)
|
||||
self._track_job_result(result)
|
||||
output_parts.append(result.output)
|
||||
windows += 1
|
||||
return RemoteCommandResult(
|
||||
job_id=result.job_id,
|
||||
status=result.status.value,
|
||||
done=result.done,
|
||||
exit_code=result.exit_code,
|
||||
output="".join(output_parts),
|
||||
offset=result.offset,
|
||||
truncated=result.truncated,
|
||||
output_path=result.output_path,
|
||||
)
|
||||
return self._shell_handle
|
||||
finally:
|
||||
if job_id is not None:
|
||||
await self._delete_job_best_effort(job_id)
|
||||
self._forget_tracked_job(job_id)
|
||||
|
||||
def _require_client(self) -> ShellctlClientProtocol:
|
||||
"""Return the live shellctl client from the handle."""
|
||||
return cast(ShellctlClientProtocol, self._require_handle().client)
|
||||
"""Return the live client or reject tool/lifecycle use without one."""
|
||||
if self._shellctl_client is None:
|
||||
raise RuntimeError(
|
||||
"DifyShellLayer requires an active shellctl client inside resource_context(); "
|
||||
+ "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()."
|
||||
)
|
||||
return self._shellctl_client
|
||||
|
||||
def _require_workspace_cwd(self) -> str:
|
||||
"""Return the configured workspace directory for user-facing shell jobs."""
|
||||
@@ -723,6 +785,15 @@ def _shell_layer_prefix_prompt() -> str:
|
||||
return _SHELL_LAYER_PREFIX_PROMPT
|
||||
|
||||
|
||||
def create_shellctl_client_factory(*, token: str) -> ShellctlClientFactory:
|
||||
"""Return the default shellctl client factory used by server-side providers."""
|
||||
|
||||
def factory(entrypoint: str) -> ShellctlClientProtocol:
|
||||
return ShellctlClient(entrypoint, token=token)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
def _job_result_observation(result: JobResult) -> ShellJobObservation:
|
||||
return {
|
||||
"job_id": result.job_id,
|
||||
@@ -753,8 +824,16 @@ def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObs
|
||||
return result
|
||||
|
||||
|
||||
def _generate_session_id() -> str:
|
||||
time_component = int(time.time()) & _SESSION_TIME_HEX_MASK
|
||||
random_component = secrets.token_hex(1)
|
||||
if len(random_component) != _SESSION_RANDOM_HEX_LENGTH:
|
||||
raise RuntimeError("Expected a one-byte random hex suffix for Dify shell session ids.")
|
||||
return f"{time_component:05x}{random_component}"
|
||||
|
||||
|
||||
def _workspace_cwd(session_id: str) -> str:
|
||||
return f"{_WORKSPACE_ROOT}/{session_id}"
|
||||
return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}"
|
||||
|
||||
|
||||
def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
|
||||
@@ -798,11 +877,41 @@ def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str:
|
||||
return "\n".join([*lines, script])
|
||||
|
||||
|
||||
def _workspace_mkdir_script(*, session_id: str) -> str:
|
||||
"""Return the internal mkdir command used for proposal-defined collision checks.
|
||||
|
||||
The parent ``$HOME/workspace`` directory is created with ``mkdir -p`` so it
|
||||
can already exist, but the final session directory intentionally uses plain
|
||||
``mkdir``. That second call is the collision detector: when the target
|
||||
already exists, the script maps that case to ``_WORKSPACE_COLLISION_EXIT_CODE``
|
||||
so ``on_context_create()`` can retry with a different random suffix instead
|
||||
of silently reusing another session's workspace.
|
||||
"""
|
||||
safe_session_id = _validated_session_id(session_id)
|
||||
workspace_dir = f"$HOME/workspace/{safe_session_id}"
|
||||
return (
|
||||
'mkdir -p "$HOME/workspace"; '
|
||||
f'if mkdir "{workspace_dir}"; then exit 0; fi; '
|
||||
f'if [ -e "{workspace_dir}" ]; then exit {_WORKSPACE_COLLISION_EXIT_CODE}; fi; '
|
||||
"exit 1"
|
||||
)
|
||||
|
||||
|
||||
def _workspace_cleanup_script(*, session_id: str) -> str:
|
||||
return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"'
|
||||
|
||||
|
||||
def _shquote(value: str) -> str:
|
||||
"""Single-quote a value for POSIX shells, escaping embedded single quotes."""
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def _validated_session_id(session_id: str) -> str:
|
||||
if not _SESSION_ID_PATTERN.fullmatch(session_id):
|
||||
raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.")
|
||||
return session_id
|
||||
|
||||
|
||||
def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
@@ -819,5 +928,7 @@ __all__ = [
|
||||
"DifyShellLayer",
|
||||
"DifyShellRuntimeState",
|
||||
"RemoteCommandResult",
|
||||
"ShellctlClientFactory",
|
||||
"ShellctlClientProtocol",
|
||||
"create_shellctl_client_factory",
|
||||
]
|
||||
|
||||
@@ -44,10 +44,8 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer
|
||||
from dify_agent.layers.output.output_layer import DifyOutputLayer
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provisioner
|
||||
from dify_agent.layers.shell.configs import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory
|
||||
|
||||
type DifyAgentLayerProvider = LayerProvider[Any]
|
||||
|
||||
@@ -65,11 +63,13 @@ def create_default_layer_providers(
|
||||
) -> tuple[DifyAgentLayerProvider, ...]:
|
||||
"""Return the server provider set of safe config-constructible layers.
|
||||
|
||||
``shellctl_auth_token`` defaults to no token. An explicit empty string
|
||||
prevents ``ShellctlClient`` from falling back to the Dify Agent process's
|
||||
``SHELLCTL_AUTH_TOKEN`` environment variable; deployments that enable
|
||||
shellctl bearer auth must set the Dify Agent server setting explicitly.
|
||||
``shellctl_auth_token`` defaults to no token. Passing an explicit empty string
|
||||
to ``create_shellctl_client_factory`` prevents ``ShellctlClient`` from falling
|
||||
back to the Dify Agent process's ``SHELLCTL_AUTH_TOKEN`` environment variable;
|
||||
deployments that enable shellctl bearer auth must set the Dify Agent server
|
||||
setting explicitly.
|
||||
"""
|
||||
shellctl_token = shellctl_auth_token or ""
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
if agent_stub_token_codec is not None:
|
||||
|
||||
@@ -102,15 +102,8 @@ def create_default_layer_providers(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shell_provisioner=create_shell_provisioner(
|
||||
ShellAdapterSettings(
|
||||
shell_provider="shellctl",
|
||||
shellctl_entrypoint=shellctl_entrypoint,
|
||||
shellctl_auth_token=shellctl_auth_token,
|
||||
)
|
||||
)
|
||||
if shellctl_entrypoint
|
||||
else None,
|
||||
shellctl_entrypoint=shellctl_entrypoint,
|
||||
shellctl_client_factory=create_shellctl_client_factory(token=shellctl_token),
|
||||
agent_stub_api_base_url=agent_stub_api_base_url,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
),
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
"""Local tests for the shellctl shell adapter and env-driven provider factory.
|
||||
|
||||
These exercise the provider-agnostic boundary contract (provision/execute/wait,
|
||||
file transfer, optional input/interrupt) against a fake shellctl client, plus the
|
||||
``DIFY_AGENT_SHELL_PROVIDER`` selection in the factory. They avoid the private
|
||||
``shell-session-manager`` package by injecting a structural fake client.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.adapters.shell import shellctl
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provisioner
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
ShellEnvironmentDescriptor,
|
||||
)
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlEnvironmentDescriptor,
|
||||
ShellctlProvisioner,
|
||||
ShellFileTransferError,
|
||||
ShellProvisionError,
|
||||
)
|
||||
|
||||
_SESSION_HEX = "deadbeefdeadbeef"
|
||||
_WORKSPACE_CWD = f"~/workspace/{_SESSION_HEX}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Job:
|
||||
job_id: str
|
||||
done: bool = True
|
||||
output: str = ""
|
||||
offset: int = 0
|
||||
truncated: bool = False
|
||||
exit_code: int | None = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Status:
|
||||
job_id: str
|
||||
done: bool = True
|
||||
offset: int = 0
|
||||
exit_code: int | None = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RunCall:
|
||||
script: str
|
||||
cwd: str | None
|
||||
env: dict[str, str] | None
|
||||
|
||||
|
||||
type _RunHandler = Callable[[str, str | None, dict[str, str] | None], _Job]
|
||||
type _WaitHandler = Callable[[str, int], _Job]
|
||||
type _InputHandler = Callable[[str, str, int], _Job]
|
||||
type _TerminateHandler = Callable[[str], _Status]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FakeShellctlClient:
|
||||
"""Structural shellctl client double recording calls and replaying handlers."""
|
||||
|
||||
run_handler: _RunHandler | None = None
|
||||
wait_handler: _WaitHandler | None = None
|
||||
input_handler: _InputHandler | None = None
|
||||
terminate_handler: _TerminateHandler | None = None
|
||||
run_calls: list[_RunCall] = field(default_factory=list)
|
||||
wait_calls: list[tuple[str, int]] = field(default_factory=list)
|
||||
input_calls: list[tuple[str, str, int]] = field(default_factory=list)
|
||||
terminate_calls: list[tuple[str, float]] = field(default_factory=list)
|
||||
delete_calls: list[str] = field(default_factory=list)
|
||||
closed: bool = False
|
||||
|
||||
async def run(self, script, *, cwd=None, env=None, timeout=30.0):
|
||||
del timeout
|
||||
self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env))
|
||||
if self.run_handler is not None:
|
||||
return self.run_handler(script, cwd, env)
|
||||
return _Job(job_id="job", done=True, exit_code=0)
|
||||
|
||||
async def wait(self, job_id, *, offset, timeout=30.0):
|
||||
del timeout
|
||||
self.wait_calls.append((job_id, offset))
|
||||
if self.wait_handler is not None:
|
||||
return self.wait_handler(job_id, offset)
|
||||
return _Job(job_id=job_id, done=True, offset=offset, exit_code=0)
|
||||
|
||||
async def input(self, job_id, text, *, offset, timeout=30.0):
|
||||
del timeout
|
||||
self.input_calls.append((job_id, text, offset))
|
||||
if self.input_handler is not None:
|
||||
return self.input_handler(job_id, text, offset)
|
||||
return _Job(job_id=job_id, done=True, offset=offset, exit_code=0)
|
||||
|
||||
async def terminate(self, job_id, grace_seconds=10.0):
|
||||
self.terminate_calls.append((job_id, grace_seconds))
|
||||
if self.terminate_handler is not None:
|
||||
return self.terminate_handler(job_id)
|
||||
return _Status(job_id=job_id, done=True, exit_code=130)
|
||||
|
||||
async def delete(self, job_id, *, force=False):
|
||||
del force
|
||||
self.delete_calls.append(job_id)
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fixed_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: _SESSION_HEX)
|
||||
|
||||
|
||||
def _provisioner(client: FakeShellctlClient) -> ShellctlProvisioner:
|
||||
return ShellctlProvisioner(client_factory=lambda: client)
|
||||
|
||||
|
||||
def test_provision_allocates_workspace_and_execute_drains_merged_output() -> None:
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del env
|
||||
if script.startswith("mkdir"):
|
||||
assert cwd is None
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
assert cwd == _WORKSPACE_CWD
|
||||
return _Job(job_id="user-job", done=False, output="par", offset=3, truncated=False, exit_code=None)
|
||||
|
||||
def wait_handler(job_id: str, offset: int) -> _Job:
|
||||
assert job_id == "user-job"
|
||||
assert offset == 3
|
||||
return _Job(job_id="user-job", done=True, output="tial", offset=7, exit_code=0)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
handle = await _provisioner(client).provision()
|
||||
assert handle.workspace_cwd == _WORKSPACE_CWD
|
||||
executor = await handle.get_executor()
|
||||
result = await executor.execute("pwd", env={"FOO": "bar"})
|
||||
assert result.stdout() == "partial"
|
||||
assert result.stderr() == ""
|
||||
assert result.exit_code() == 0
|
||||
assert result.truncated() is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.run_calls[0].cwd is None
|
||||
user_run = next(call for call in client.run_calls if call.script == "pwd")
|
||||
assert user_run.env == {"FOO": "bar"}
|
||||
# completed jobs (internal mkdir + user command) are self-cleaned.
|
||||
assert "mkdir-job" in client.delete_calls
|
||||
assert "user-job" in client.delete_calls
|
||||
|
||||
|
||||
def test_execute_reports_truncated_when_output_window_cap_is_hit() -> None:
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del cwd, env
|
||||
if script.startswith("mkdir"):
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
return _Job(job_id="user-job", done=False, output="x", offset=1, truncated=True, exit_code=None)
|
||||
|
||||
def wait_handler(job_id: str, offset: int) -> _Job:
|
||||
return _Job(job_id=job_id, done=False, output="x", offset=offset + 1, truncated=True, exit_code=None)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
|
||||
async def scenario() -> bool:
|
||||
handle = await _provisioner(client).provision()
|
||||
executor = await handle.get_executor()
|
||||
result = await executor.execute("tail -f log")
|
||||
return result.truncated()
|
||||
|
||||
assert asyncio.run(scenario()) is True
|
||||
# a job that never completed is left intact (not deleted/forgotten).
|
||||
assert "user-job" not in client.delete_calls
|
||||
|
||||
|
||||
def test_provision_failure_closes_client_and_raises() -> None:
|
||||
client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=1)
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(ShellProvisionError):
|
||||
await _provisioner(client).provision()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_destroy_runs_cleanup_in_default_cwd_then_closes_client() -> None:
|
||||
client = FakeShellctlClient(run_handler=lambda _script, _cwd, _env: _Job(job_id="job", done=True, exit_code=0))
|
||||
|
||||
async def scenario() -> None:
|
||||
provisioner = _provisioner(client)
|
||||
handle = await provisioner.provision()
|
||||
await provisioner.destroy(handle)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
cleanup_call = client.run_calls[-1]
|
||||
assert cleanup_call.cwd is None
|
||||
assert _SESSION_HEX in cleanup_call.script and cleanup_call.script.startswith("rm -rf")
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_file_transfer_download_decodes_sentinel_framed_base64() -> None:
|
||||
content = b"hello \x00 world"
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
framed = f"noise{shellctl._TRANSFER_BEGIN}{encoded}{shellctl._TRANSFER_END}trailing"
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del env
|
||||
if script.startswith("mkdir"):
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
assert cwd == _WORKSPACE_CWD
|
||||
return _Job(job_id="dl-job", done=True, output=framed, exit_code=0)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
handle = await _provisioner(client).provision()
|
||||
transfer = await handle.get_file_transfer()
|
||||
downloaded = await transfer.download(remote_path="report.txt")
|
||||
assert downloaded == content
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_file_transfer_download_missing_file_raises() -> None:
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del cwd, env
|
||||
if script.startswith("mkdir"):
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
return _Job(job_id="dl-job", done=True, output="", exit_code=shellctl._DOWNLOAD_MISSING_EXIT_CODE)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
handle = await _provisioner(client).provision()
|
||||
transfer = await handle.get_file_transfer()
|
||||
with pytest.raises(ShellFileTransferError, match="not found"):
|
||||
await transfer.download(remote_path="missing.txt")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_file_transfer_upload_embeds_base64_and_succeeds() -> None:
|
||||
content = b"payload-bytes"
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del env
|
||||
if script.startswith('mkdir -p "$HOME'):
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
assert cwd == _WORKSPACE_CWD
|
||||
assert encoded in script
|
||||
return _Job(job_id="ul-job", done=True, exit_code=0)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
handle = await _provisioner(client).provision()
|
||||
transfer = await handle.get_file_transfer()
|
||||
await transfer.upload(content=content, remote_path="out.bin")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_provision_exposes_descriptor_seed() -> None:
|
||||
client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
)
|
||||
|
||||
async def scenario() -> ShellEnvironmentDescriptor:
|
||||
handle = await _provisioner(client).provision()
|
||||
return handle.descriptor()
|
||||
|
||||
descriptor = asyncio.run(scenario())
|
||||
assert isinstance(descriptor, ShellctlEnvironmentDescriptor)
|
||||
assert descriptor.workspace_cwd == _WORKSPACE_CWD
|
||||
assert descriptor.session_id == _SESSION_HEX
|
||||
|
||||
|
||||
def test_reattach_rebuilds_handle_without_mkdir_and_executes_in_same_workspace() -> None:
|
||||
descriptor = ShellctlEnvironmentDescriptor(workspace_cwd=_WORKSPACE_CWD, session_id=_SESSION_HEX)
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del env
|
||||
assert not script.startswith("mkdir")
|
||||
assert cwd == _WORKSPACE_CWD
|
||||
return _Job(job_id="user-job", done=True, output="ok", offset=2, exit_code=0)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> str:
|
||||
handle = await _provisioner(client).reattach(descriptor)
|
||||
executor = await handle.get_executor()
|
||||
result = await executor.execute("pwd")
|
||||
return result.stdout()
|
||||
|
||||
assert asyncio.run(scenario()) == "ok"
|
||||
# reattach must not allocate a new workspace.
|
||||
assert all(not call.script.startswith("mkdir") for call in client.run_calls)
|
||||
|
||||
|
||||
def test_factory_unknown_provider_raises() -> None:
|
||||
settings = ShellAdapterSettings(shell_provider="nope")
|
||||
with pytest.raises(ValueError, match="Unknown shell provider"):
|
||||
create_shell_provisioner(settings)
|
||||
|
||||
|
||||
def test_factory_shellctl_requires_entrypoint() -> None:
|
||||
settings = ShellAdapterSettings(shell_provider="shellctl", shellctl_entrypoint=None)
|
||||
with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
|
||||
create_shell_provisioner(settings)
|
||||
|
||||
|
||||
def test_factory_builds_shellctl_provisioner_from_settings() -> None:
|
||||
settings = ShellAdapterSettings(
|
||||
shell_provider="shellctl",
|
||||
shellctl_entrypoint="http://shellctl.example",
|
||||
)
|
||||
provisioner = create_shell_provisioner(settings)
|
||||
assert isinstance(provisioner, ShellctlProvisioner)
|
||||
@@ -2,23 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult, ShellctlClientFactory
|
||||
|
||||
|
||||
def _unused_client_factory():
|
||||
def _unused_client_factory(_entrypoint: str):
|
||||
raise AssertionError("shellctl client should not be used by these drive-layer tests")
|
||||
|
||||
|
||||
def _shell_layer() -> DifyShellLayer:
|
||||
return DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=_unused_client_factory),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=cast(ShellctlClientFactory, _unused_client_factory),
|
||||
)
|
||||
|
||||
|
||||
@@ -57,10 +59,14 @@ def _remote_result(
|
||||
truncated: bool = False,
|
||||
) -> RemoteCommandResult:
|
||||
return RemoteCommandResult(
|
||||
job_id="remote-drive-pull",
|
||||
status="exited",
|
||||
done=True,
|
||||
exit_code=exit_code,
|
||||
output=output,
|
||||
offset=len(output),
|
||||
truncated=truncated,
|
||||
output_path="/tmp/output.log",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
@@ -23,14 +24,8 @@ from dify_agent.layers.shell import (
|
||||
DifyShellSandboxConfig,
|
||||
DifyShellSecretRefConfig,
|
||||
)
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlEnvironmentDescriptor,
|
||||
ShellctlHandle,
|
||||
ShellctlProvisioner,
|
||||
ShellProvisionError,
|
||||
)
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState
|
||||
from shell_session_manager.shellctl.shared import JobResult, JobStatusName, JobStatusView
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState, ShellctlClientFactory
|
||||
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
|
||||
|
||||
|
||||
def _job_result(
|
||||
@@ -121,6 +116,7 @@ class TerminateCall:
|
||||
class DeleteCall:
|
||||
job_id: str
|
||||
force: bool
|
||||
grace_seconds: float | None
|
||||
|
||||
|
||||
class FakeShellctlClient:
|
||||
@@ -139,7 +135,7 @@ class FakeShellctlClient:
|
||||
wait_handler: Callable[[str, int, float], JobResult] | None = None,
|
||||
input_handler: Callable[[str, str, int, float], JobResult] | None = None,
|
||||
terminate_handler: Callable[[str, float], JobStatusView] | None = None,
|
||||
delete_handler: Callable[[str, bool, float | None], object] | None = None,
|
||||
delete_handler: Callable[[str, bool, float | None], DeleteJobResponse] | None = None,
|
||||
) -> None:
|
||||
self._run_handler = run_handler
|
||||
self._wait_handler = wait_handler
|
||||
@@ -194,22 +190,26 @@ class FakeShellctlClient:
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> object:
|
||||
self.delete_calls.append(DeleteCall(job_id=job_id, force=force))
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
self.delete_calls.append(DeleteCall(job_id=job_id, force=force, grace_seconds=grace_seconds))
|
||||
self.events.append(("delete", job_id))
|
||||
if self._delete_handler is None:
|
||||
return None
|
||||
return self._delete_handler(job_id, force, None)
|
||||
return DeleteJobResponse(job_id=job_id)
|
||||
return self._delete_handler(job_id, force, grace_seconds)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
self.events.append(("close", "client"))
|
||||
|
||||
|
||||
def _shell_layer(*, client: FakeShellctlClient, config: DifyShellLayerConfig | None = None) -> DifyShellLayer:
|
||||
def _shell_layer(
|
||||
*, client_factory: ShellctlClientFactory, config: DifyShellLayerConfig | None = None
|
||||
) -> DifyShellLayer:
|
||||
return DifyShellLayer.from_config_with_settings(
|
||||
config or DifyShellLayerConfig(),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=client_factory,
|
||||
)
|
||||
|
||||
|
||||
@@ -236,12 +236,13 @@ def _execution_context_layer() -> DifyExecutionContextLayer:
|
||||
)
|
||||
|
||||
|
||||
def _shell_provider(*, client: FakeShellctlClient) -> LayerProvider[DifyShellLayer]:
|
||||
def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=client_factory,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -250,34 +251,31 @@ def test_shell_type_id_constant_matches_implementation_class() -> None:
|
||||
assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id
|
||||
|
||||
|
||||
def test_environment_descriptor_returns_workspace_seed_from_runtime_state() -> None:
|
||||
layer = _shell_layer(client=FakeShellctlClient())
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
|
||||
descriptor = layer.environment_descriptor()
|
||||
|
||||
assert descriptor == ShellctlEnvironmentDescriptor(workspace_cwd="~/workspace/abc12ff", session_id="abc12ff")
|
||||
|
||||
|
||||
def test_environment_descriptor_raises_without_session_identity() -> None:
|
||||
layer = _shell_layer(client=FakeShellctlClient())
|
||||
|
||||
with pytest.raises(ValueError, match="session_id or workspace_cwd"):
|
||||
_ = layer.environment_descriptor()
|
||||
|
||||
|
||||
def test_shell_layer_create_provisions_workspace_and_bootstraps(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "deadbeefdeadbeef")
|
||||
def test_shell_layer_create_generates_5_plus_2_hex_session_id_and_retries_workspace_collision(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
random_suffixes = iter(["aa", "bb"])
|
||||
monkeypatch.setattr(time, "time", lambda: 0x12345F)
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda nbytes: next(random_suffixes))
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
|
||||
assert cwd is None
|
||||
assert env is None
|
||||
if cwd is None:
|
||||
assert 'mkdir -p "$HOME/workspace/deadbeefdeadbeef"' in script
|
||||
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
raise AssertionError(f"Unexpected script with cwd={cwd}: {script}")
|
||||
assert timeout == 30.0
|
||||
if "2345faa" in script:
|
||||
return _job_result("mkdir-collision", status=JobStatusName.EXITED, done=True, exit_code=17)
|
||||
if "2345fbb" in script:
|
||||
return _job_result("mkdir-success", status=JobStatusName.RUNNING, done=False, offset=4)
|
||||
raise AssertionError(f"Unexpected script: {script}")
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(client=client)
|
||||
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
|
||||
assert job_id == "mkdir-success"
|
||||
assert offset == 4
|
||||
assert timeout == 30.0
|
||||
return _job_result("mkdir-success", status=JobStatusName.EXITED, done=True, exit_code=0, offset=8)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
@@ -286,25 +284,29 @@ def test_shell_layer_create_provisions_workspace_and_bootstraps(monkeypatch: pyt
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert layer.runtime_state.session_id == "deadbeefdeadbeef"
|
||||
assert layer.runtime_state.workspace_cwd == "~/workspace/deadbeefdeadbeef"
|
||||
assert layer.runtime_state.session_id == "2345fbb"
|
||||
assert layer.runtime_state.workspace_cwd == "~/workspace/2345fbb"
|
||||
assert layer.runtime_state.job_ids == ["mkdir-collision", "mkdir-success"]
|
||||
assert layer.runtime_state.job_offsets == {"mkdir-collision": 0, "mkdir-success": 8}
|
||||
assert 'mkdir "$HOME/workspace/2345fbb"' in client.run_calls[1].script
|
||||
assert 'mkdir -p "$HOME/workspace/2345fbb"' not in client.run_calls[1].script
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_suspend_closes_client_before_resource_context_exits() -> None:
|
||||
def test_shell_layer_suspend_leaves_client_open_until_resource_context_exits() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client=client)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
await layer.on_context_suspend()
|
||||
assert client.closed is True
|
||||
assert client.closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None:
|
||||
first_client = FakeShellctlClient(
|
||||
@@ -315,31 +317,15 @@ def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None
|
||||
exit_code=0,
|
||||
)
|
||||
)
|
||||
second_client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _env, _timeout: _job_result(
|
||||
"cleanup-job",
|
||||
status=JobStatusName.EXITED,
|
||||
done=True,
|
||||
exit_code=0,
|
||||
)
|
||||
)
|
||||
second_client = FakeShellctlClient()
|
||||
created_entrypoints: list[str] = []
|
||||
clients = iter([first_client, second_client])
|
||||
|
||||
def factory() -> FakeShellctlClient:
|
||||
def factory(entrypoint: str) -> FakeShellctlClient:
|
||||
created_entrypoints.append(entrypoint)
|
||||
return next(clients)
|
||||
|
||||
provisioner = ShellctlProvisioner(client_factory=factory)
|
||||
|
||||
def make_provider(c: FakeShellctlClient) -> LayerProvider[DifyShellLayer]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shell_provisioner=provisioner,
|
||||
),
|
||||
)
|
||||
|
||||
compositor = Compositor([LayerNode("shell", make_provider(first_client))])
|
||||
compositor = Compositor([LayerNode("shell", _shell_provider(client_factory=factory))])
|
||||
|
||||
async def scenario() -> None:
|
||||
async with compositor.enter(configs={"shell": DifyShellLayerConfig()}) as run:
|
||||
@@ -367,44 +353,57 @@ def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None
|
||||
assert second_client.closed is False
|
||||
assert resumed_shell.runtime_state.session_id == initial_session_id
|
||||
assert resumed_shell.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}"
|
||||
assert set(resumed_shell.runtime_state.job_ids) == {"user-job"}
|
||||
assert resumed_shell.runtime_state.job_offsets == {"user-job": 42}
|
||||
assert set(resumed_shell.runtime_state.job_ids) == {"mkdir-job", "user-job"}
|
||||
assert resumed_shell.runtime_state.job_offsets == {"mkdir-job": 0, "user-job": 42}
|
||||
resumed_run.suspend_layer_on_exit("shell")
|
||||
|
||||
assert second_client.closed is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert created_entrypoints == ["http://shellctl", "http://shellctl"]
|
||||
|
||||
def test_shell_layer_delete_force_deletes_tracked_jobs_then_destroys_workspace() -> None:
|
||||
|
||||
def test_shell_layer_delete_removes_workspace_then_force_deletes_tracked_jobs_and_closes_client() -> None:
|
||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
|
||||
del cwd, env, timeout
|
||||
return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
assert script == 'rm -rf -- "$HOME/workspace/abc12ff"'
|
||||
assert cwd is None
|
||||
assert env is None
|
||||
assert timeout == 30.0
|
||||
return _job_result("cleanup-job", status=JobStatusName.RUNNING, done=False, offset=3)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(client=client)
|
||||
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
|
||||
assert job_id == "cleanup-job"
|
||||
assert offset == 3
|
||||
assert timeout == 30.0
|
||||
return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0, offset=5)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer.runtime_state.job_ids = ["user-job", "mkdir-job"]
|
||||
layer.runtime_state.job_offsets = {"user-job": 9, "mkdir-job": 1}
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
await layer.on_context_delete()
|
||||
assert client.closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
deleted_job_ids = {call.job_id for call in client.delete_calls}
|
||||
assert {"user-job", "mkdir-job"}.issubset(deleted_job_ids)
|
||||
assert client.events[:2] == [("run", 'rm -rf -- "$HOME/workspace/abc12ff"'), ("wait", "cleanup-job")]
|
||||
assert {call.job_id for call in client.delete_calls} == {"user-job", "mkdir-job", "cleanup-job"}
|
||||
assert all(
|
||||
client.events.index(("delete", call.job_id)) > client.events.index(("wait", "cleanup-job"))
|
||||
for call in client.delete_calls
|
||||
)
|
||||
assert all(call.force is True for call in client.delete_calls)
|
||||
assert layer.runtime_state.job_ids == []
|
||||
assert layer.runtime_state.job_offsets == {}
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_create_failure_destroys_provisioned_workspace() -> None:
|
||||
def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising() -> None:
|
||||
client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _env, _timeout: _job_result(
|
||||
"mkdir-failed",
|
||||
@@ -413,26 +412,32 @@ def test_shell_layer_create_failure_destroys_provisioned_workspace() -> None:
|
||||
exit_code=1,
|
||||
)
|
||||
)
|
||||
layer = _shell_layer(client=client)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(ShellProvisionError, match="Failed to create shell workspace"):
|
||||
with pytest.raises(RuntimeError, match="Failed to create shell workspace"):
|
||||
async with layer.resource_context():
|
||||
await layer.on_context_create()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [call.job_id for call in client.delete_calls] == ["mkdir-failed"]
|
||||
assert all(call.force is True for call in client.delete_calls)
|
||||
assert layer.runtime_state.job_ids == []
|
||||
assert layer.runtime_state.job_offsets == {}
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "abc12ffabc12ff")
|
||||
monkeypatch.setattr(time, "time", lambda: 0xABC12)
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "ff")
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
|
||||
assert env is None
|
||||
if cwd is None:
|
||||
assert timeout == 30.0
|
||||
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
assert cwd == "~/workspace/abc12ffabc12ff"
|
||||
assert cwd == "~/workspace/abc12ff"
|
||||
assert "export PROJECT_NAME='demo project'" in script
|
||||
assert "export QUOTED='it'\\''s ok'" in script
|
||||
assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
|
||||
@@ -445,7 +450,7 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(
|
||||
client=client,
|
||||
client_factory=lambda _entrypoint: client,
|
||||
config=DifyShellLayerConfig(
|
||||
cli_tools=[
|
||||
DifyShellCliToolConfig(
|
||||
@@ -470,11 +475,17 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ffabc12ff"]
|
||||
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"]
|
||||
assert layer.runtime_state.job_ids == ["mkdir-job", "bootstrap-job"]
|
||||
|
||||
|
||||
def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "abc12ffabc12ff")
|
||||
monkeypatch.setattr(time, "time", lambda: 0xABC12)
|
||||
|
||||
def token_hex(_nbytes: int) -> str:
|
||||
return "ff"
|
||||
|
||||
monkeypatch.setattr(secrets, "token_hex", token_hex)
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
|
||||
del timeout
|
||||
@@ -482,7 +493,7 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
|
||||
if cwd is None:
|
||||
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
|
||||
assert cwd == "~/workspace/abc12ffabc12ff"
|
||||
assert cwd == "~/workspace/abc12ff"
|
||||
assert "export PROJECT_NAME='demo project'" in script
|
||||
assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
|
||||
assert "export DIFY_SANDBOX_PROVIDER='independent'" in script
|
||||
@@ -492,7 +503,7 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(
|
||||
client=client,
|
||||
client_factory=lambda _entrypoint: client,
|
||||
config=DifyShellLayerConfig(
|
||||
env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project")],
|
||||
secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")],
|
||||
@@ -515,7 +526,8 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ffabc12ff"]
|
||||
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"]
|
||||
assert layer.runtime_state.job_ids == ["mkdir-job", "user-job"]
|
||||
|
||||
|
||||
def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None:
|
||||
@@ -575,15 +587,12 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -
|
||||
input_handler=input_handler,
|
||||
terminate_handler=terminate_handler,
|
||||
)
|
||||
layer = _shell_layer(client=client)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
|
||||
run_tool_def = await tools["shell_run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
wait_tool_def = await tools["shell_wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
@@ -633,7 +642,7 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -
|
||||
|
||||
assert layer.runtime_state.job_ids == ["user-job"]
|
||||
assert layer.runtime_state.job_offsets == {"user-job": 22}
|
||||
assert client.closed is False
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None:
|
||||
@@ -648,7 +657,8 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
@@ -710,14 +720,11 @@ def test_run_remote_script_uses_workspace_cwd_accumulates_output_and_deletes_job
|
||||
)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
layer = _shell_layer(client=client)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
result = await layer.run_remote_script("printf 'hello world'", timeout=7.5)
|
||||
assert result.output == "hello world"
|
||||
assert result.exit_code == 0
|
||||
@@ -746,14 +753,11 @@ def test_run_remote_script_deletes_job_even_when_command_exits_non_zero() -> Non
|
||||
)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(client=client)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
result = await layer.run_remote_script("exit 17", timeout=3.0)
|
||||
assert result.exit_code == 17
|
||||
assert result.output == "failed\n"
|
||||
@@ -781,7 +785,8 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
@@ -792,9 +797,6 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
_ = await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True)
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -813,7 +815,8 @@ def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None:
|
||||
)
|
||||
layer = DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
@@ -823,9 +826,6 @@ def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None:
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Agent Stub environment injection is not available"):
|
||||
await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True)
|
||||
|
||||
@@ -845,7 +845,8 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
|
||||
)
|
||||
layer = DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
@@ -856,9 +857,6 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
_ = await tools["shell_run"].function_schema.call(
|
||||
{"script": "pwd"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
@@ -871,15 +869,12 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
|
||||
|
||||
def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client=client)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
|
||||
wait_result = await tools["shell_wait"].function_schema.call(
|
||||
{"job_id": "missing-job"},
|
||||
@@ -907,16 +902,19 @@ def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() ->
|
||||
|
||||
def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client=client)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(RuntimeError, match="resource_context"):
|
||||
await layer.on_context_suspend()
|
||||
|
||||
run_result = await tools["shell_run"].function_schema.call(
|
||||
{"script": "pwd"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
_assert_error_observation(run_result, includes="shell handle")
|
||||
_assert_error_observation(run_result, includes="resource_context")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -924,7 +922,7 @@ def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_contex
|
||||
|
||||
|
||||
def test_shell_runtime_state_rejects_unsafe_resumed_workspace_identity() -> None:
|
||||
with pytest.raises(ValueError, match="session_id must be 7 or 16 lowercase hex characters"):
|
||||
with pytest.raises(ValueError, match="session_id must match"):
|
||||
_ = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
"session_id": "../../tmp",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
import dify_agent.runtime.compositor_factory as compositor_factory_module
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
@@ -12,30 +8,30 @@ from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
|
||||
|
||||
class FakeProvisioner:
|
||||
"""No-op provisioner for tests that never actually provision a workspace."""
|
||||
|
||||
async def provision(self) -> object:
|
||||
raise AssertionError("provision should not be called by these tests")
|
||||
|
||||
async def reattach(self, descriptor: object) -> object:
|
||||
raise AssertionError("reattach should not be called by these tests")
|
||||
|
||||
async def destroy(self, handle: object) -> None:
|
||||
raise AssertionError("destroy should not be called by these tests")
|
||||
class FakeFactoryClient:
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_default_layer_providers_register_shell_layer_with_configured_token_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_settings: list[ShellAdapterSettings] = []
|
||||
fake_provisioner = FakeProvisioner()
|
||||
captured_tokens: list[str] = []
|
||||
captured_entrypoints: list[str] = []
|
||||
fake_client = FakeFactoryClient()
|
||||
|
||||
def fake_create_shell_provisioner(settings: ShellAdapterSettings) -> ShellProvisionProtocol:
|
||||
captured_settings.append(settings)
|
||||
return cast(ShellProvisionProtocol, fake_provisioner)
|
||||
def fake_create_shellctl_client_factory(*, token: str):
|
||||
captured_tokens.append(token)
|
||||
|
||||
monkeypatch.setattr(compositor_factory_module, "create_shell_provisioner", fake_create_shell_provisioner)
|
||||
def factory(entrypoint: str) -> FakeFactoryClient:
|
||||
captured_entrypoints.append(entrypoint)
|
||||
return fake_client
|
||||
|
||||
return factory
|
||||
|
||||
monkeypatch.setattr(
|
||||
compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory
|
||||
)
|
||||
|
||||
providers = create_default_layer_providers(
|
||||
shellctl_entrypoint="http://shellctl.example",
|
||||
@@ -45,29 +41,34 @@ def test_default_layer_providers_register_shell_layer_with_configured_token_fact
|
||||
shell_layer = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
|
||||
assert isinstance(shell_layer, DifyShellLayer)
|
||||
assert shell_layer.shell_provisioner is fake_provisioner
|
||||
assert len(captured_settings) == 1
|
||||
assert captured_settings[0].shellctl_entrypoint == "http://shellctl.example"
|
||||
assert captured_settings[0].shellctl_auth_token == "shell-secret"
|
||||
assert shell_layer.shellctl_entrypoint == "http://shellctl.example"
|
||||
assert captured_tokens == ["shell-secret"]
|
||||
assert shell_layer.shellctl_client_factory(shell_layer.shellctl_entrypoint) is fake_client
|
||||
assert captured_entrypoints == ["http://shellctl.example"]
|
||||
|
||||
|
||||
def test_default_layer_providers_keep_empty_shellctl_token_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_settings: list[ShellAdapterSettings] = []
|
||||
captured_tokens: list[str] = []
|
||||
|
||||
def fake_create_shell_provisioner(settings: ShellAdapterSettings) -> ShellProvisionProtocol:
|
||||
captured_settings.append(settings)
|
||||
return cast(ShellProvisionProtocol, FakeProvisioner())
|
||||
def fake_create_shellctl_client_factory(*, token: str):
|
||||
captured_tokens.append(token)
|
||||
|
||||
monkeypatch.setattr(compositor_factory_module, "create_shell_provisioner", fake_create_shell_provisioner)
|
||||
def factory(_entrypoint: str) -> FakeFactoryClient:
|
||||
return FakeFactoryClient()
|
||||
|
||||
return factory
|
||||
|
||||
monkeypatch.setattr(
|
||||
compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory
|
||||
)
|
||||
|
||||
providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example")
|
||||
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
|
||||
_ = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
|
||||
assert len(captured_settings) == 1
|
||||
assert captured_settings[0].shellctl_auth_token is None
|
||||
assert captured_tokens == [""]
|
||||
|
||||
|
||||
def test_shell_provider_rejects_blank_settings_entrypoint_only_when_shell_layer_is_created() -> None:
|
||||
|
||||
@@ -29,7 +29,6 @@ from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer
|
||||
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
@@ -1347,7 +1346,8 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: shell_client),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: shell_client,
|
||||
),
|
||||
)
|
||||
layer_providers = tuple(
|
||||
@@ -2342,7 +2342,7 @@ def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None:
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(AgentRunValidationError, match="non-null shell provisioner"):
|
||||
with pytest.raises(AgentRunValidationError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
from typing import ClassVar
|
||||
@@ -7,8 +8,7 @@ from typing import ClassVar
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
|
||||
from shell_session_manager.shellctl.client import ShellctlClient
|
||||
|
||||
import dify_agent.server.app as app_module
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
@@ -246,8 +246,12 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
assert isinstance(knowledge_layer, DifyKnowledgeBaseLayer)
|
||||
assert knowledge_layer.inner_api_url == "http://dify-api"
|
||||
assert knowledge_layer.inner_api_key == "inner-secret"
|
||||
assert isinstance(shell_layer.shell_provisioner, ShellctlProvisioner)
|
||||
assert shell_layer.shellctl_entrypoint == "http://shellctl"
|
||||
assert shell_layer.agent_stub_api_base_url == "https://agent.example.com/agent-stub"
|
||||
shellctl_client = shell_layer.shellctl_client_factory("http://shellctl")
|
||||
assert isinstance(shellctl_client, ShellctlClient)
|
||||
assert shellctl_client.token == "shell-secret"
|
||||
asyncio.run(shellctl_client.close())
|
||||
http_client = scheduler.plugin_daemon_http_client
|
||||
assert http_client is fake_http_client
|
||||
assert http_client.is_closed is False
|
||||
|
||||
@@ -19,7 +19,6 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
|
||||
from dify_agent.protocol import (
|
||||
CreateRunRequest,
|
||||
RunComposition,
|
||||
@@ -39,7 +38,7 @@ from dify_agent.server.sandbox_files import (
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from shell_session_manager.shellctl.shared import JobResult, JobStatusName
|
||||
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -72,7 +71,7 @@ class FakeShellctlClient:
|
||||
return self.run_handler(script, cwd, env, timeout)
|
||||
|
||||
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult:
|
||||
return self.run_handler("", None, None, timeout)
|
||||
raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}")
|
||||
|
||||
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult:
|
||||
raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}")
|
||||
@@ -85,10 +84,11 @@ class FakeShellctlClient:
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> object:
|
||||
del force
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
del force, grace_seconds
|
||||
self.delete_calls.append(job_id)
|
||||
return None
|
||||
return DeleteJobResponse(job_id=job_id)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -191,7 +191,8 @@ def _service(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
|
||||
@@ -294,13 +294,3 @@ Or browse the step definition files directly:
|
||||
|
||||
- `features/step-definitions/common/` — auth guards and navigation assertions shared by all features
|
||||
- `features/step-definitions/<capability>/` — domain-specific steps scoped to a single feature area
|
||||
|
||||
## Agent v2 scenarios
|
||||
|
||||
Agent v2 scenarios live under `features/agent-v2/` and use the `@agent-v2` capability tag.
|
||||
|
||||
The E2E web environment enables Agent v2 through `NEXT_PUBLIC_ENABLE_AGENT_V2=true` in `scripts/common.ts`, because `/roster` routes are guarded by that feature flag.
|
||||
|
||||
Use `support/agent.ts` for Agent v2 API fixtures. It owns roster-shaped Agent IDs, configure/access route helpers, composer draft sync, build-draft helpers, publish, API access toggles, and Agent cleanup. Store created roster Agent IDs in `DifyWorld.createdAgentIds`; the shared `After` hook deletes them after each scenario.
|
||||
|
||||
Keep Agent v2 step definitions under `features/step-definitions/agent-v2/`. Prefer API setup for prerequisite state, then use Playwright only for user-observable navigation, editing, and assertions.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
@agent-v2 @authenticated @infra
|
||||
Feature: Agent v2 configure entry
|
||||
Scenario: Open the configure page for an Agent v2 test agent
|
||||
Given I am signed in as the default E2E admin
|
||||
And an Agent v2 test agent has been created via API
|
||||
And a minimal Agent v2 composer draft has been synced
|
||||
When I open the Agent v2 configure page
|
||||
Then I should be on the Agent v2 configure page
|
||||
And I should see the Agent v2 configure workspace
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import {
|
||||
createTestAgent,
|
||||
getAgentConfigurePath,
|
||||
saveAgentComposerDraft,
|
||||
} from '../../../support/agent'
|
||||
|
||||
Given('an Agent v2 test agent has been created via API', async function (this: DifyWorld) {
|
||||
const agent = await createTestAgent()
|
||||
this.createdAgentIds.push(agent.id)
|
||||
this.lastCreatedAgentName = agent.name
|
||||
this.lastCreatedAgentRole = agent.role
|
||||
})
|
||||
|
||||
Given('a minimal Agent v2 composer draft has been synced', async function (this: DifyWorld) {
|
||||
const agentId = this.createdAgentIds.at(-1)
|
||||
if (!agentId)
|
||||
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
|
||||
|
||||
await saveAgentComposerDraft(agentId)
|
||||
})
|
||||
|
||||
When('I open the Agent v2 configure page', async function (this: DifyWorld) {
|
||||
const agentId = this.createdAgentIds.at(-1)
|
||||
if (!agentId)
|
||||
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
|
||||
|
||||
await this.getPage().goto(getAgentConfigurePath(agentId))
|
||||
})
|
||||
|
||||
Then('I should be on the Agent v2 configure page', async function (this: DifyWorld) {
|
||||
const agentId = this.createdAgentIds.at(-1)
|
||||
if (!agentId)
|
||||
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
|
||||
|
||||
await expect(this.getPage()).toHaveURL(
|
||||
new RegExp(`/roster/agent/${agentId}/configure(?:\\?.*)?$`),
|
||||
)
|
||||
})
|
||||
|
||||
Then('I should see the Agent v2 configure workspace', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
await expect(page.getByRole('region', { name: 'Configure' })).toBeVisible({ timeout: 30_000 })
|
||||
await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible()
|
||||
await expect(page.getByText(this.lastCreatedAgentName!)).toBeVisible()
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Browser } from '@playwright/test'
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import type { DifyWorld } from './world'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
@@ -7,7 +6,6 @@ import { fileURLToPath } from 'node:url'
|
||||
import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber'
|
||||
import { chromium } from '@playwright/test'
|
||||
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
|
||||
import { deleteTestAgent } from '../../support/agent'
|
||||
import { deleteTestApp } from '../../support/api'
|
||||
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||
|
||||
@@ -43,7 +41,7 @@ BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => {
|
||||
slowMo: cucumberSlowMo,
|
||||
})
|
||||
|
||||
console.warn(`[e2e] session cache bootstrap against ${baseURL}`)
|
||||
console.log(`[e2e] session cache bootstrap against ${baseURL}`)
|
||||
await ensureAuthenticatedState(browser, baseURL)
|
||||
})
|
||||
|
||||
@@ -60,7 +58,7 @@ Before(async function (this: DifyWorld, { pickle }) {
|
||||
this.scenarioStartedAt = Date.now()
|
||||
|
||||
const tags = pickle.tags.map(tag => tag.name).join(' ')
|
||||
console.warn(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
|
||||
console.log(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
|
||||
})
|
||||
|
||||
After(async function (this: DifyWorld, { pickle, result }) {
|
||||
@@ -87,11 +85,10 @@ After(async function (this: DifyWorld, { pickle, result }) {
|
||||
}
|
||||
|
||||
const status = result?.status || 'UNKNOWN'
|
||||
console.warn(
|
||||
console.log(
|
||||
`[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`,
|
||||
)
|
||||
|
||||
for (const id of this.createdAgentIds) await deleteTestAgent(id).catch(() => {})
|
||||
for (const id of this.createdAppIds) await deleteTestApp(id).catch(() => {})
|
||||
|
||||
await this.closeSession()
|
||||
|
||||
@@ -13,10 +13,7 @@ export class DifyWorld extends World {
|
||||
scenarioStartedAt: number | undefined
|
||||
session: AuthSessionMetadata | undefined
|
||||
lastCreatedAppName: string | undefined
|
||||
lastCreatedAgentName: string | undefined
|
||||
lastCreatedAgentRole: string | undefined
|
||||
createdAppIds: string[] = []
|
||||
createdAgentIds: string[] = []
|
||||
capturedDownloads: Download[] = []
|
||||
shareURL: string | undefined
|
||||
|
||||
@@ -29,10 +26,7 @@ export class DifyWorld extends World {
|
||||
this.consoleErrors = []
|
||||
this.pageErrors = []
|
||||
this.lastCreatedAppName = undefined
|
||||
this.lastCreatedAgentName = undefined
|
||||
this.lastCreatedAgentRole = undefined
|
||||
this.createdAppIds = []
|
||||
this.createdAgentIds = []
|
||||
this.capturedDownloads = []
|
||||
this.shareURL = undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
@@ -43,7 +42,6 @@ export const webEnvExampleFile = path.join(webDir, '.env.example')
|
||||
export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example')
|
||||
export const e2eWebEnvOverrides = {
|
||||
NEXT_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/console/api',
|
||||
NEXT_PUBLIC_ENABLE_AGENT_V2: 'true',
|
||||
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/api',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from './api'
|
||||
|
||||
export type AgentSeed = {
|
||||
app_id?: string
|
||||
backing_app_id?: string
|
||||
description?: string
|
||||
enable_site?: boolean
|
||||
id: string
|
||||
name: string
|
||||
role?: string
|
||||
site?: {
|
||||
access_token?: string | null
|
||||
app_base_url?: string | null
|
||||
code?: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type AgentSoulConfig = Record<string, unknown>
|
||||
|
||||
export type AgentComposerResponse = {
|
||||
agent_soul?: AgentSoulConfig
|
||||
}
|
||||
|
||||
export type AgentBuildDraftResponse = {
|
||||
agent_soul: AgentSoulConfig
|
||||
draft: Record<string, unknown>
|
||||
variant: 'agent_app'
|
||||
}
|
||||
|
||||
export type AgentApiAccess = {
|
||||
api_key_count: number
|
||||
api_reference_url: string
|
||||
endpoint: string
|
||||
enabled: boolean
|
||||
files_upload_endpoint: string
|
||||
}
|
||||
|
||||
export type AgentApiKey = {
|
||||
id: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
export const defaultAgentSoulConfig: AgentSoulConfig = {
|
||||
prompt: {
|
||||
system_prompt: 'You are a Dify Agent E2E test assistant.',
|
||||
},
|
||||
}
|
||||
|
||||
export const getAgentConfigurePath = (agentId: string) => `/roster/agent/${agentId}/configure`
|
||||
export const getAgentAccessPath = (agentId: string) => `/roster/agent/${agentId}/access`
|
||||
|
||||
export async function createTestAgent({
|
||||
description = 'Created by Dify E2E.',
|
||||
name = `E2E Agent ${Date.now()}`,
|
||||
role = 'E2E test assistant',
|
||||
}: {
|
||||
description?: string
|
||||
name?: string
|
||||
role?: string
|
||||
} = {}): Promise<AgentSeed> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.post('/console/api/agent', {
|
||||
data: {
|
||||
description,
|
||||
icon: '🤖',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_type: 'emoji',
|
||||
name,
|
||||
role,
|
||||
},
|
||||
})
|
||||
await expectApiResponseOK(response, 'Create Agent v2 test agent')
|
||||
return (await response.json()) as AgentSeed
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTestAgent(agentId: string): Promise<AgentSeed> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.get(`/console/api/agent/${agentId}`)
|
||||
await expectApiResponseOK(response, `Get Agent v2 test agent ${agentId}`)
|
||||
return (await response.json()) as AgentSeed
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTestAgent(agentId: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.delete(`/console/api/agent/${agentId}`)
|
||||
await expectApiResponseOK(response, `Delete Agent v2 test agent ${agentId}`)
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAgentComposerDraft(
|
||||
agentId: string,
|
||||
agentSoul: AgentSoulConfig = defaultAgentSoulConfig,
|
||||
): Promise<AgentComposerResponse> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.put(`/console/api/agent/${agentId}/composer`, {
|
||||
data: {
|
||||
agent_soul: agentSoul,
|
||||
save_strategy: 'save_to_current_version',
|
||||
variant: 'agent_app',
|
||||
},
|
||||
})
|
||||
await expectApiResponseOK(response, `Save Agent v2 composer draft for ${agentId}`)
|
||||
return (await response.json()) as AgentComposerResponse
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkoutAgentBuildDraft(agentId: string): Promise<AgentBuildDraftResponse> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.post(`/console/api/agent/${agentId}/build-draft/checkout`, {
|
||||
data: { force: true },
|
||||
})
|
||||
await expectApiResponseOK(response, `Checkout Agent v2 build draft for ${agentId}`)
|
||||
return (await response.json()) as AgentBuildDraftResponse
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function discardAgentBuildDraft(agentId: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.delete(`/console/api/agent/${agentId}/build-draft`)
|
||||
await expectApiResponseOK(response, `Discard Agent v2 build draft for ${agentId}`)
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.post(`/console/api/agent/${agentId}/publish`, {
|
||||
data: { version_note: versionNote },
|
||||
})
|
||||
await expectApiResponseOK(response, `Publish Agent v2 test agent ${agentId}`)
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableAgentSiteAndGetURL(agentId: string): Promise<string> {
|
||||
const agent = await getTestAgent(agentId)
|
||||
const appId = agent.app_id ?? agent.backing_app_id
|
||||
if (!appId)
|
||||
throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`)
|
||||
|
||||
const appDetail = await setAppSiteEnabled(appId, true)
|
||||
const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site.access_token
|
||||
const baseURL = agent.site?.app_base_url ?? appDetail.site.app_base_url
|
||||
|
||||
return `${baseURL.replace(/\/$/, '')}/agent/${token}`
|
||||
}
|
||||
|
||||
export async function getAgentApiAccess(agentId: string): Promise<AgentApiAccess> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.get(`/console/api/agent/${agentId}/api-access`)
|
||||
await expectApiResponseOK(response, `Get Agent v2 API access for ${agentId}`)
|
||||
return (await response.json()) as AgentApiAccess
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAgentApiAccess(
|
||||
agentId: string,
|
||||
enabled: boolean,
|
||||
): Promise<AgentApiAccess> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.post(`/console/api/agent/${agentId}/api-enable`, {
|
||||
data: { enable_api: enabled },
|
||||
})
|
||||
await expectApiResponseOK(
|
||||
response,
|
||||
`${enabled ? 'Enable' : 'Disable'} Agent v2 API access for ${agentId}`,
|
||||
)
|
||||
return (await response.json()) as AgentApiAccess
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAgentApiKey(agentId: string): Promise<AgentApiKey> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.post(`/console/api/agent/${agentId}/api-keys`)
|
||||
await expectApiResponseOK(response, `Create Agent v2 API key for ${agentId}`)
|
||||
return (await response.json()) as AgentApiKey
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAgentApiKey(agentId: string, apiKeyId: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.delete(`/console/api/agent/${agentId}/api-keys/${apiKeyId}`)
|
||||
await expectApiResponseOK(response, `Delete Agent v2 API key ${apiKeyId} for ${agentId}`)
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
+8
-31
@@ -1,4 +1,3 @@
|
||||
import type { APIResponse } from '@playwright/test'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { request } from '@playwright/test'
|
||||
import { authStatePath } from '../fixtures/auth'
|
||||
@@ -8,7 +7,7 @@ type StorageState = {
|
||||
cookies: Array<{ name: string, value: string }>
|
||||
}
|
||||
|
||||
export async function createApiContext() {
|
||||
async function createApiContext() {
|
||||
const state = JSON.parse(await readFile(authStatePath, 'utf8')) as StorageState
|
||||
const csrfToken = state.cookies.find(c => c.name.endsWith('csrf_token'))?.value ?? ''
|
||||
|
||||
@@ -19,14 +18,6 @@ export async function createApiContext() {
|
||||
})
|
||||
}
|
||||
|
||||
export async function expectApiResponseOK(response: APIResponse, action: string): Promise<void> {
|
||||
if (response.ok())
|
||||
return
|
||||
|
||||
const body = await response.text().catch(() => '')
|
||||
throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`)
|
||||
}
|
||||
|
||||
export type AppSeed = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -150,34 +141,20 @@ export async function publishWorkflowApp(appId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export type AppDetailWithSite = {
|
||||
mode?: string
|
||||
type AppDetailWithSite = {
|
||||
site: { access_token: string, app_base_url: string, enable_site: boolean }
|
||||
}
|
||||
|
||||
export function getAppSiteURL({ mode, site }: AppDetailWithSite): string {
|
||||
const webAppMode = mode === 'completion' || mode === 'workflow' ? mode : 'chat'
|
||||
return `${site.app_base_url}/${webAppMode}/${site.access_token}`
|
||||
}
|
||||
|
||||
export async function enableAppSiteAndGetURL(appId: string): Promise<string> {
|
||||
return getAppSiteURL(await setAppSiteEnabled(appId, true))
|
||||
}
|
||||
|
||||
export async function setAppSiteEnabled(
|
||||
appId: string,
|
||||
enabled: boolean,
|
||||
): Promise<AppDetailWithSite> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const enableResponse = await ctx.post(`/console/api/apps/${appId}/site-enable`, {
|
||||
data: { enable_site: enabled },
|
||||
await ctx.post(`/console/api/apps/${appId}/site-enable`, {
|
||||
data: { enable_site: true },
|
||||
})
|
||||
await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`)
|
||||
|
||||
const detailResponse = await ctx.get(`/console/api/apps/${appId}`)
|
||||
await expectApiResponseOK(detailResponse, `Get app site detail for ${appId}`)
|
||||
return (await detailResponse.json()) as AppDetailWithSite
|
||||
const res = await ctx.get(`/console/api/apps/${appId}`)
|
||||
const body = (await res.json()) as AppDetailWithSite
|
||||
const { app_base_url, access_token } = body.site
|
||||
return `${app_base_url}/workflow/${access_token}`
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
|
||||
+11
-13
@@ -122,23 +122,21 @@ const waitForProcessExit = (childProcess: ChildProcess, timeoutMs: number) =>
|
||||
return
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout>
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timeout)
|
||||
childProcess.off('exit', onExit)
|
||||
}
|
||||
|
||||
function onExit() {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}, timeoutMs)
|
||||
|
||||
const onExit = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
childProcess.off('exit', onExit)
|
||||
}
|
||||
|
||||
childProcess.once('exit', onExit)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
{
|
||||
"e2e/features/support/hooks.ts": {
|
||||
"no-console": {
|
||||
"count": 3
|
||||
},
|
||||
"node/prefer-global/buffer": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"e2e/scripts/common.ts": {
|
||||
"node/prefer-global/buffer": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"e2e/support/process.ts": {
|
||||
"ts/no-use-before-define": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts": {
|
||||
"no-console": {
|
||||
"count": 11
|
||||
@@ -1919,9 +1937,6 @@
|
||||
"web/app/components/base/markdown-blocks/form.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-regex-literals": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/markdown-blocks/index.ts": {
|
||||
@@ -4347,6 +4362,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/snippet-list/components/snippet-card.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/components/snippet-run-panel.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 5
|
||||
@@ -4363,6 +4386,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-nodes-sync-draft.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/snippets/hooks/use-snippet-run.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
@@ -6284,6 +6312,9 @@
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 5
|
||||
},
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/panel/chat-variable-panel/components/object-value-list.tsx": {
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "dify",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"devEngines": {
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
|
||||
@@ -43,9 +43,6 @@ import {
|
||||
zGetSnippetsBySnippetIdWorkflowsPublishResponse,
|
||||
zGetSnippetsBySnippetIdWorkflowsQuery,
|
||||
zGetSnippetsBySnippetIdWorkflowsResponse,
|
||||
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody,
|
||||
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath,
|
||||
zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse,
|
||||
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody,
|
||||
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath,
|
||||
zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse,
|
||||
@@ -713,31 +710,7 @@ export const restore = {
|
||||
post: post8,
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a published snippet workflow version's display metadata
|
||||
*
|
||||
* Update published snippet workflow attributes
|
||||
*/
|
||||
export const patch2 = oc
|
||||
.route({
|
||||
description: 'Update published snippet workflow attributes',
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'patchSnippetsBySnippetIdWorkflowsByWorkflowId',
|
||||
path: '/snippets/{snippet_id}/workflows/{workflow_id}',
|
||||
summary: 'Update a published snippet workflow version\'s display metadata',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody,
|
||||
params: zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath,
|
||||
}),
|
||||
)
|
||||
.output(zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse)
|
||||
|
||||
export const byWorkflowId = {
|
||||
patch: patch2,
|
||||
restore,
|
||||
}
|
||||
|
||||
|
||||
@@ -208,11 +208,6 @@ export type WorkflowPublishResponse = {
|
||||
result: string
|
||||
}
|
||||
|
||||
export type WorkflowUpdatePayload = {
|
||||
marked_comment?: string | null
|
||||
marked_name?: string | null
|
||||
}
|
||||
|
||||
export type WorkflowRunForListResponse = {
|
||||
created_at?: number | null
|
||||
created_by_account?: SimpleAccount | null
|
||||
@@ -828,28 +823,6 @@ export type PostSnippetsBySnippetIdWorkflowsPublishResponses = {
|
||||
export type PostSnippetsBySnippetIdWorkflowsPublishResponse
|
||||
= PostSnippetsBySnippetIdWorkflowsPublishResponses[keyof PostSnippetsBySnippetIdWorkflowsPublishResponses]
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdData = {
|
||||
body: WorkflowUpdatePayload
|
||||
path: {
|
||||
snippet_id: string
|
||||
workflow_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/snippets/{snippet_id}/workflows/{workflow_id}'
|
||||
}
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdErrors = {
|
||||
400: unknown
|
||||
404: unknown
|
||||
}
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses = {
|
||||
200: SnippetWorkflowResponse
|
||||
}
|
||||
|
||||
export type PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse
|
||||
= PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponses]
|
||||
|
||||
export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreData = {
|
||||
body?: never
|
||||
path: {
|
||||
|
||||
@@ -133,14 +133,6 @@ export const zWorkflowPublishResponse = z.object({
|
||||
result: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowUpdatePayload
|
||||
*/
|
||||
export const zWorkflowUpdatePayload = z.object({
|
||||
marked_comment: z.string().max(100).nullish(),
|
||||
marked_name: z.string().max(20).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleAccount
|
||||
*/
|
||||
@@ -675,18 +667,6 @@ export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({
|
||||
*/
|
||||
export const zPostSnippetsBySnippetIdWorkflowsPublishResponse = zWorkflowPublishResponse
|
||||
|
||||
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdBody = zWorkflowUpdatePayload
|
||||
|
||||
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdPath = z.object({
|
||||
snippet_id: z.uuid(),
|
||||
workflow_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Workflow updated successfully
|
||||
*/
|
||||
export const zPatchSnippetsBySnippetIdWorkflowsByWorkflowIdResponse = zSnippetWorkflowResponse
|
||||
|
||||
export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath = z.object({
|
||||
snippet_id: z.uuid(),
|
||||
workflow_id: z.string(),
|
||||
|
||||
@@ -3,36 +3,12 @@
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
zPostWorkflowGenerateBody,
|
||||
zPostWorkflowGenerateResponse,
|
||||
zPostWorkflowGenerateSuggestionsBody,
|
||||
zPostWorkflowGenerateSuggestionsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
/**
|
||||
* Suggest example workflow-generator instructions for the tenant
|
||||
*/
|
||||
export const post = oc
|
||||
.route({
|
||||
description: 'Suggest example workflow-generator instructions for the tenant',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postWorkflowGenerateSuggestions',
|
||||
path: '/workflow-generate/suggestions',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ body: zPostWorkflowGenerateSuggestionsBody }))
|
||||
.output(zPostWorkflowGenerateSuggestionsResponse)
|
||||
|
||||
export const suggestions = {
|
||||
post,
|
||||
}
|
||||
import { zPostWorkflowGenerateBody, zPostWorkflowGenerateResponse } from './zod.gen'
|
||||
|
||||
/**
|
||||
* Generate a Dify workflow graph from natural language
|
||||
*/
|
||||
export const post2 = oc
|
||||
export const post = oc
|
||||
.route({
|
||||
description: 'Generate a Dify workflow graph from natural language',
|
||||
inputStructure: 'detailed',
|
||||
@@ -45,8 +21,7 @@ export const post2 = oc
|
||||
.output(zPostWorkflowGenerateResponse)
|
||||
|
||||
export const workflowGenerate = {
|
||||
post: post2,
|
||||
suggestions,
|
||||
post,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
|
||||
@@ -10,18 +10,12 @@ export type WorkflowGeneratePayload = {
|
||||
} | null
|
||||
ideal_output?: string
|
||||
instruction: string
|
||||
mode: 'advanced-chat' | 'auto' | 'workflow'
|
||||
mode: 'advanced-chat' | 'workflow'
|
||||
model_config: ModelConfig
|
||||
}
|
||||
|
||||
export type GeneratorResponse = unknown
|
||||
|
||||
export type WorkflowInstructionSuggestionsPayload = {
|
||||
count?: number
|
||||
language?: string | null
|
||||
mode: 'advanced-chat' | 'workflow'
|
||||
}
|
||||
|
||||
export type ModelConfig = {
|
||||
completion_params?: {
|
||||
[key: string]: unknown
|
||||
@@ -51,21 +45,3 @@ export type PostWorkflowGenerateResponses = {
|
||||
|
||||
export type PostWorkflowGenerateResponse
|
||||
= PostWorkflowGenerateResponses[keyof PostWorkflowGenerateResponses]
|
||||
|
||||
export type PostWorkflowGenerateSuggestionsData = {
|
||||
body: WorkflowInstructionSuggestionsPayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workflow-generate/suggestions'
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateSuggestionsErrors = {
|
||||
400: unknown
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateSuggestionsResponses = {
|
||||
200: GeneratorResponse
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateSuggestionsResponse
|
||||
= PostWorkflowGenerateSuggestionsResponses[keyof PostWorkflowGenerateSuggestionsResponses]
|
||||
|
||||
@@ -7,21 +7,6 @@ import * as z from 'zod'
|
||||
*/
|
||||
export const zGeneratorResponse = z.unknown()
|
||||
|
||||
/**
|
||||
* WorkflowInstructionSuggestionsPayload
|
||||
*
|
||||
* Payload for the workflow-generator instruction-suggestions endpoint.
|
||||
*
|
||||
* Runs before the user picks a model, so the suggestions come from the
|
||||
* tenant's default model. The underlying generator never raises — an empty
|
||||
* ``suggestions`` list is a valid 200 (soft-fail).
|
||||
*/
|
||||
export const zWorkflowInstructionSuggestionsPayload = z.object({
|
||||
count: z.int().gte(1).lte(6).optional().default(4),
|
||||
language: z.string().nullish(),
|
||||
mode: z.enum(['advanced-chat', 'workflow']),
|
||||
})
|
||||
|
||||
/**
|
||||
* LLMMode
|
||||
*
|
||||
@@ -52,7 +37,7 @@ export const zWorkflowGeneratePayload = z.object({
|
||||
current_graph: z.record(z.string(), z.unknown()).nullish(),
|
||||
ideal_output: z.string().optional().default(''),
|
||||
instruction: z.string(),
|
||||
mode: z.enum(['advanced-chat', 'auto', 'workflow']),
|
||||
mode: z.enum(['advanced-chat', 'workflow']),
|
||||
model_config: zModelConfig,
|
||||
})
|
||||
|
||||
@@ -62,10 +47,3 @@ export const zPostWorkflowGenerateBody = zWorkflowGeneratePayload
|
||||
* Workflow graph generated successfully
|
||||
*/
|
||||
export const zPostWorkflowGenerateResponse = zGeneratorResponse
|
||||
|
||||
export const zPostWorkflowGenerateSuggestionsBody = zWorkflowInstructionSuggestionsPayload
|
||||
|
||||
/**
|
||||
* Suggestions generated successfully
|
||||
*/
|
||||
export const zPostWorkflowGenerateSuggestionsResponse = zGeneratorResponse
|
||||
|
||||
@@ -17,7 +17,7 @@ const meta = {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens and surface treatments. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.',
|
||||
component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens, panel surfaces, and scrollbar spacing. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -27,7 +27,27 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const scrollFadeRootClassName = cn(
|
||||
'has-[>_:first-child:focus-visible]:outline-2',
|
||||
'has-[>_:first-child:focus-visible]:outline-offset-0',
|
||||
'has-[>_:first-child:focus-visible]:outline-state-accent-solid',
|
||||
)
|
||||
const rootClassName = 'relative min-h-0 min-w-0'
|
||||
const viewportClassName = 'h-full max-h-full max-w-full rounded-xl border border-divider-subtle bg-components-panel-bg'
|
||||
const fadeViewportClassName = cn(
|
||||
'h-full max-h-full max-w-full rounded-xl bg-components-panel-bg outline-none focus-visible:outline-none',
|
||||
'mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
|
||||
)
|
||||
const scrollbarClassName = cn(
|
||||
'data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1',
|
||||
'data-[orientation=horizontal]:mx-1 data-[orientation=horizontal]:mb-1',
|
||||
)
|
||||
const verticalContentClassName = 'w-full max-w-full min-w-0'
|
||||
const verticalContentStyle = { minWidth: 0 } satisfies React.CSSProperties
|
||||
const panelClassName = 'min-w-0 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'
|
||||
const pageClassName = 'min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5'
|
||||
const labelClassName = 'system-xs-medium-uppercase text-text-tertiary'
|
||||
const headingClassName = 'system-md-semibold text-text-primary'
|
||||
|
||||
const appRows = [
|
||||
{ name: 'Invoice Copilot', meta: 'Pinned', icon: 'i-ri-file-list-3-line', selected: true, pinned: true },
|
||||
@@ -79,10 +99,10 @@ function StorySection({
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className={cn('min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5', className)}>
|
||||
<section className={cn(pageClassName, className)}>
|
||||
<div className="space-y-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{eyebrow}</div>
|
||||
<h3 className="system-md-semibold text-text-primary">{title}</h3>
|
||||
<div className={labelClassName}>{eyebrow}</div>
|
||||
<h3 className={headingClassName}>{title}</h3>
|
||||
<p className="max-w-[72ch] text-pretty system-sm-regular text-text-secondary">{description}</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-center">
|
||||
@@ -102,7 +122,7 @@ function VerticalContent({
|
||||
return (
|
||||
<ScrollAreaContent
|
||||
style={verticalContentStyle}
|
||||
className={cn('w-full max-w-full min-w-0', className)}
|
||||
className={cn(verticalContentClassName, className)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaContent>
|
||||
@@ -116,20 +136,22 @@ export const Anatomy: Story = {
|
||||
title="Base UI compound parts"
|
||||
description="The baseline story mirrors the official Scroll Area anatomy: Root, Viewport, Content, Scrollbar, and Thumb, with keyboard focus drawn by the viewport."
|
||||
>
|
||||
<ScrollAreaRoot className="relative h-75 w-full max-w-105 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Scrollable anatomy example" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<VerticalContent className="flex flex-col gap-4 py-2 pl-3 pr-5 text-text-secondary system-sm-regular leading-6">
|
||||
{articleParagraphs.map(paragraph => (
|
||||
<p key={paragraph}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
<div className={cn(panelClassName, 'h-75 w-full max-w-105')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Scrollable anatomy example" role="region" className={viewportClassName}>
|
||||
<VerticalContent className="flex flex-col gap-4 py-2 pl-3 pr-5 text-text-secondary system-sm-regular leading-6">
|
||||
{articleParagraphs.map(paragraph => (
|
||||
<p key={paragraph}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@@ -141,26 +163,28 @@ export const Vertical: Story = {
|
||||
title="Long form content"
|
||||
description="Vertical overflow keeps the official viewport focus pattern while constraining content width so text never leaks outside the frame."
|
||||
>
|
||||
<ScrollAreaRoot className="relative h-90 w-full max-w-130 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Long form content" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<VerticalContent className="flex flex-col gap-4 p-4 pr-6 text-text-secondary system-sm-regular leading-6">
|
||||
<div className="space-y-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">Article</div>
|
||||
<div className="system-md-semibold text-text-primary">Scrollable text region</div>
|
||||
</div>
|
||||
{Array.from({ length: 4 }, (_, groupIndex) => (
|
||||
articleParagraphs.map(paragraph => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
<div className={cn(panelClassName, 'h-90 w-full max-w-130')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Long form content" role="region" className={viewportClassName}>
|
||||
<VerticalContent className="flex flex-col gap-4 p-4 pr-6 text-text-secondary system-sm-regular leading-6">
|
||||
<div className="space-y-1">
|
||||
<div className={labelClassName}>Article</div>
|
||||
<div className={headingClassName}>Scrollable text region</div>
|
||||
</div>
|
||||
{Array.from({ length: 4 }, (_, groupIndex) => (
|
||||
articleParagraphs.map(paragraph => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@@ -172,26 +196,28 @@ export const VerticalTruncation: Story = {
|
||||
title="Constrained content width"
|
||||
description="Use width constraints plus minWidth: 0 on ScrollArea.Content when a vertical-only list should keep vertical scrolling while truncating long labels instead of creating horizontal scroll."
|
||||
>
|
||||
<ScrollAreaRoot className="relative h-48 w-full max-w-80 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Vertical file list" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<VerticalContent className="flex flex-col gap-0.5 p-2">
|
||||
{fileRows.map(file => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-text-line size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate system-sm-regular" title={file}>
|
||||
{file}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
<div className={cn(panelClassName, 'h-48 w-full max-w-80')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Vertical file list" role="region" className={viewportClassName}>
|
||||
<VerticalContent className="flex flex-col gap-0.5 p-2">
|
||||
{fileRows.map(file => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-text-line size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate system-sm-regular" title={file}>
|
||||
{file}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@@ -203,33 +229,24 @@ export const ScrollFade: Story = {
|
||||
title="Viewport mask with root focus"
|
||||
description="This mirrors the Base UI scroll-fade example: the viewport owns the mask and the root owns the focus outline so the indicator is never clipped."
|
||||
>
|
||||
<ScrollAreaRoot className={cn(
|
||||
'relative h-90 w-full max-w-130 min-w-0',
|
||||
'has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid',
|
||||
)}
|
||||
>
|
||||
<ScrollAreaViewport
|
||||
aria-label="Scroll fade article"
|
||||
role="region"
|
||||
className={cn(
|
||||
'h-full max-h-full max-w-full rounded-xl bg-components-panel-bg outline-none focus-visible:outline-none',
|
||||
'mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
|
||||
)}
|
||||
>
|
||||
<VerticalContent className="flex flex-col gap-4 px-4 py-3 pr-6 text-text-secondary system-sm-regular leading-6">
|
||||
{Array.from({ length: 5 }, (_, groupIndex) => (
|
||||
articleParagraphs.map(paragraph => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="opacity-0 data-hovering:opacity-100 data-scrolling:opacity-100 data-scrolling:duration-0">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
<div className={cn(panelClassName, 'h-90 w-full max-w-130')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, scrollFadeRootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Scroll fade article" role="region" className={fadeViewportClassName}>
|
||||
<VerticalContent className="flex flex-col gap-4 px-4 py-3 pr-6 text-text-secondary system-sm-regular leading-6">
|
||||
{Array.from({ length: 5 }, (_, groupIndex) => (
|
||||
articleParagraphs.map(paragraph => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@@ -242,22 +259,24 @@ export const Horizontal: Story = {
|
||||
description="Horizontal overflow keeps Base UI's content sizing behavior and uses the same viewport focus treatment on the scrollable element."
|
||||
className="mx-auto max-w-190"
|
||||
>
|
||||
<ScrollAreaRoot className="relative h-46 w-full max-w-130 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Horizontal numbered row" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<ScrollAreaContent className="min-h-full min-w-max p-4 pb-6">
|
||||
<div className="grid grid-cols-[repeat(18,6.25rem)] gap-3">
|
||||
{gridCells.slice(0, 18).map(cell => (
|
||||
<div key={cell} className="flex h-24 items-center justify-center rounded-xl border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="horizontal">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
<div className={cn(panelClassName, 'h-46 w-full max-w-130')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Horizontal numbered row" role="region" className={viewportClassName}>
|
||||
<ScrollAreaContent className="min-h-full min-w-max p-4 pb-6">
|
||||
<div className="grid grid-cols-[repeat(18,6.25rem)] gap-3">
|
||||
{gridCells.slice(0, 18).map(cell => (
|
||||
<div key={cell} className="flex h-24 items-center justify-center rounded-xl border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="horizontal" className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@@ -269,26 +288,28 @@ export const BothAxes: Story = {
|
||||
title="Numbered grid"
|
||||
description="This follows the official two-axis example: both scrollbars are rendered and Corner reserves the intersection."
|
||||
>
|
||||
<ScrollAreaRoot className="relative h-85 w-full max-w-140 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Numbered grid" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<ScrollAreaContent className="pt-3 pr-6 pb-6 pl-3">
|
||||
<div className="grid grid-cols-[repeat(10,6.25rem)] grid-rows-[repeat(10,6.25rem)] gap-3">
|
||||
{gridCells.map(cell => (
|
||||
<div key={cell} className="flex items-center justify-center rounded-lg border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar orientation="horizontal">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
<div className={cn(panelClassName, 'h-85 w-full max-w-140')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Numbered grid" role="region" className={viewportClassName}>
|
||||
<ScrollAreaContent className="pt-3 pr-6 pb-6 pl-3">
|
||||
<div className="grid grid-cols-[repeat(10,6.25rem)] grid-rows-[repeat(10,6.25rem)] gap-3">
|
||||
{gridCells.map(cell => (
|
||||
<div key={cell} className="flex items-center justify-center rounded-lg border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar orientation="horizontal" className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@@ -303,50 +324,54 @@ export const AppSidebar: Story = {
|
||||
title="Main navigation list"
|
||||
description="A Dify-like sidebar keeps business UI outside the primitive while preserving the same Root, Viewport, Content, Scrollbar anatomy."
|
||||
>
|
||||
<div className="w-full max-w-70 rounded-xl bg-background-default-subtle p-3">
|
||||
<div className="mb-4 flex h-8 items-center gap-2 rounded-lg bg-state-base-active px-2 text-text-accent">
|
||||
<span className="i-ri-apps-fill size-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate system-sm-semibold">Explore</span>
|
||||
<div className="w-full max-w-70 rounded-2xl border border-divider-subtle bg-background-body p-3 shadow-lg shadow-shadow-shadow-5">
|
||||
<div className="rounded-xl bg-background-default-subtle p-3">
|
||||
<div className="mb-4 flex h-8 items-center gap-2 rounded-lg bg-state-base-active px-2 text-text-accent">
|
||||
<span className="i-ri-apps-fill size-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate system-sm-semibold">Explore</span>
|
||||
</div>
|
||||
<div className="mb-1.5 flex items-center justify-between px-2">
|
||||
<span className={labelClassName}>Web apps</span>
|
||||
<span className="system-xs-medium text-text-quaternary">{appRows.length}</span>
|
||||
</div>
|
||||
<div className="h-76 min-h-0">
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full')}>
|
||||
<ScrollAreaViewport aria-label="Web apps" role="region" className="h-full max-h-full max-w-full rounded-lg bg-transparent">
|
||||
<VerticalContent className="space-y-0.5">
|
||||
{appRows.map((row, index) => (
|
||||
<div key={row.name} className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 text-left transition-colors outline-none focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-state-accent-solid',
|
||||
row.selected
|
||||
? 'bg-state-base-active text-components-menu-item-text-active'
|
||||
: 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-components-avatar-shape-fill-stop-100">
|
||||
<span aria-hidden className={cn(row.icon, 'size-3.5')} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate system-sm-regular">{row.name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 system-2xs-medium-uppercase text-text-quaternary">
|
||||
{row.meta}
|
||||
</span>
|
||||
</button>
|
||||
{index === pinnedCount - 1 && index !== appRows.length - 1 && (
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1.5 flex items-center justify-between px-2">
|
||||
<span className="system-xs-medium-uppercase text-text-tertiary">Web apps</span>
|
||||
<span className="system-xs-medium text-text-quaternary">{appRows.length}</span>
|
||||
</div>
|
||||
<ScrollAreaRoot className="relative h-76 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Web apps" role="region" className="h-full max-h-full max-w-full rounded-lg bg-transparent">
|
||||
<VerticalContent className="space-y-0.5 pr-3">
|
||||
{appRows.map((row, index) => (
|
||||
<div key={row.name} className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 text-left transition-colors outline-none focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-state-accent-solid',
|
||||
row.selected
|
||||
? 'bg-state-base-active text-components-menu-item-text-active'
|
||||
: 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-components-avatar-shape-fill-stop-100">
|
||||
<span aria-hidden className={cn(row.icon, 'size-3.5')} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate system-sm-regular">{row.name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 system-2xs-medium-uppercase text-text-quaternary">
|
||||
{row.meta}
|
||||
</span>
|
||||
</button>
|
||||
{index === pinnedCount - 1 && index !== appRows.length - 1 && (
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</StorySection>
|
||||
)
|
||||
|
||||
Generated
+2623
-3258
File diff suppressed because it is too large
Load Diff
+43
-43
@@ -51,30 +51,30 @@ overrides:
|
||||
yaml@>=2.0.0 <2.8.3: 2.9.0
|
||||
yauzl@<3.2.1: 3.2.1
|
||||
catalog:
|
||||
'@amplitude/analytics-browser': 2.44.3
|
||||
'@amplitude/plugin-session-replay-browser': 1.32.3
|
||||
'@antfu/eslint-config': 9.1.0
|
||||
'@amplitude/analytics-browser': 2.44.1
|
||||
'@amplitude/plugin-session-replay-browser': 1.32.1
|
||||
'@antfu/eslint-config': 9.0.0
|
||||
'@base-ui/react': 1.6.0
|
||||
'@chromatic-com/storybook': 5.2.1
|
||||
'@cucumber/cucumber': 13.0.0
|
||||
'@egoist/tailwindcss-icons': 1.9.2
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@eslint-react/eslint-plugin': 5.9.5
|
||||
'@eslint-react/eslint-plugin': 5.9.0
|
||||
'@eslint/js': 10.0.1
|
||||
'@floating-ui/react': 0.27.19
|
||||
'@formatjs/intl-localematcher': 0.8.10
|
||||
'@heroicons/react': 2.2.0
|
||||
'@hey-api/openapi-ts': 0.98.2
|
||||
'@hono/node-server': 2.0.6
|
||||
'@hono/node-server': 2.0.5
|
||||
'@iconify-json/heroicons': 1.2.3
|
||||
'@iconify-json/ri': 1.2.10
|
||||
'@lexical/code': 0.46.0
|
||||
'@lexical/link': 0.46.0
|
||||
'@lexical/list': 0.46.0
|
||||
'@lexical/react': 0.46.0
|
||||
'@lexical/selection': 0.46.0
|
||||
'@lexical/text': 0.46.0
|
||||
'@lexical/utils': 0.46.0
|
||||
'@lexical/code': 0.45.0
|
||||
'@lexical/link': 0.45.0
|
||||
'@lexical/list': 0.45.0
|
||||
'@lexical/react': 0.45.0
|
||||
'@lexical/selection': 0.45.0
|
||||
'@lexical/text': 0.45.0
|
||||
'@lexical/utils': 0.45.0
|
||||
'@mdx-js/loader': 3.1.1
|
||||
'@mdx-js/react': 3.1.1
|
||||
'@mdx-js/rollup': 3.1.1
|
||||
@@ -86,10 +86,10 @@ catalog:
|
||||
'@orpc/contract': 1.14.6
|
||||
'@orpc/openapi-client': 1.14.6
|
||||
'@orpc/tanstack-query': 1.14.6
|
||||
'@playwright/test': 1.61.1
|
||||
'@playwright/test': 1.61.0
|
||||
'@remixicon/react': 4.9.0
|
||||
'@rgrove/parse-xml': 4.2.1
|
||||
'@sentry/react': 10.62.0
|
||||
'@rgrove/parse-xml': 4.2.0
|
||||
'@sentry/react': 10.59.0
|
||||
'@storybook/addon-a11y': 10.4.6
|
||||
'@storybook/addon-docs': 10.4.6
|
||||
'@storybook/addon-links': 10.4.6
|
||||
@@ -105,13 +105,13 @@ catalog:
|
||||
'@tailwindcss/postcss': 4.3.1
|
||||
'@tailwindcss/typography': 0.5.20
|
||||
'@tailwindcss/vite': 4.3.1
|
||||
'@tanstack/eslint-plugin-query': 5.101.2
|
||||
'@tanstack/eslint-plugin-query': 5.101.0
|
||||
'@tanstack/form-core': 1.33.0
|
||||
'@tanstack/query-core': 5.101.2
|
||||
'@tanstack/query-core': 5.101.0
|
||||
'@tanstack/react-form': 1.33.0
|
||||
'@tanstack/react-hotkeys': 0.10.0
|
||||
'@tanstack/react-query': 5.101.2
|
||||
'@tanstack/react-virtual': 3.14.4
|
||||
'@tanstack/react-query': 5.101.0
|
||||
'@tanstack/react-virtual': 3.14.3
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/jest-dom': 6.9.1
|
||||
'@testing-library/react': 16.3.2
|
||||
@@ -128,10 +128,10 @@ catalog:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@typescript-eslint/eslint-plugin': 8.62.0
|
||||
'@typescript-eslint/parser': 8.62.0
|
||||
'@typescript/native-preview': 7.0.0-dev.20260627.2
|
||||
'@vitejs/plugin-react': 6.0.3
|
||||
'@typescript-eslint/eslint-plugin': 8.61.1
|
||||
'@typescript-eslint/parser': 8.61.1
|
||||
'@typescript/native-preview': 7.0.0-dev.20260620.1
|
||||
'@vitejs/plugin-react': 6.0.2
|
||||
'@vitejs/plugin-rsc': 0.5.27
|
||||
'@vitest/browser': 4.1.9
|
||||
'@vitest/browser-playwright': 4.1.9
|
||||
@@ -145,10 +145,10 @@ catalog:
|
||||
cli-table3: 0.6.5
|
||||
clsx: 2.1.1
|
||||
cmdk: 1.1.1
|
||||
code-inspector-plugin: 1.6.2
|
||||
code-inspector-plugin: 1.6.1
|
||||
concurrently: ^10.0.3
|
||||
copy-to-clipboard: 4.0.2
|
||||
cron-parser: 5.6.1
|
||||
cron-parser: 5.6.0
|
||||
dayjs: 1.11.21
|
||||
decimal.js: 10.6.0
|
||||
dompurify: 3.4.11
|
||||
@@ -159,8 +159,8 @@ catalog:
|
||||
embla-carousel-fade: 8.6.0
|
||||
embla-carousel-react: 8.6.0
|
||||
emoji-mart: 5.6.0
|
||||
es-toolkit: 1.49.0
|
||||
eslint: 10.6.0
|
||||
es-toolkit: 1.47.1
|
||||
eslint: 10.5.0
|
||||
eslint-markdown: 0.11.0
|
||||
eslint-plugin-better-tailwindcss: 4.6.0
|
||||
eslint-plugin-hyoban: 0.14.1
|
||||
@@ -172,14 +172,14 @@ catalog:
|
||||
eslint-plugin-storybook: 10.4.6
|
||||
eventsource-parser: 3.1.0
|
||||
fast-deep-equal: 3.1.3
|
||||
foxact: 0.3.8
|
||||
foxact: 0.3.7
|
||||
fuse.js: 7.4.2
|
||||
happy-dom: 20.10.6
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
hono: 4.12.27
|
||||
hono: 4.12.26
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.3.3
|
||||
i18next: 26.3.1
|
||||
i18next-resources-to-backend: 1.2.1
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.8
|
||||
@@ -188,31 +188,31 @@ catalog:
|
||||
jotai-tanstack-query: 0.11.0
|
||||
js-audio-recorder: 1.0.7
|
||||
js-cookie: 3.0.8
|
||||
js-yaml: 4.3.0
|
||||
js-yaml: 4.2.0
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.17.0
|
||||
knip: 6.22.0
|
||||
knip: 6.17.1
|
||||
ky: 2.0.2
|
||||
lamejs: 1.2.1
|
||||
lexical: 0.46.0
|
||||
lexical: 0.45.0
|
||||
lockfile: 1.0.4
|
||||
loro-crdt: 1.13.6
|
||||
mermaid: 11.16.0
|
||||
loro-crdt: 1.13.5
|
||||
mermaid: 11.15.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
motion: 12.42.0
|
||||
motion: 12.40.0
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.9
|
||||
next-themes: 0.4.6
|
||||
nuqs: 2.8.9
|
||||
open: 11.0.0
|
||||
ora: 9.4.1
|
||||
ora: 9.4.0
|
||||
picocolors: 1.1.1
|
||||
pinyin-pro: 3.28.1
|
||||
playwright: 1.61.1
|
||||
postcss: 8.5.16
|
||||
playwright: 1.61.0
|
||||
postcss: 8.5.15
|
||||
qrcode.react: 4.2.0
|
||||
qs: 6.15.3
|
||||
qs: 6.15.2
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7
|
||||
react-easy-crop: 6.0.2
|
||||
@@ -229,7 +229,7 @@ catalog:
|
||||
scheduler: 0.27.0
|
||||
server-only: 0.0.1
|
||||
sharp: 0.35.2
|
||||
shiki: 4.3.0
|
||||
shiki: 4.2.0
|
||||
socket.io-client: 4.8.3
|
||||
sortablejs: 1.15.7
|
||||
std-semver: 1.0.8
|
||||
@@ -238,7 +238,7 @@ catalog:
|
||||
string-ts: 2.3.1
|
||||
tailwind-merge: 3.6.0
|
||||
tailwindcss: 4.3.1
|
||||
tldts: 7.4.4
|
||||
tldts: 7.4.3
|
||||
tsx: 4.22.4
|
||||
typescript: 6.0.3
|
||||
uglify-js: 3.19.3
|
||||
@@ -246,7 +246,7 @@ catalog:
|
||||
unist-util-visit: 5.1.0
|
||||
use-context-selector: 2.0.0
|
||||
uuid: 14.0.1
|
||||
vinext: 0.1.8
|
||||
vinext: 0.1.6
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.2.1
|
||||
vite-plugin-inspect: 12.0.0-beta.3
|
||||
vite-plus: 0.2.1
|
||||
|
||||
@@ -232,20 +232,7 @@ describe('Billing Page + Plan Integration', () => {
|
||||
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button when manager has subscription management permission', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: ['billing.subscription.manage'],
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when subscription management permission is granted without manager role', () => {
|
||||
it('should show billing button when subscription management permission is granted', () => {
|
||||
setupProviderContext({ type: Plan.sandbox })
|
||||
setupAppContext({
|
||||
isCurrentWorkspaceManager: false,
|
||||
@@ -254,7 +241,8 @@ describe('Billing Page + Plan Integration', () => {
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByText(/viewBillingTitle/i)).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingTitle/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/viewBillingAction/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide billing button when subscription management permission is missing', () => {
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('SnippetInfoDropdown', () => {
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.getByText('snippet.menu.editInfo')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.menu.deleteSnippet')).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
@@ -201,7 +201,7 @@ describe('SnippetInfoDropdown', () => {
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(screen.queryByText('snippet.menu.editInfo')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.menu.exportSnippet')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.menu.exportSnippet')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.menu.deleteSnippet')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -244,7 +244,7 @@ describe('SnippetInfoDropdown', () => {
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockWorkspacePermissionKeys = ['snippets.create_and_modify']
|
||||
mockWorkspacePermissionKeys = ['snippets.management']
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
@@ -264,7 +264,7 @@ describe('SnippetInfoDropdown', () => {
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockWorkspacePermissionKeys = ['snippets.create_and_modify']
|
||||
mockWorkspacePermissionKeys = ['snippets.management']
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
@@ -58,7 +58,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
if (!canCreateAndModifySnippet)
|
||||
if (!canManageSnippet)
|
||||
return
|
||||
|
||||
setOpen(false)
|
||||
@@ -70,7 +70,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [canCreateAndModifySnippet, exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
}, [canManageSnippet, exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description }: {
|
||||
name: string
|
||||
@@ -125,20 +125,18 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
{canCreateAndModifySnippet && (
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canManageSnippet && (
|
||||
<>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canManageSnippet && (
|
||||
<>
|
||||
{canCreateAndModifySnippet && <DropdownMenuSeparator className="my-1! bg-divider-subtle" />}
|
||||
<DropdownMenuSeparator className="my-1! bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
variant="destructive"
|
||||
|
||||
@@ -21,7 +21,6 @@ let mockChatConversationDetail: Record<string, unknown> | undefined
|
||||
let mockCompletionConversationDetail: Record<string, unknown> | undefined
|
||||
let mockShowMessageLogModal = false
|
||||
let mockShowPromptLogModal = false
|
||||
let mockShowAgentLogModal = false
|
||||
let mockCurrentLogItem: Record<string, unknown> | undefined
|
||||
let mockCurrentLogModalActiveTab = 'messages'
|
||||
|
||||
@@ -82,7 +81,6 @@ vi.mock('@/app/components/app/store', () => ({
|
||||
setShowAgentLogModal: mockSetShowAgentLogModal,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
showPromptLogModal: mockShowPromptLogModal,
|
||||
showAgentLogModal: mockShowAgentLogModal,
|
||||
currentLogModalActiveTab: mockCurrentLogModalActiveTab,
|
||||
}),
|
||||
}))
|
||||
@@ -128,7 +126,6 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
onAnnotationEdited,
|
||||
onAnnotationRemoved,
|
||||
switchSibling,
|
||||
hideLogModal,
|
||||
}: {
|
||||
chatList: Array<{ id: string }>
|
||||
onFeedback: (mid: string, value: { rating: string, content?: string }) => Promise<boolean>
|
||||
@@ -136,9 +133,8 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
onAnnotationEdited: (query: string, answer: string, index: number) => void
|
||||
onAnnotationRemoved: (index: number) => Promise<boolean>
|
||||
switchSibling: (siblingMessageId: string) => void
|
||||
hideLogModal?: boolean
|
||||
}) => (
|
||||
<div data-testid="chat-panel" data-hide-log-modal={String(hideLogModal)}>
|
||||
<div data-testid="chat-panel">
|
||||
<div>{chatList.length}</div>
|
||||
<button onClick={() => void onFeedback('message-1', { rating: 'like', content: 'nice' })}>chat-feedback</button>
|
||||
<button onClick={() => onAnnotationAdded('annotation-2', 'Admin', 'Edited question', 'Edited answer', 1)}>chat-add-annotation</button>
|
||||
@@ -149,14 +145,6 @@ vi.mock('@/app/components/base/chat/chat', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/agent-log-modal', () => ({
|
||||
default: ({ floating, onCancel }: { floating?: boolean, onCancel: () => void }) => (
|
||||
<div data-testid="agent-log-modal" data-floating={String(floating)}>
|
||||
<button onClick={onCancel}>close-agent-log-modal</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/message-log-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="message-log-modal">
|
||||
@@ -267,7 +255,6 @@ describe('ConversationList', () => {
|
||||
mockCompletionConversationDetail = undefined
|
||||
mockShowMessageLogModal = false
|
||||
mockShowPromptLogModal = false
|
||||
mockShowAgentLogModal = false
|
||||
mockCurrentLogItem = undefined
|
||||
mockCurrentLogModalActiveTab = 'messages'
|
||||
mockDelAnnotation.mockResolvedValue(undefined)
|
||||
@@ -396,7 +383,6 @@ describe('ConversationList', () => {
|
||||
|
||||
expect(screen.getByTestId('var-panel')).toHaveTextContent('query:Latest question')
|
||||
expect(screen.getByTestId('model-info')).toHaveTextContent('gpt-4o')
|
||||
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
|
||||
expect(screen.getByTestId('message-log-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('chat-feedback'))
|
||||
@@ -413,61 +399,6 @@ describe('ConversationList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should mount agent log modals from the detail panel instead of the nested chat layout', async () => {
|
||||
mockChatConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
created_at: 1710000000,
|
||||
model_config: {
|
||||
model: 'gpt-4o',
|
||||
configs: {
|
||||
introduction: 'Hello there',
|
||||
},
|
||||
user_input_form: [],
|
||||
},
|
||||
message: {
|
||||
inputs: {},
|
||||
},
|
||||
}
|
||||
mockShowAgentLogModal = true
|
||||
mockCurrentLogItem = {
|
||||
id: 'message-1',
|
||||
conversationId: 'conversation-1',
|
||||
}
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'message-1',
|
||||
answer: 'Assistant reply',
|
||||
query: 'Latest question',
|
||||
created_at: 1710000000,
|
||||
inputs: {},
|
||||
feedbacks: [],
|
||||
message: [],
|
||||
message_files: [],
|
||||
agent_thoughts: [{ id: 'thought-1' }],
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
})
|
||||
|
||||
renderConversationList({
|
||||
searchParams: '?page=2&conversation_id=conversation-1',
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-panel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('chat-panel')).toHaveAttribute('data-hide-log-modal', 'true')
|
||||
expect(screen.getByTestId('agent-log-modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('agent-log-modal')).toHaveAttribute('data-floating', 'true')
|
||||
|
||||
fireEvent.click(screen.getByText('close-agent-log-modal'))
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalled()
|
||||
expect(mockSetShowAgentLogModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should render completion details and refetch after feedback updates', async () => {
|
||||
mockCompletionConversationDetail = {
|
||||
id: 'conversation-1',
|
||||
@@ -493,7 +424,7 @@ describe('ConversationList', () => {
|
||||
},
|
||||
}
|
||||
mockShowPromptLogModal = true
|
||||
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
|
||||
mockCurrentLogItem = { id: 'log-2' }
|
||||
|
||||
renderConversationList({
|
||||
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
|
||||
@@ -695,7 +626,7 @@ describe('ConversationList', () => {
|
||||
},
|
||||
}
|
||||
mockShowPromptLogModal = true
|
||||
mockCurrentLogItem = { id: 'log-2', log: [{ role: 'user', text: 'Prompt body' }] }
|
||||
mockCurrentLogItem = { id: 'log-2' }
|
||||
|
||||
renderConversationList({
|
||||
appDetail: { id: 'app-1', mode: AppModeEnum.COMPLETION } as any,
|
||||
|
||||
@@ -36,7 +36,6 @@ import ModelInfo from '@/app/components/app/log/model-info'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import TextGeneration from '@/app/components/app/text-generate/item'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import AgentLogModal from '@/app/components/base/agent-log-modal'
|
||||
import Chat from '@/app/components/base/chat/chat'
|
||||
import CopyIcon from '@/app/components/base/copy-icon'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
@@ -166,25 +165,13 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
})
|
||||
const { formatTime } = useTimestamp()
|
||||
const { onClose, appDetail } = useContext(DrawerContext)
|
||||
const {
|
||||
currentLogItem,
|
||||
setCurrentLogItem,
|
||||
showMessageLogModal,
|
||||
setShowMessageLogModal,
|
||||
showPromptLogModal,
|
||||
setShowPromptLogModal,
|
||||
showAgentLogModal,
|
||||
setShowAgentLogModal,
|
||||
currentLogModalActiveTab,
|
||||
} = useAppStore(useShallow((state: AppStoreState) => ({
|
||||
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({
|
||||
currentLogItem: state.currentLogItem,
|
||||
setCurrentLogItem: state.setCurrentLogItem,
|
||||
showMessageLogModal: state.showMessageLogModal,
|
||||
setShowMessageLogModal: state.setShowMessageLogModal,
|
||||
showPromptLogModal: state.showPromptLogModal,
|
||||
setShowPromptLogModal: state.setShowPromptLogModal,
|
||||
showAgentLogModal: state.showAgentLogModal,
|
||||
setShowAgentLogModal: state.setShowAgentLogModal,
|
||||
currentLogModalActiveTab: state.currentLogModalActiveTab,
|
||||
})))
|
||||
const { t } = useTranslation()
|
||||
@@ -408,7 +395,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
|
||||
const isChatMode = appDetail?.mode !== AppModeEnum.COMPLETION
|
||||
const isAdvanced = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
|
||||
const shouldShowPromptLogModal = showPromptLogModal && !!currentLogItem?.log
|
||||
|
||||
const varList = getDetailVarList(detail, varValues)
|
||||
const message_files = getCompletionMessageFiles(detail, isChatMode)
|
||||
@@ -521,7 +507,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
noChatInput
|
||||
showPromptLog
|
||||
hideProcessDetail
|
||||
hideLogModal
|
||||
chatContainerInnerClassName="px-3"
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
@@ -561,7 +546,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
noChatInput
|
||||
showPromptLog
|
||||
hideProcessDetail
|
||||
hideLogModal
|
||||
chatContainerInnerClassName="px-3"
|
||||
switchSibling={switchSibling}
|
||||
/>
|
||||
@@ -590,18 +574,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
/>
|
||||
</WorkflowContextProvider>
|
||||
)}
|
||||
{showAgentLogModal && (
|
||||
<AgentLogModal
|
||||
floating
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowAgentLogModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{shouldShowPromptLogModal && (
|
||||
{!isChatMode && showPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
|
||||
@@ -426,18 +426,16 @@ describe('List', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render sort filter before search and the snippets link', () => {
|
||||
it('should render sort filter before search and hide the snippets link', () => {
|
||||
renderList()
|
||||
|
||||
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
|
||||
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
|
||||
const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' })
|
||||
const createButton = screen.getByRole('button', { name: 'common.operation.create' })
|
||||
|
||||
expect(snippetsLink).toHaveAttribute('href', '/snippets')
|
||||
expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(searchInput.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(screen.queryByRole('link', { name: 'app.studio.viewSnippets' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import { TagFilter } from '@/features/tag-management/components/tag-filter'
|
||||
import Link from '@/next/link'
|
||||
import { AppSortFilter } from './app-sort-filter'
|
||||
import { AppTypeFilter } from './app-type-filter'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
@@ -71,12 +70,6 @@ export function AppListHeaderFilters({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
{t('studio.viewSnippets', { ns: 'app' })}
|
||||
</Link>
|
||||
{showCreateButton && (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
|
||||
@@ -119,17 +119,6 @@ describe('AgentLogModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the floating modal through a dialog portal', () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
const { container } = render(<AgentLogModal {...mockProps} floating />)
|
||||
|
||||
const modal = screen.getByRole('dialog')
|
||||
expect(container).not.toContainElement(modal)
|
||||
expect(document.body).toContainElement(modal)
|
||||
expect(modal).toHaveClass('fixed', 'z-50', 'w-[480px]!', 'left-[max(8px,calc(100vw-1136px))]!')
|
||||
})
|
||||
|
||||
it('should call onCancel when close button is clicked', () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
@@ -169,18 +158,4 @@ describe('AgentLogModal', () => {
|
||||
|
||||
expect(mockProps.onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not use click-away to close the floating dialog', () => {
|
||||
vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {}))
|
||||
|
||||
let clickAwayHandler!: (event: Event) => void
|
||||
vi.mocked(useClickAway).mockImplementation((callback) => {
|
||||
clickAwayHandler = callback
|
||||
})
|
||||
|
||||
render(<AgentLogModal {...mockProps} floating />)
|
||||
clickAwayHandler(new Event('click'))
|
||||
|
||||
expect(mockProps.onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -11,13 +10,11 @@ import AgentLogDetail from './detail'
|
||||
type AgentLogModalProps = Readonly<{
|
||||
currentLogItem?: IChatItem
|
||||
width: number
|
||||
floating?: boolean
|
||||
onCancel: () => void
|
||||
}>
|
||||
const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
currentLogItem,
|
||||
width,
|
||||
floating,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -25,7 +22,7 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useClickAway(() => {
|
||||
if (mounted && !floating)
|
||||
if (mounted)
|
||||
onCancel()
|
||||
}, ref)
|
||||
|
||||
@@ -36,44 +33,6 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
if (!currentLogItem || !currentLogItem.conversationId)
|
||||
return null
|
||||
|
||||
const detailContent = (
|
||||
<>
|
||||
<AgentLogDetail
|
||||
conversationID={currentLogItem.conversationId}
|
||||
messageID={currentLogItem.id}
|
||||
log={currentLogItem}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
if (floating) {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
backdropClassName="bg-transparent!"
|
||||
className="top-16! bottom-4! left-[max(8px,calc(100vw-1136px))]! flex max-h-none! w-[480px]! max-w-[calc(100vw-16px)]! translate-x-0! translate-y-0! flex-col overflow-hidden! rounded-xl! border-[0.5px]! border-components-panel-border! bg-components-panel-bg! p-0! pt-3! pb-3! shadow-xl!"
|
||||
>
|
||||
<DialogTitle className="text-md shrink-0 px-4 py-1 font-semibold text-text-primary">{t('runDetail.workflowTitle', { ns: 'appLog' })}</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="absolute top-4 right-3 z-20 cursor-pointer border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
{detailContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('relative z-10 flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl')}
|
||||
@@ -95,7 +54,11 @@ const AgentLogModal: FC<AgentLogModalProps> = ({
|
||||
>
|
||||
<RiCloseLine className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
{detailContent}
|
||||
<AgentLogDetail
|
||||
conversationID={currentLogItem.conversationId}
|
||||
messageID={currentLogItem.id}
|
||||
log={currentLogItem}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const ChatWrapper = () => {
|
||||
|
||||
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
|
||||
const timezone = appSourceType === AppSourceType.webApp
|
||||
? new Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: undefined
|
||||
|
||||
// Semantic variable for better code readability
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ChatConfig, ChatItemInTree } from '../../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { sseGet, ssePost } from '@/service/base'
|
||||
import { useChat } from '../hooks'
|
||||
@@ -353,6 +353,74 @@ describe('useChat', () => {
|
||||
expect(result.current.chatList[1]!.id).toBe('m-1')
|
||||
})
|
||||
|
||||
it('should process inputs with the per-send input form override', () => {
|
||||
vi.mocked(ssePost).mockImplementation(async () => undefined)
|
||||
const { result } = renderHook(() => useChat(undefined, {
|
||||
inputs: {},
|
||||
inputsForm: [{
|
||||
type: InputVarType.textInput,
|
||||
label: 'City',
|
||||
variable: 'city',
|
||||
required: true,
|
||||
hide: false,
|
||||
}],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', {
|
||||
query: 'hello',
|
||||
inputs: {
|
||||
enabled: undefined,
|
||||
},
|
||||
overrideInputsForm: [{
|
||||
type: InputVarType.checkbox,
|
||||
label: 'Enabled',
|
||||
variable: 'enabled',
|
||||
required: true,
|
||||
hide: false,
|
||||
}],
|
||||
}, {})
|
||||
})
|
||||
|
||||
expect(ssePost).toHaveBeenCalledWith(
|
||||
'test-url',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
inputs: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should settle a send once when the SSE stream errors', () => {
|
||||
let callbacks: HookCallbacks
|
||||
const onSendSettled = vi.fn()
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'hello' }, {
|
||||
onSendSettled,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onError()
|
||||
callbacks.onCompleted(true)
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
expect(onSendSettled).toHaveBeenCalledTimes(1)
|
||||
expect(onSendSettled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle onThought and different workflow events', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
@@ -1106,6 +1174,44 @@ describe('useChat', () => {
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
|
||||
it('should settle a resume once when the event stream errors', () => {
|
||||
let callbacks: HookCallbacks
|
||||
const onSendSettled = vi.fn()
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const prevChatTree = [{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [{
|
||||
id: 'm-resume-error',
|
||||
content: 'initial',
|
||||
isAnswer: true,
|
||||
siblingIndex: 0,
|
||||
}],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[]))
|
||||
|
||||
act(() => {
|
||||
result.current.handleResume('m-resume-error', 'wr-error', {
|
||||
isPublicAPI: true,
|
||||
onSendSettled,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onError()
|
||||
callbacks.onCompleted(true)
|
||||
})
|
||||
|
||||
expect(onSendSettled).toHaveBeenCalledTimes(1)
|
||||
expect(onSendSettled).toHaveBeenCalledWith(true)
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
|
||||
it('should abort previous workflow event stream when resuming again', () => {
|
||||
const callbacksList: HookCallbacks[] = []
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
@@ -1489,6 +1595,45 @@ describe('useChat', () => {
|
||||
|
||||
expect(clearChatListCallback).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should keep the first send after a reset acknowledgement', () => {
|
||||
let clearChatList = true
|
||||
const clearChatListCallback = vi.fn((nextClearChatList: boolean) => {
|
||||
clearChatList = nextClearChatList
|
||||
})
|
||||
const { rerender, result } = renderHook(() => useChat(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
clearChatList,
|
||||
clearChatListCallback,
|
||||
))
|
||||
|
||||
expect(clearChatListCallback).toHaveBeenCalledWith(false)
|
||||
|
||||
rerender()
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'first after reset' }, {})
|
||||
})
|
||||
|
||||
expect(ssePost).toHaveBeenCalledWith(
|
||||
'test-url',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
query: 'first after reset',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(result.current.chatList).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: 'first after reset',
|
||||
isAnswer: false,
|
||||
}),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotations and siblings', () => {
|
||||
|
||||
@@ -361,6 +361,32 @@ describe('ChatInputArea', () => {
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should keep the textarea when async send is rejected by the owner', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn().mockResolvedValue(false)
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()!
|
||||
|
||||
await user.type(textarea, 'Keep this message')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled())
|
||||
expect(textarea).toHaveValue('Keep this message')
|
||||
})
|
||||
|
||||
it('should keep the textarea when async send fails', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn().mockRejectedValue(new Error('send failed'))
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()!
|
||||
|
||||
await user.type(textarea, 'Retry this message')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled())
|
||||
expect(textarea).toHaveValue('Retry this message')
|
||||
})
|
||||
|
||||
it('should call onSend and reset the input when pressing Enter', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn()
|
||||
|
||||
@@ -24,6 +24,8 @@ type AudioRecorderWithPermission = typeof Recorder & {
|
||||
getPermission: () => Promise<void>
|
||||
}
|
||||
|
||||
type SendAcceptance = void | boolean | Promise<void | boolean>
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
readonly?: boolean
|
||||
botName?: string
|
||||
@@ -62,10 +64,19 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
const historyRef = useRef([''])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const isComposingRef = useRef(false)
|
||||
const queryRef = useRef('')
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
queryRef.current = value
|
||||
setQuery(value)
|
||||
setTimeout(handleTextareaResize, 0)
|
||||
}, [handleTextareaResize])
|
||||
const resetAcceptedMessage = useCallback((acceptedQuery: string, acceptedFiles: ReturnType<typeof filesStore.getState>['files']) => {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (queryRef.current === acceptedQuery)
|
||||
handleQueryChange('')
|
||||
if (files === acceptedFiles)
|
||||
setFiles([])
|
||||
}, [filesStore, handleQueryChange])
|
||||
const handleSend = () => {
|
||||
if (!canSend)
|
||||
return
|
||||
@@ -75,15 +86,23 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
return
|
||||
}
|
||||
if (onSend) {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
const { files } = filesStore.getState()
|
||||
if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
if (checkInputsForm(inputs, inputsForm)) {
|
||||
onSend(query, files)
|
||||
handleQueryChange('')
|
||||
setFiles([])
|
||||
const sendResult = onSend(query, files) as SendAcceptance
|
||||
if (sendResult instanceof Promise) {
|
||||
sendResult.then((accepted) => {
|
||||
if (accepted !== false)
|
||||
resetAcceptedMessage(query, files)
|
||||
}).catch(noop)
|
||||
return
|
||||
}
|
||||
|
||||
if (sendResult !== false)
|
||||
resetAcceptedMessage(query, files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ type SendCallback = {
|
||||
onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
|
||||
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onSendSettled?: (hasError?: boolean) => void
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
@@ -256,10 +257,19 @@ export const useChat = (
|
||||
{
|
||||
onGetSuggestedQuestions,
|
||||
onConversationComplete,
|
||||
onSendSettled,
|
||||
isPublicAPI,
|
||||
}: SendCallback,
|
||||
) => {
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
let hasSettled = false
|
||||
const settleSend = (hasError?: boolean) => {
|
||||
if (hasSettled)
|
||||
return
|
||||
|
||||
hasSettled = true
|
||||
onSendSettled?.(hasError)
|
||||
}
|
||||
// Re-subscribe to workflow events for the specific message
|
||||
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
|
||||
|
||||
@@ -303,25 +313,29 @@ export const useChat = (
|
||||
async onCompleted(hasError?: boolean) {
|
||||
handleResponding(false)
|
||||
|
||||
if (hasError)
|
||||
return
|
||||
try {
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
messageId,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setSuggestedQuestions([])
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
messageId,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
catch {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
settleSend(hasError)
|
||||
}
|
||||
},
|
||||
onFile(file) {
|
||||
// Convert simple file type to MIME type for non-agent mode
|
||||
@@ -404,6 +418,7 @@ export const useChat = (
|
||||
},
|
||||
onError() {
|
||||
handleResponding(false)
|
||||
settleSend(true)
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
handleResponding(true)
|
||||
@@ -669,6 +684,7 @@ export const useChat = (
|
||||
onGetConversationMessages,
|
||||
onGetSuggestedQuestions,
|
||||
onConversationComplete,
|
||||
onSendSettled,
|
||||
isPublicAPI,
|
||||
}: SendCallback,
|
||||
) => {
|
||||
@@ -721,13 +737,14 @@ export const useChat = (
|
||||
handleResponding(true)
|
||||
hasStopRespondedRef.current = false
|
||||
|
||||
const { query, files, inputs, ...restData } = data
|
||||
const { query, files, inputs, overrideInputsForm, ...restData } = data
|
||||
const requestInputsForm = overrideInputsForm ?? formSettings?.inputsForm ?? []
|
||||
const bodyParams = {
|
||||
response_mode: 'streaming',
|
||||
conversation_id: conversationIdRef.current,
|
||||
files: getProcessedFiles(files || []),
|
||||
query,
|
||||
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
|
||||
inputs: getProcessedInputs(inputs || {}, requestInputsForm),
|
||||
...restData,
|
||||
}
|
||||
if (bodyParams?.files?.length) {
|
||||
@@ -744,6 +761,14 @@ export const useChat = (
|
||||
|
||||
let isAgentMode = false
|
||||
let hasSetResponseId = false
|
||||
let hasSettled = false
|
||||
const settleSend = (hasError?: boolean) => {
|
||||
if (hasSettled)
|
||||
return
|
||||
|
||||
hasSettled = true
|
||||
onSendSettled?.(hasError)
|
||||
}
|
||||
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
|
||||
@@ -802,63 +827,67 @@ export const useChat = (
|
||||
async onCompleted(hasError?: boolean) {
|
||||
handleResponding(false)
|
||||
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
|
||||
const { data }: any = await onGetConversationMessages(
|
||||
conversationIdRef.current,
|
||||
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
|
||||
if (!newResponseItem)
|
||||
try {
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
|
||||
updateChatTreeNode(responseItem.id, {
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message.at(-1).role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
text: newResponseItem.answer,
|
||||
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
more: {
|
||||
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
|
||||
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
|
||||
latency: newResponseItem.provider_response_latency.toFixed(2),
|
||||
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
|
||||
},
|
||||
// for agent log
|
||||
conversationId: conversationIdRef.current,
|
||||
input: {
|
||||
inputs: newResponseItem.inputs,
|
||||
query: newResponseItem.query,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
responseItem.id,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
|
||||
const { data }: any = await onGetConversationMessages(
|
||||
conversationIdRef.current,
|
||||
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
|
||||
if (!newResponseItem)
|
||||
return
|
||||
|
||||
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
|
||||
updateChatTreeNode(responseItem.id, {
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message.at(-1).role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
text: newResponseItem.answer,
|
||||
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
more: {
|
||||
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
|
||||
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
|
||||
latency: newResponseItem.provider_response_latency.toFixed(2),
|
||||
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
|
||||
},
|
||||
// for agent log
|
||||
conversationId: conversationIdRef.current,
|
||||
input: {
|
||||
inputs: newResponseItem.inputs,
|
||||
query: newResponseItem.query,
|
||||
},
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setSuggestedQuestions([])
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
responseItem.id,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
catch {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
settleSend(hasError)
|
||||
}
|
||||
},
|
||||
onFile(file) {
|
||||
// Convert simple file type to MIME type for non-agent mode
|
||||
@@ -965,6 +994,7 @@ export const useChat = (
|
||||
},
|
||||
onError() {
|
||||
handleResponding(false)
|
||||
settleSend(true)
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
|
||||
@@ -85,7 +85,7 @@ const ChatWrapper = () => {
|
||||
} as ChatConfig
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const timezone = appSourceType === AppSourceType.webApp
|
||||
? new Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: undefined
|
||||
const {
|
||||
chatList,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
export function hexToRGBA(hex: string, opacity: number): string {
|
||||
hex = hex.replace('#', '')
|
||||
|
||||
@@ -15,11 +13,11 @@ export function hexToRGBA(hex: string, opacity: number): string {
|
||||
* Since strings cannot be directly assigned to the 'style' attribute in JSX,
|
||||
* this method transforms the string into an object representation of the styles.
|
||||
*/
|
||||
export function CssTransform(cssString: string): CSSProperties {
|
||||
export function CssTransform(cssString: string): object {
|
||||
if (cssString.length === 0)
|
||||
return {}
|
||||
|
||||
const style: CSSProperties = {}
|
||||
const style: object = {}
|
||||
const propertyValuePairs = cssString.split(';')
|
||||
for (const pair of propertyValuePairs) {
|
||||
if (pair.trim().length > 0) {
|
||||
|
||||
@@ -532,54 +532,6 @@ describe('MarkdownForm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Unicode letters should be valid form field names.
|
||||
describe('Unicode name support', () => {
|
||||
it('should include Unicode-named fields from all supported controls in JSON output', async () => {
|
||||
const user = userEvent.setup()
|
||||
const node = createRootNode(
|
||||
[
|
||||
createElementNode('label', { for: '用户名' }, [createTextNode('Username:')]),
|
||||
createElementNode('input', { type: 'text', name: '用户名', value: 'Alice' }),
|
||||
createElementNode('label', { for: '密码' }, [createTextNode('Password:')]),
|
||||
createElementNode('input', { type: 'password', name: '密码', value: 'secret' }),
|
||||
createElementNode('label', { for: '内容' }, [createTextNode('Content:')]),
|
||||
createElementNode('textarea', { name: '内容', value: 'Hello' }),
|
||||
createElementNode('label', { for: '日期' }, [createTextNode('Date:')]),
|
||||
createElementNode('input', { type: 'date', name: '日期', value: dayjs('2026-01-10') }),
|
||||
createElementNode('label', { for: '时间' }, [createTextNode('Time:')]),
|
||||
createElementNode('input', { type: 'time', name: '时间', value: '09:00' }),
|
||||
createElementNode('label', { for: '日期时间' }, [createTextNode('Datetime:')]),
|
||||
createElementNode('input', { type: 'datetime', name: '日期时间', value: dayjs('2026-01-10T08:30:00') }),
|
||||
createElementNode('label', { for: 'café' }, [createTextNode('Select:')]),
|
||||
createElementNode('input', { type: 'select', name: 'café', value: 'hello', dataOptions: ['hello', 'world'] }),
|
||||
createElementNode('input', { type: 'checkbox', name: '同意条款', value: true, dataTip: 'By checking this means you agreed' }),
|
||||
createElementNode('button', { dataSize: 'small', dataVariant: 'primary' }, [createTextNode('Login')]),
|
||||
],
|
||||
{ dataFormat: 'json' },
|
||||
)
|
||||
|
||||
render(<MarkdownForm node={node} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Login' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const submittedPayload = JSON.parse(mockOnSend.mock.calls[0]![0]) as Record<string, unknown>
|
||||
expect(submittedPayload).toMatchObject({
|
||||
用户名: 'Alice',
|
||||
密码: 'secret',
|
||||
内容: 'Hello',
|
||||
日期: 'formatted-date',
|
||||
日期时间: 'formatted-datetime',
|
||||
café: 'hello',
|
||||
同意条款: true,
|
||||
})
|
||||
expect(submittedPayload).toHaveProperty('时间')
|
||||
})
|
||||
})
|
||||
|
||||
// Double-click protection: button disables after the first submit.
|
||||
describe('Double submit prevention', () => {
|
||||
it('should disable submit button after first click', async () => {
|
||||
|
||||
@@ -41,15 +41,7 @@ type SupportedType = typeof SUPPORTED_TYPES[keyof typeof SUPPORTED_TYPES]
|
||||
|
||||
const SUPPORTED_TYPES_SET = new Set<string>(Object.values(SUPPORTED_TYPES))
|
||||
|
||||
const SAFE_NAME_RE = (() => {
|
||||
try {
|
||||
return new RegExp('^\\p{L}[\\p{L}\\p{M}\\p{N}_-]*$', 'u')
|
||||
}
|
||||
catch {
|
||||
// Fallback for browsers without Unicode property escape support.
|
||||
return /^[a-z][\w-]*$/i
|
||||
}
|
||||
})()
|
||||
const SAFE_NAME_RE = /^[a-z][\w-]*$/i
|
||||
const PROTOTYPE_POISON_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
|
||||
function isSafeName(name: unknown): name is string {
|
||||
|
||||
@@ -6,7 +6,6 @@ let fetching = false
|
||||
let isManager = true
|
||||
let enableBilling = true
|
||||
let workspacePermissionKeys: string[] = ['billing.subscription.manage']
|
||||
let billingUrlEnabled = false
|
||||
|
||||
const refetchMock = vi.fn()
|
||||
const openAsyncWindowMock = vi.fn()
|
||||
@@ -20,14 +19,11 @@ type BillingWindowOptions = {
|
||||
type OpenAsyncWindowCall = [BillingUrlCallback, BillingWindowOptions]
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: (enabled: boolean) => {
|
||||
billingUrlEnabled = enabled
|
||||
return {
|
||||
data: currentBillingUrl,
|
||||
isFetching: fetching,
|
||||
refetch: refetchMock,
|
||||
}
|
||||
},
|
||||
useBillingUrl: () => ({
|
||||
data: currentBillingUrl,
|
||||
isFetching: fetching,
|
||||
refetch: refetchMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
@@ -58,32 +54,28 @@ describe('Billing', () => {
|
||||
fetching = false
|
||||
isManager = true
|
||||
enableBilling = true
|
||||
billingUrlEnabled = false
|
||||
workspacePermissionKeys = ['billing.subscription.manage']
|
||||
refetchMock.mockResolvedValue({ data: 'https://billing' })
|
||||
})
|
||||
|
||||
it('hides the billing action when subscription management permission is granted without manager role', () => {
|
||||
it('shows the billing action when subscription management permission is granted without manager role', () => {
|
||||
isManager = false
|
||||
|
||||
render(<Billing />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
expect(screen.getByRole('button', { name: /billing\.viewBillingTitle/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the billing action when subscription management permission is missing or billing is disabled', () => {
|
||||
workspacePermissionKeys = []
|
||||
render(<Billing />)
|
||||
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
|
||||
vi.clearAllMocks()
|
||||
workspacePermissionKeys = ['billing.subscription.manage']
|
||||
enableBilling = false
|
||||
render(<Billing />)
|
||||
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
|
||||
expect(billingUrlEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the billing window with the immediate url when the button is clicked', async () => {
|
||||
|
||||
@@ -11,9 +11,9 @@ import PlanComp from '../plan'
|
||||
|
||||
const Billing: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceManager, workspacePermissionKeys } = useAppContext()
|
||||
const { workspacePermissionKeys } = useAppContext()
|
||||
const { enableBilling } = useProviderContext()
|
||||
const canManageBillingSubscription = isCurrentWorkspaceManager && hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
|
||||
const canManageBillingSubscription = hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage)
|
||||
const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && canManageBillingSubscription)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ type PricingProps = {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const pricingScrollAreaClassNames = {
|
||||
root: 'relative h-full w-full overflow-hidden',
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-h-full min-w-[1200px]',
|
||||
verticalScrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:me-1',
|
||||
horizontalScrollbar: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-0.5',
|
||||
corner: 'bg-saas-background',
|
||||
} as const
|
||||
|
||||
const Pricing: FC<PricingProps> = ({
|
||||
onCancel,
|
||||
}) => {
|
||||
@@ -56,9 +65,9 @@ const Pricing: FC<PricingProps> = ({
|
||||
<DialogContent
|
||||
className="inset-0 size-full max-h-none max-w-none translate-0 overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none"
|
||||
>
|
||||
<ScrollAreaRoot className="relative h-full w-full overflow-hidden">
|
||||
<ScrollAreaViewport className="overscroll-contain">
|
||||
<ScrollAreaContent className="min-h-full min-w-300">
|
||||
<ScrollAreaRoot className={pricingScrollAreaClassNames.root}>
|
||||
<ScrollAreaViewport className={pricingScrollAreaClassNames.viewport}>
|
||||
<ScrollAreaContent className={pricingScrollAreaClassNames.content}>
|
||||
<div className="relative grid min-h-full grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
|
||||
<div className="absolute inset-x-0 -top-12 -z-10">
|
||||
<NoiseTop />
|
||||
@@ -83,13 +92,16 @@ const Pricing: FC<PricingProps> = ({
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar className={pricingScrollAreaClassNames.verticalScrollbar}>
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar orientation="horizontal">
|
||||
<ScrollAreaScrollbar
|
||||
orientation="horizontal"
|
||||
className={pricingScrollAreaClassNames.horizontalScrollbar}
|
||||
>
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner className="bg-saas-background" />
|
||||
<ScrollAreaCorner className={pricingScrollAreaClassNames.corner} />
|
||||
</ScrollAreaRoot>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -23,6 +23,12 @@ import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/s
|
||||
import Item from './app-nav-item'
|
||||
import NoApps from './no-apps'
|
||||
|
||||
const expandedSidebarScrollAreaClassNames = {
|
||||
content: 'space-y-0.5',
|
||||
scrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:-me-3',
|
||||
viewport: 'overscroll-contain',
|
||||
} as const
|
||||
|
||||
const SideBar = () => {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
@@ -110,10 +116,7 @@ const SideBar = () => {
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'space-y-0.5 pr-3',
|
||||
}}
|
||||
slotClassNames={expandedSidebarScrollAreaClassNames}
|
||||
labelledBy={webAppsLabelId}
|
||||
>
|
||||
{installedAppItems}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user