Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec995c2a8a | ||
|
|
f83ef26edc | ||
|
|
611a7c5081 | ||
|
|
b72a51cf65 | ||
|
|
47de929a00 | ||
|
|
614c23da32 | ||
|
|
6e1bfdf4ac | ||
|
|
2581267a1a | ||
|
|
d8b8439047 | ||
|
|
d3db19e20d | ||
|
|
d96ba750db | ||
|
|
eecb3bb676 | ||
|
|
865426f2f5 | ||
|
|
41ffd27f2a | ||
|
|
a6a6bc322b | ||
|
|
83dd03f430 | ||
|
|
1ce65bf1c9 | ||
|
|
c5558b562c | ||
|
|
1bac0a1a40 | ||
|
|
5bb8658b0d | ||
|
|
a656e514aa | ||
|
|
fe274fc28c | ||
|
|
63e91b40ae | ||
|
|
dbb7e36ab9 | ||
|
|
4ddc9ceb5c | ||
|
|
01c138e7a9 | ||
|
|
71df1572b7 | ||
|
|
5d0ec54e8c | ||
|
|
ac80d95aed | ||
|
|
b1feacca6b | ||
|
|
7ef6c13d0d | ||
|
|
673d84073b | ||
|
|
3d24e21709 | ||
|
|
f11b09abf1 | ||
|
|
238b9202d6 | ||
|
|
573f9de17a | ||
|
|
d6e902e5a7 | ||
|
|
bf5caeb9f1 | ||
|
|
1e17c0f314 | ||
|
|
73c9017e63 | ||
|
|
6dd394edee | ||
|
|
53b59eea8b | ||
|
|
c9a3fa9a45 | ||
|
|
bf01ad417c | ||
|
|
6e1b7d806d | ||
|
|
7bb4b67885 | ||
|
|
8203d42233 | ||
|
|
3efc1f93b9 | ||
|
|
c06d924094 | ||
|
|
c3cb134e73 | ||
|
|
dd3ba72c2c | ||
|
|
44a4fb8888 | ||
|
|
6c05219af8 | ||
|
|
ce5033c554 | ||
|
|
31ee7bb467 |
@@ -312,7 +312,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
@@ -513,7 +513,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
|
||||
@@ -137,6 +137,7 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
def post(self, tenant_id: str, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -271,6 +272,7 @@ class AgentComposerValidateApi(Resource):
|
||||
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
|
||||
@@ -56,6 +56,7 @@ from libs.login import login_required
|
||||
from models import Account
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, IconType
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.agent.observability_service import (
|
||||
AgentLogQueryParams,
|
||||
@@ -65,7 +66,7 @@ from services.agent.observability_service import (
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import RosterListQuery
|
||||
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
@@ -250,6 +251,36 @@ class AgentDebugConversationRefreshResponse(BaseModel):
|
||||
debug_conversation_id: str
|
||||
|
||||
|
||||
class AgentPublishPayload(BaseModel):
|
||||
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
|
||||
|
||||
|
||||
class AgentPublishResponse(BaseModel):
|
||||
result: str
|
||||
active_config_snapshot_id: str
|
||||
active_config_snapshot: dict[str, object] | None = None
|
||||
draft: dict[str, object] | None = None
|
||||
|
||||
|
||||
class AgentBuildDraftCheckoutPayload(BaseModel):
|
||||
force: bool = Field(default=False, description="Overwrite the existing current-user build draft")
|
||||
|
||||
|
||||
class AgentBuildDraftResponse(BaseModel):
|
||||
variant: str
|
||||
draft: dict[str, object]
|
||||
agent_soul: dict[str, object]
|
||||
|
||||
|
||||
class AgentBuildDraftApplyResponse(BaseModel):
|
||||
result: str
|
||||
draft: dict[str, object]
|
||||
|
||||
|
||||
class AgentSimpleResultResponse(BaseModel):
|
||||
result: str
|
||||
|
||||
|
||||
class AgentAppPagination(GenericAppPagination):
|
||||
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
|
||||
validation_alias=AliasChoices("items", "data")
|
||||
@@ -261,6 +292,9 @@ register_schema_models(
|
||||
AgentAppCreatePayload,
|
||||
AgentAppUpdatePayload,
|
||||
AgentAppCopyPayload,
|
||||
AgentPublishPayload,
|
||||
AgentBuildDraftCheckoutPayload,
|
||||
ComposerSavePayload,
|
||||
AgentApiStatusPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
@@ -277,6 +311,10 @@ register_response_schema_models(
|
||||
AgentAppDetailWithSite,
|
||||
AgentAppPartial,
|
||||
AgentDebugConversationRefreshResponse,
|
||||
AgentPublishResponse,
|
||||
AgentBuildDraftResponse,
|
||||
AgentBuildDraftApplyResponse,
|
||||
AgentSimpleResultResponse,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentConfigSnapshotRestoreResponse,
|
||||
@@ -583,6 +621,112 @@ class AgentDebugConversationRefreshApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/publish")
|
||||
class AgentPublishApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentPublishPayload.__name__])
|
||||
@console_ns.response(200, "Agent draft published", console_ns.models[AgentPublishResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentPublishPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.publish_agent_app_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
version_note=args.version_note,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft/checkout")
|
||||
class AgentBuildDraftCheckoutApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentBuildDraftCheckoutPayload.__name__])
|
||||
@console_ns.response(200, "Agent build draft checked out", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = AgentBuildDraftCheckoutPayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.checkout_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
force=args.force,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft")
|
||||
class AgentBuildDraftApi(Resource):
|
||||
@console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.load_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(200, "Agent build draft saved", console_ns.models[AgentBuildDraftResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
@console_ns.response(200, "Agent build draft discarded", console_ns.models[AgentSimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.discard_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/build-draft/apply")
|
||||
class AgentBuildDraftApplyApi(Resource):
|
||||
@console_ns.response(200, "Agent build draft applied", console_ns.models[AgentBuildDraftApplyResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
return AgentComposerService.apply_agent_app_build_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account_id=current_user.id,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/copy")
|
||||
class AgentAppCopyApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
|
||||
|
||||
@@ -38,6 +38,7 @@ from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceResult,
|
||||
SkillToolInferenceService,
|
||||
)
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
@@ -181,6 +182,22 @@ def _agent_not_bound() -> tuple[dict[str, str], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _sync_active_soul_files(
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
committed_items: list[dict[str, Any]],
|
||||
) -> None:
|
||||
AgentSoulFilesService.sync_drive_commit_to_active_soul(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=account_id,
|
||||
committed_items=committed_items,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _upload_skill_for_app(*, current_user: Account, app_model: App):
|
||||
"""Upload one skill package and commit its normalized files into the agent drive."""
|
||||
|
||||
@@ -195,8 +212,9 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
|
||||
|
||||
upload = request.files["file"]
|
||||
content = upload.stream.read()
|
||||
standardize_service = SkillStandardizeService()
|
||||
try:
|
||||
result = SkillStandardizeService().standardize(
|
||||
result = standardize_service.standardize(
|
||||
content=content,
|
||||
filename=upload.filename or "",
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -205,6 +223,12 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
|
||||
)
|
||||
except (SkillPackageError, AgentDriveError) as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
_sync_active_soul_files(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
committed_items=standardize_service.last_committed_items,
|
||||
)
|
||||
return result, 201
|
||||
|
||||
|
||||
@@ -243,6 +267,12 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
_sync_active_soul_files(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
committed_items=committed,
|
||||
)
|
||||
|
||||
row = committed[0]
|
||||
return {
|
||||
@@ -267,15 +297,13 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[DriveCommitItem(key=key, file_ref=None)],
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
result = [{"key": key, "removed": True}]
|
||||
_sync_active_soul_files(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
committed_items=result,
|
||||
)
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
@@ -290,18 +318,34 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(
|
||||
session=db.session,
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
skill_prefix = f"{slug}/"
|
||||
removed_keys = [
|
||||
file_ref.drive_key
|
||||
for skill in agent_soul.files.skills
|
||||
if (
|
||||
skill.path
|
||||
or (AgentSoulFilesService.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")
|
||||
).strip("/")
|
||||
== slug
|
||||
for file_ref in skill.file_refs
|
||||
if file_ref.drive_key
|
||||
]
|
||||
if f"{slug}/SKILL.md" not in removed_keys:
|
||||
removed_keys.append(f"{slug}/SKILL.md")
|
||||
result = [{"key": key, "removed": True} for key in removed_keys if key.startswith(skill_prefix)]
|
||||
_sync_active_soul_files(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
committed_items=result,
|
||||
)
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
|
||||
@@ -28,10 +28,13 @@ from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.agent import AgentDriveFileKind
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
@@ -160,6 +163,111 @@ def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
def _versioned_manifest(*, tenant_id: str, agent_id: str, prefix: str = "") -> list[dict[str, Any]]:
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
normalized_prefix = prefix.strip().lstrip("/")
|
||||
skill_prefixes = AgentSoulFilesService.allowed_skill_prefixes(agent_soul)
|
||||
if normalized_prefix and any(normalized_prefix.startswith(p) for p in skill_prefixes):
|
||||
return AgentSoulFilesService.list_manifest_items(
|
||||
session=db.session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=normalized_prefix,
|
||||
)
|
||||
return AgentSoulFilesService.list_files(
|
||||
session=db.session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=normalized_prefix,
|
||||
)
|
||||
|
||||
|
||||
def _versioned_skills(*, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
|
||||
return AgentSoulFilesService.list_skills(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
|
||||
|
||||
def _assert_key_in_active_soul(*, tenant_id: str, agent_id: str, key: str) -> None:
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
if not AgentSoulFilesService.key_allowed_by_soul(agent_soul=agent_soul, key=key):
|
||||
raise AgentDriveError(
|
||||
"drive_key_not_in_agent_soul",
|
||||
"drive key is not part of the active Agent Soul version",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
def _file_ref_for_active_soul(*, tenant_id: str, agent_id: str, key: str):
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
file_ref = AgentSoulFilesService.file_ref_for_key(agent_soul=agent_soul, key=key)
|
||||
if file_ref is None and not AgentSoulFilesService.key_allowed_by_soul(agent_soul=agent_soul, key=key):
|
||||
raise AgentDriveError(
|
||||
"drive_key_not_in_agent_soul",
|
||||
"drive key is not part of the active Agent Soul version",
|
||||
status_code=404,
|
||||
)
|
||||
return file_ref
|
||||
|
||||
|
||||
def _file_kind_from_ref(file_ref) -> AgentDriveFileKind | None:
|
||||
raw = file_ref.transfer_method or ("upload_file" if file_ref.upload_file_id else None)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return AgentDriveFileKind(raw)
|
||||
except ValueError as exc:
|
||||
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref has invalid transfer method") from exc
|
||||
|
||||
|
||||
def _preview_versioned_file(*, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
|
||||
file_ref = _file_ref_for_active_soul(tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
if file_ref is None:
|
||||
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
file_kind = _file_kind_from_ref(file_ref)
|
||||
file_id = file_ref.file_id or file_ref.upload_file_id
|
||||
if file_kind is None or not file_id:
|
||||
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref is missing file id", status_code=404)
|
||||
return AgentDriveService().preview_file_ref(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
file_kind=file_kind,
|
||||
file_id=file_id,
|
||||
size=file_ref.get("size"),
|
||||
)
|
||||
|
||||
|
||||
def _download_versioned_file(*, tenant_id: str, agent_id: str, key: str) -> str:
|
||||
file_ref = _file_ref_for_active_soul(tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
if file_ref is None:
|
||||
return AgentDriveService().download_url(tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
file_kind = _file_kind_from_ref(file_ref)
|
||||
file_id = file_ref.file_id or file_ref.upload_file_id
|
||||
if file_kind is None or not file_id:
|
||||
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref is missing file id", status_code=404)
|
||||
return AgentDriveService().download_url_for_ref(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
file_kind=file_kind,
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
|
||||
def _assert_skill_in_active_soul(*, tenant_id: str, agent_id: str, skill_path: str) -> None:
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
wanted = skill_path.strip().strip("/")
|
||||
for skill in agent_soul.files.skills:
|
||||
path = skill.path
|
||||
if not path and skill.skill_md_key:
|
||||
path = AgentSoulFilesService.skill_path_from_key(skill.skill_md_key)
|
||||
if path == wanted:
|
||||
return
|
||||
raise AgentDriveError(
|
||||
"skill_not_in_agent_soul",
|
||||
"skill is not part of the active Agent Soul version",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
def _json_response(data: Mapping[str, Any]):
|
||||
return Response(
|
||||
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
|
||||
@@ -184,7 +292,7 @@ class AgentDriveListByAgentApi(Resource):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
|
||||
items = _versioned_manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
@@ -203,7 +311,7 @@ class AgentDriveSkillListByAgentApi(Resource):
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
items = _versioned_skills(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
@@ -222,6 +330,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
_assert_skill_in_active_soul(tenant_id=tenant_id, agent_id=str(agent_id), skill_path=skill_path)
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=tenant_id,
|
||||
@@ -247,7 +356,7 @@ class AgentDrivePreviewByAgentApi(Resource):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
return _preview_versioned_file(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
@@ -266,7 +375,7 @@ class AgentDriveDownloadByAgentApi(Resource):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
url = _download_versioned_file(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
@@ -288,7 +397,7 @@ class AgentDriveListApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
|
||||
items = _versioned_manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
# the inner manifest exposes file_id for agent-side pulls; the console
|
||||
@@ -312,7 +421,7 @@ class AgentDriveSkillListApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
|
||||
items = _versioned_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
@@ -340,6 +449,7 @@ class AgentDriveSkillInspectApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
_assert_skill_in_active_soul(tenant_id=app_model.tenant_id, agent_id=agent_id, skill_path=skill_path)
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -367,7 +477,7 @@ class AgentDrivePreviewApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
return _preview_versioned_file(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
@@ -388,7 +498,7 @@ class AgentDriveDownloadApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
url = _download_versioned_file(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
@@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class Site(ResponseModel):
|
||||
class AppDetailSiteResponse(ResponseModel):
|
||||
access_token: str | None = Field(default=None, validation_alias="code")
|
||||
code: str | None = None
|
||||
title: str | None = None
|
||||
@@ -461,7 +461,7 @@ class AppDetailWithSite(AppDetail):
|
||||
api_base_url: str | None = None
|
||||
max_active_requests: int | None = None
|
||||
deleted_tools: list[DeletedTool] = Field(default_factory=list)
|
||||
site: Site | None = None
|
||||
site: AppDetailSiteResponse | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
@@ -546,7 +546,7 @@ register_schema_models(
|
||||
WorkflowPartial,
|
||||
ModelConfigPartial,
|
||||
ModelConfig,
|
||||
Site,
|
||||
AppDetailSiteResponse,
|
||||
DeletedTool,
|
||||
AppDetail,
|
||||
AppExportResponse,
|
||||
|
||||
@@ -93,6 +93,10 @@ class ChatMessagePayload(BaseMessagePayload):
|
||||
query: str = Field(..., description="User query")
|
||||
conversation_id: str | None = Field(default=None, description="Conversation ID")
|
||||
parent_message_id: str | None = Field(default=None, description="Parent message ID")
|
||||
draft_type: Literal["draft", "debug_build"] = Field(
|
||||
default="draft",
|
||||
description="Agent App debug config source. Use debug_build while the Agent is in build mode.",
|
||||
)
|
||||
|
||||
@field_validator("conversation_id", "parent_message_id")
|
||||
@classmethod
|
||||
|
||||
@@ -17,6 +17,9 @@ from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.plugin.wraps import get_user
|
||||
from controllers.inner_api.wraps import plugin_inner_api_only
|
||||
from extensions.ext_database import db
|
||||
from models.agent import AgentDriveFileKind
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
@@ -35,6 +38,42 @@ def _error_response(exc: AgentDriveError) -> tuple[dict[str, str], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
def _versioned_manifest(
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prefix: str = "",
|
||||
include_download_url: bool = False,
|
||||
) -> list[dict[str, object]]:
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
normalized_prefix = prefix.strip().lstrip("/")
|
||||
items = AgentSoulFilesService.list_manifest_items(
|
||||
session=db.session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=normalized_prefix,
|
||||
)
|
||||
skill_prefixes = AgentSoulFilesService.allowed_skill_prefixes(agent_soul)
|
||||
if normalized_prefix and not any(normalized_prefix.startswith(p) for p in skill_prefixes):
|
||||
allowed_keys = AgentSoulFilesService.allowed_drive_keys(agent_soul)
|
||||
items = [item for item in items if item.get("key") in allowed_keys]
|
||||
if include_download_url:
|
||||
for item in items:
|
||||
file_kind = item.get("file_kind")
|
||||
file_id = item.get("file_id")
|
||||
if not file_kind or not file_id:
|
||||
continue
|
||||
try:
|
||||
item["download_url"] = AgentDriveService.resolve_download_url_for_ref(
|
||||
tenant_id=tenant_id,
|
||||
file_kind=AgentDriveFileKind(str(file_kind)),
|
||||
file_id=str(file_id),
|
||||
)
|
||||
except ValueError:
|
||||
item["download_url"] = None
|
||||
return items
|
||||
|
||||
|
||||
@inner_api_ns.route("/drive/<string:drive_ref>/manifest")
|
||||
class AgentDriveManifestApi(Resource):
|
||||
@setup_required
|
||||
@@ -48,7 +87,7 @@ class AgentDriveManifestApi(Resource):
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
include_download_url = (request.args.get("include_download_url") or "").lower() in ("1", "true", "yes")
|
||||
items = AgentDriveService().manifest(
|
||||
items = _versioned_manifest(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=request.args.get("prefix", ""),
|
||||
@@ -71,7 +110,7 @@ class AgentDriveSkillsApi(Resource):
|
||||
tenant_id = (request.args.get("tenant_id") or "").strip()
|
||||
if not tenant_id:
|
||||
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
items = AgentSoulFilesService.list_skills(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
@@ -97,6 +136,13 @@ class AgentDriveCommitApi(Resource):
|
||||
agent_id=agent_id,
|
||||
items=body.items,
|
||||
)
|
||||
AgentSoulFilesService.sync_drive_commit_to_active_soul(
|
||||
tenant_id=body.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=user.id,
|
||||
committed_items=items,
|
||||
)
|
||||
db.session.commit()
|
||||
except AgentDriveError as exc:
|
||||
return _error_response(exc)
|
||||
return {"items": items}
|
||||
|
||||
@@ -42,7 +42,15 @@ from core.app.llm.model_access import build_dify_model_access
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App, EndUser, Message
|
||||
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigSnapshot,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentStatus,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.conversation_service import ConversationService
|
||||
|
||||
@@ -73,10 +81,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
inputs = args["inputs"]
|
||||
|
||||
# Resolve the bound roster Agent + its current Agent Soul snapshot.
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
agent, agent_config_id, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type=args.get("draft_type"),
|
||||
user=user,
|
||||
)
|
||||
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
|
||||
invoke_from=invoke_from,
|
||||
snapshot_id=snapshot.id,
|
||||
snapshot_id=agent_config_id,
|
||||
)
|
||||
|
||||
conversation = None
|
||||
@@ -123,7 +136,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
|
||||
)
|
||||
|
||||
@@ -179,7 +192,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
persisted to the conversation. Live streaming to a reconnected client is
|
||||
out of scope here — the message is persisted and can be re-fetched.
|
||||
"""
|
||||
agent, snapshot, agent_soul = self._resolve_agent(app_model)
|
||||
agent, agent_config_id, agent_soul = self._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=invoke_from,
|
||||
draft_type="draft",
|
||||
user=user,
|
||||
)
|
||||
conversation = ConversationService.get_conversation(
|
||||
app_model=app_model, conversation_id=conversation_id, user=user
|
||||
)
|
||||
@@ -226,7 +244,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
call_depth=0,
|
||||
trace_manager=trace_manager,
|
||||
agent_id=agent.id,
|
||||
agent_config_snapshot_id=snapshot.id,
|
||||
agent_config_snapshot_id=agent_config_id,
|
||||
)
|
||||
|
||||
conversation, message = self._init_generate_records(application_generate_entity, conversation)
|
||||
@@ -421,7 +439,14 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
return False, query
|
||||
|
||||
def _resolve_agent(self, app_model: App) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
def _resolve_agent(
|
||||
self,
|
||||
app_model: App,
|
||||
*,
|
||||
invoke_from: InvokeFrom,
|
||||
draft_type: Any,
|
||||
user: Account | EndUser,
|
||||
) -> tuple[Agent, str, AgentSoulConfig]:
|
||||
agent = db.session.scalar(
|
||||
select(Agent).where(
|
||||
Agent.app_id == app_model.id,
|
||||
@@ -432,39 +457,108 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
)
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent App has no bound Agent")
|
||||
return self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
draft = self._resolve_debug_draft(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent=agent,
|
||||
draft_type=draft_type,
|
||||
account_id=user.id if isinstance(user, Account) else None,
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return agent, draft.id, agent_soul
|
||||
_, snapshot, agent_soul = self._resolve_agent_by_id(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
return agent, snapshot.id, agent_soul
|
||||
|
||||
@staticmethod
|
||||
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
|
||||
"""Return the session scope snapshot id for Agent App runtime state.
|
||||
|
||||
Console preview/debug chat is an editing workspace: saving Agent Soul
|
||||
creates replacement snapshots, but the user expects the same preview
|
||||
conversation to keep context while trying prompt changes. Use a stable
|
||||
NULL snapshot scope for debugger runs so each turn can use the latest
|
||||
Agent Soul while reusing the conversation history. Published/web/API
|
||||
runs keep snapshot-scoped sessions for reproducible runtime state.
|
||||
Console preview/debug chat uses a stable Agent draft row id; build mode
|
||||
uses the current user's build-draft row id. Published/web/API runs use
|
||||
immutable published snapshot ids. This keeps runtime session continuity
|
||||
inside one editable surface without mixing draft/build/published state.
|
||||
"""
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
return None
|
||||
return snapshot_id
|
||||
|
||||
@staticmethod
|
||||
def _resolve_debug_draft(
|
||||
*, tenant_id: str, agent: Agent, draft_type: Any, account_id: str | None
|
||||
) -> AgentConfigDraft:
|
||||
effective_draft_type = (
|
||||
AgentConfigDraftType.DEBUG_BUILD
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD.value
|
||||
else AgentConfigDraftType.DRAFT
|
||||
)
|
||||
stmt = select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent.id,
|
||||
AgentConfigDraft.draft_type == effective_draft_type,
|
||||
)
|
||||
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
if not account_id:
|
||||
raise AgentAppGeneratorError("Build draft requires an account user")
|
||||
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
|
||||
else:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
|
||||
draft = db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
if draft is not None:
|
||||
return draft
|
||||
if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
raise AgentAppGeneratorError("Agent build draft not found")
|
||||
_, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id=snapshot.id,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=agent.created_by,
|
||||
updated_by=agent.updated_by,
|
||||
)
|
||||
db.session.add(draft)
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_by_id(
|
||||
*, tenant_id: str, agent_id: str, snapshot_id: str | None
|
||||
) -> tuple[Agent, AgentConfigSnapshot, AgentSoulConfig]:
|
||||
) -> tuple[Agent, AgentConfigSnapshot | AgentConfigDraft, AgentSoulConfig]:
|
||||
agent = db.session.scalar(select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id))
|
||||
if agent is None:
|
||||
raise AgentAppGeneratorError("Agent not found")
|
||||
if not snapshot_id:
|
||||
raise AgentAppGeneratorError("Agent has no published version")
|
||||
snapshot = db.session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.id == snapshot_id))
|
||||
if snapshot is None:
|
||||
snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == snapshot_id,
|
||||
)
|
||||
)
|
||||
if snapshot is not None:
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
draft = db.session.scalar(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.id == snapshot_id,
|
||||
)
|
||||
)
|
||||
if draft is None:
|
||||
raise AgentAppGeneratorError("Agent published version not found")
|
||||
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
return agent, snapshot, agent_soul
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
return agent, draft, agent_soul
|
||||
|
||||
|
||||
__all__ = ["AgentAppGenerator", "AgentAppGeneratorError"]
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
|
||||
|
||||
SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
|
||||
{
|
||||
@@ -48,9 +49,7 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
)
|
||||
|
||||
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
|
||||
reserved_status["knowledge"] = (
|
||||
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured"
|
||||
)
|
||||
reserved_status["knowledge"] = "supported_by_knowledge_layer" if agent_soul.knowledge.sets else "not_configured"
|
||||
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
|
||||
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
|
||||
reserved_status["env"] = "supported_by_shell_bootstrap"
|
||||
@@ -66,14 +65,14 @@ def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any
|
||||
|
||||
|
||||
def list_configured_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
|
||||
"""Return the normalized knowledge dataset ids that can produce a runtime layer.
|
||||
"""Return normalized dataset ids selected by Agent v2 knowledge sets.
|
||||
|
||||
``build_runtime_feature_manifest()`` and ``build_knowledge_layer_config()``
|
||||
must stay aligned: both decide knowledge support from this effective,
|
||||
non-blank dataset-id set rather than from raw
|
||||
``agent_soul.knowledge.datasets`` entries.
|
||||
stay aligned on the set-based contract: DTO validation rejects blank dataset
|
||||
ids before runtime, so this helper only flattens configured set datasets for
|
||||
metadata/diagnostic surfaces that still need a dataset-id summary.
|
||||
"""
|
||||
return [dataset_id for dataset in agent_soul.knowledge.datasets if (dataset_id := (dataset.id or "").strip())]
|
||||
return list_agent_soul_knowledge_dataset_ids(agent_soul)
|
||||
|
||||
|
||||
def _get_nested(value: dict[str, Any], path: str) -> Any:
|
||||
|
||||
@@ -15,7 +15,16 @@ from dify_agent.layers.execution_context import (
|
||||
DifyExecutionContextLayerConfig,
|
||||
DifyExecutionContextUserFrom,
|
||||
)
|
||||
from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig, DifyKnowledgeRetrievalConfig
|
||||
from dify_agent.layers.knowledge import (
|
||||
DifyKnowledgeBaseLayerConfig,
|
||||
DifyKnowledgeDatasetConfig,
|
||||
DifyKnowledgeMetadataFilteringConfig,
|
||||
DifyKnowledgeModelConfig,
|
||||
DifyKnowledgeQueryConfig,
|
||||
DifyKnowledgeRerankingModelConfig,
|
||||
DifyKnowledgeRetrievalConfig,
|
||||
DifyKnowledgeSetConfig,
|
||||
)
|
||||
from dify_agent.layers.shell import (
|
||||
DifyShellCliToolConfig,
|
||||
DifyShellEnvVarConfig,
|
||||
@@ -40,7 +49,9 @@ from graphon.file import FileTransferMethod
|
||||
from graphon.variables.segments import Segment
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
from models.agent_config_entities import (
|
||||
AgentKnowledgeQueryConfig,
|
||||
AgentKnowledgeMetadataFilteringConfig,
|
||||
AgentKnowledgeModelConfig,
|
||||
AgentKnowledgeRetrievalConfig,
|
||||
AgentSoulConfig,
|
||||
DeclaredArrayItem,
|
||||
DeclaredOutputChildConfig,
|
||||
@@ -60,11 +71,12 @@ from services.agent.prompt_mentions import (
|
||||
expand_prompt_mentions,
|
||||
parse_prompt_mentions,
|
||||
)
|
||||
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
from services.agent_drive_service import decode_drive_mention_ref
|
||||
|
||||
from .output_failure_orchestrator import retry_idempotency_key
|
||||
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
|
||||
from .runtime_feature_manifest import build_runtime_feature_manifest, list_configured_knowledge_dataset_ids
|
||||
from .runtime_feature_manifest import build_runtime_feature_manifest
|
||||
|
||||
_DENIED_PERMISSION_STATUSES = frozenset({"unauthorized", "denied", "forbidden", "invalid", "unavailable"})
|
||||
_DANGEROUS_FLAG_KEYS = ("dangerous", "dangerous_command", "requires_confirmation")
|
||||
@@ -547,42 +559,84 @@ def build_shell_layer_config(agent_soul: AgentSoulConfig) -> DifyShellLayerConfi
|
||||
|
||||
|
||||
def build_knowledge_layer_config(agent_soul: AgentSoulConfig) -> DifyKnowledgeBaseLayerConfig | None:
|
||||
"""Map Agent Soul knowledge config into the fixed Dify knowledge-base layer.
|
||||
"""Map Agent Soul knowledge sets into one Dify knowledge-base layer.
|
||||
|
||||
Normalization intentionally matches the current dify-agent runtime contract:
|
||||
|
||||
- blank or missing dataset ids are ignored;
|
||||
- if no valid dataset ids remain, no knowledge layer is injected;
|
||||
- retrieval mode is always forced to ``multiple`` in this first wiring pass;
|
||||
- ``top_k`` falls back to a stable runtime default when the soul omits it;
|
||||
- ``score_threshold`` is only forwarded when the product config explicitly
|
||||
enables it, otherwise the layer keeps the disabled/default ``0.0`` value;
|
||||
- metadata filtering stays at the layer DTO default (disabled).
|
||||
Agent Soul DTO validation owns malformed set rejection. Runtime mapping is
|
||||
intentionally lossless: every configured set is forwarded with its query
|
||||
policy, dataset refs, retrieval controls, and metadata-filtering controls.
|
||||
``score_threshold=None`` means disabled threshold filtering and maps to the
|
||||
inner retrieval request's ``0.0`` default through the Agent backend DTO.
|
||||
"""
|
||||
dataset_ids = list_configured_knowledge_dataset_ids(agent_soul)
|
||||
if not dataset_ids:
|
||||
if not agent_soul.knowledge.sets:
|
||||
return None
|
||||
|
||||
query_config = agent_soul.knowledge.query_config
|
||||
return DifyKnowledgeBaseLayerConfig(
|
||||
dataset_ids=dataset_ids,
|
||||
retrieval=DifyKnowledgeRetrievalConfig(
|
||||
mode="multiple",
|
||||
top_k=_knowledge_top_k(query_config),
|
||||
score_threshold=_knowledge_score_threshold(query_config),
|
||||
),
|
||||
sets=[
|
||||
DifyKnowledgeSetConfig(
|
||||
id=knowledge_set.id,
|
||||
name=knowledge_set.name,
|
||||
description=knowledge_set.description,
|
||||
datasets=[
|
||||
DifyKnowledgeDatasetConfig(
|
||||
id=dataset.id or "",
|
||||
name=dataset.name,
|
||||
description=dataset.description,
|
||||
)
|
||||
for dataset in knowledge_set.datasets
|
||||
],
|
||||
query=DifyKnowledgeQueryConfig(
|
||||
mode=cast(Literal["user_query", "generated_query"], knowledge_set.query.mode.value),
|
||||
value=knowledge_set.query.value,
|
||||
),
|
||||
retrieval=_knowledge_retrieval_config(knowledge_set.retrieval),
|
||||
metadata_filtering=_knowledge_metadata_filtering_config(knowledge_set.metadata_filtering),
|
||||
)
|
||||
for knowledge_set in agent_soul.knowledge.sets
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_top_k(query_config: AgentKnowledgeQueryConfig) -> int:
|
||||
top_k = query_config.top_k
|
||||
return top_k if isinstance(top_k, int) and top_k >= 1 else 4
|
||||
def _knowledge_retrieval_config(retrieval: AgentKnowledgeRetrievalConfig) -> DifyKnowledgeRetrievalConfig:
|
||||
return DifyKnowledgeRetrievalConfig(
|
||||
mode=retrieval.mode,
|
||||
top_k=retrieval.top_k,
|
||||
score_threshold=retrieval.score_threshold or 0.0,
|
||||
reranking_mode=retrieval.reranking_mode,
|
||||
reranking_enable=retrieval.reranking_enable,
|
||||
reranking_model=DifyKnowledgeRerankingModelConfig(
|
||||
provider=retrieval.reranking_model.provider,
|
||||
model=retrieval.reranking_model.model,
|
||||
)
|
||||
if retrieval.reranking_model is not None
|
||||
else None,
|
||||
weights=cast(dict[str, Any], retrieval.weights.model_dump(mode="json", exclude_none=True))
|
||||
if retrieval.weights is not None
|
||||
else None,
|
||||
model=_knowledge_model_config(retrieval.model),
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_score_threshold(query_config: AgentKnowledgeQueryConfig) -> float:
|
||||
if query_config.score_threshold_enabled and query_config.score_threshold is not None:
|
||||
return query_config.score_threshold
|
||||
return 0.0
|
||||
def _knowledge_metadata_filtering_config(
|
||||
metadata_filtering: AgentKnowledgeMetadataFilteringConfig,
|
||||
) -> DifyKnowledgeMetadataFilteringConfig:
|
||||
return DifyKnowledgeMetadataFilteringConfig(
|
||||
mode=metadata_filtering.mode,
|
||||
model_config=_knowledge_model_config(metadata_filtering.metadata_model_config),
|
||||
conditions=cast(Any, metadata_filtering.conditions.model_dump(mode="json"))
|
||||
if metadata_filtering.conditions is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
def _knowledge_model_config(model: AgentKnowledgeModelConfig | None) -> DifyKnowledgeModelConfig | None:
|
||||
if model is None:
|
||||
return None
|
||||
return DifyKnowledgeModelConfig(
|
||||
provider=model.provider,
|
||||
name=model.name,
|
||||
mode=model.mode,
|
||||
completion_params=model.completion_params,
|
||||
)
|
||||
|
||||
|
||||
def build_ask_human_layer_config(agent_soul: AgentSoulConfig) -> DifyAskHumanLayerConfig | None:
|
||||
@@ -616,13 +670,19 @@ def build_drive_aware_soul_mention_resolver(
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
):
|
||||
"""Resolve skill/file mentions against the agent drive and everything else via Agent Soul."""
|
||||
"""Resolve skill/file mentions against versioned Agent Soul refs and everything else via Agent Soul."""
|
||||
|
||||
base_resolver = build_soul_mention_resolver(agent_soul)
|
||||
drive_service = AgentDriveService()
|
||||
skill_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
skill_names_by_key = {skill["skill_md_key"]: skill["name"] for skill in skill_catalog}
|
||||
drive_keys = {item["key"] for item in drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)}
|
||||
skill_names_by_key = {
|
||||
skill.skill_md_key: skill.name
|
||||
for skill in agent_soul.files.skills
|
||||
if skill.skill_md_key and skill.name
|
||||
}
|
||||
file_names_by_key = {
|
||||
file_ref.drive_key: file_ref.name or file_ref.drive_key.rsplit("/", 1)[-1]
|
||||
for file_ref in agent_soul.files.files
|
||||
if file_ref.drive_key
|
||||
}
|
||||
|
||||
def _resolve(mention: object) -> str | None:
|
||||
if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"):
|
||||
@@ -635,9 +695,7 @@ def build_drive_aware_soul_mention_resolver(
|
||||
return skill_names_by_key.get(decoded_key) or label or decoded_key
|
||||
if kind == MentionKind.FILE:
|
||||
decoded_key = decode_drive_mention_ref(ref_id)
|
||||
if decoded_key in drive_keys:
|
||||
return decoded_key.rsplit("/", 1)[-1]
|
||||
return label or decoded_key
|
||||
return file_names_by_key.get(decoded_key) or label or decoded_key
|
||||
return base_resolver(cast(Any, mention))
|
||||
|
||||
return _resolve
|
||||
@@ -649,7 +707,7 @@ def build_drive_layer_config(
|
||||
tenant_id: str,
|
||||
agent_id: str | None,
|
||||
) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]:
|
||||
"""Derive drive runtime catalog + prompt-mentioned eager-pull keys from the drive."""
|
||||
"""Derive drive runtime catalog + prompt-mentioned eager-pull keys from Agent Soul refs."""
|
||||
|
||||
mentioned_drive_refs = [
|
||||
decode_drive_mention_ref(mention.ref_id)
|
||||
@@ -668,10 +726,22 @@ def build_drive_layer_config(
|
||||
}
|
||||
]
|
||||
|
||||
drive_service = AgentDriveService()
|
||||
skills_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
|
||||
manifest_items = drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)
|
||||
manifest_by_key = {item["key"]: item for item in manifest_items}
|
||||
skills_catalog = [
|
||||
{
|
||||
"path": skill.path or AgentSoulFilesService.skill_path_from_key(skill.skill_md_key),
|
||||
"name": skill.name or skill.path or skill.skill_md_key,
|
||||
"description": skill.description or "",
|
||||
"skill_md_key": skill.skill_md_key,
|
||||
"archive_key": skill.full_archive_key,
|
||||
}
|
||||
for skill in agent_soul.files.skills
|
||||
if skill.skill_md_key
|
||||
]
|
||||
soul_file_keys = {
|
||||
key
|
||||
for key in AgentSoulFilesService.allowed_drive_keys(agent_soul)
|
||||
if key not in {skill["skill_md_key"] for skill in skills_catalog}
|
||||
}
|
||||
skill_keys = {skill["skill_md_key"] for skill in skills_catalog}
|
||||
warnings: list[dict[str, str]] = []
|
||||
mentioned_skill_keys: list[str] = []
|
||||
@@ -680,7 +750,7 @@ def build_drive_layer_config(
|
||||
if drive_key in skill_keys:
|
||||
mentioned_skill_keys.append(drive_key)
|
||||
continue
|
||||
if drive_key in manifest_by_key:
|
||||
if drive_key in soul_file_keys:
|
||||
mentioned_file_keys.append(drive_key)
|
||||
continue
|
||||
warnings.append(
|
||||
|
||||
@@ -18,6 +18,7 @@ from models.agent_config_entities import (
|
||||
)
|
||||
from models.model import UploadFile
|
||||
from models.workflow import Workflow
|
||||
from services.agent.knowledge_datasets import list_missing_tenant_knowledge_dataset_ids
|
||||
|
||||
from .entities import DifyAgentNodeData
|
||||
|
||||
@@ -146,6 +147,7 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
cls._validate_agent_soul_env(binding=binding, agent_soul=agent_soul)
|
||||
cls._validate_agent_soul_tools(binding=binding, agent_soul=agent_soul)
|
||||
cls._validate_agent_soul_knowledge(binding=binding, agent_soul=agent_soul)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
cls.validate_node_job(session=session, binding=binding, node_job=node_job, topology=topology)
|
||||
|
||||
@@ -364,6 +366,24 @@ class WorkflowAgentNodeValidator:
|
||||
)
|
||||
cli_tool_names.add(normalized_name)
|
||||
|
||||
@classmethod
|
||||
def _validate_agent_soul_knowledge(
|
||||
cls,
|
||||
*,
|
||||
binding: WorkflowAgentNodeBinding,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> None:
|
||||
"""Validate knowledge set dataset rows against the publishing tenant."""
|
||||
missing_ids = list_missing_tenant_knowledge_dataset_ids(
|
||||
tenant_id=binding.tenant_id,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
if missing_ids:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} references missing or out-of-scope knowledge datasets: "
|
||||
f"{', '.join(missing_ids)}."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _validate_agent_soul_env(
|
||||
cls,
|
||||
|
||||
@@ -6,6 +6,7 @@ from pydantic import Field, field_validator
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import to_timestamp
|
||||
from models.agent import (
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentIconType,
|
||||
AgentKind,
|
||||
@@ -16,8 +17,10 @@ from models.agent import (
|
||||
)
|
||||
from models.agent_config_entities import (
|
||||
AgentCliToolConfig,
|
||||
AgentFileRefConfig,
|
||||
AgentHumanContactConfig,
|
||||
AgentKnowledgeDatasetConfig,
|
||||
AgentSkillRefConfig,
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
DeclaredOutputType,
|
||||
@@ -47,6 +50,18 @@ class AgentConfigSnapshotSummaryResponse(ResponseModel):
|
||||
created_at: int | None = None
|
||||
|
||||
|
||||
class AgentConfigDraftSummaryResponse(ResponseModel):
|
||||
id: str
|
||||
agent_id: str
|
||||
draft_type: AgentConfigDraftType
|
||||
account_id: str | None = None
|
||||
base_snapshot_id: str | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
|
||||
|
||||
class AgentPublishedReferenceResponse(ResponseModel):
|
||||
app_id: str
|
||||
app_name: str
|
||||
@@ -292,12 +307,18 @@ class AgentConfigSnapshotListResponse(ResponseModel):
|
||||
class AgentConfigSnapshotRestoreResponse(ResponseModel):
|
||||
result: Literal["success"]
|
||||
active_config_snapshot_id: str
|
||||
draft_config_id: str | None = None
|
||||
restored_version_id: str | None = None
|
||||
|
||||
|
||||
class AgentComposerAgentResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
role: str | None = None
|
||||
icon_type: str | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
scope: AgentScope
|
||||
status: AgentStatus
|
||||
active_config_snapshot_id: str | None = None
|
||||
@@ -350,7 +371,8 @@ class WorkflowAgentComposerResponse(ResponseModel):
|
||||
class AgentAppComposerResponse(ResponseModel):
|
||||
variant: Literal[ComposerVariant.AGENT_APP]
|
||||
agent: AgentComposerAgentResponse
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse
|
||||
active_config_snapshot: AgentConfigSnapshotSummaryResponse | None = None
|
||||
draft: AgentConfigDraftSummaryResponse | None = None
|
||||
agent_soul: AgentSoulConfig
|
||||
save_options: list[ComposerSaveStrategy]
|
||||
validation: "ComposerValidationFindingsResponse | None" = None
|
||||
@@ -400,11 +422,25 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
|
||||
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerKnowledgeDatasetCandidateResponse(AgentKnowledgeDatasetConfig):
|
||||
missing: bool = False
|
||||
|
||||
|
||||
class AgentComposerKnowledgeSetCandidateResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
datasets: list[AgentComposerKnowledgeDatasetCandidateResponse] = Field(default_factory=list)
|
||||
missing_dataset_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerSoulCandidatesResponse(ResponseModel):
|
||||
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
|
||||
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
|
||||
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
knowledge_sets: list[AgentComposerKnowledgeSetCandidateResponse] = Field(default_factory=list)
|
||||
human_contacts: list[AgentHumanContactConfig] = Field(default_factory=list)
|
||||
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
|
||||
files: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentComposerCandidatesResponse(ResponseModel):
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""add agent config drafts
|
||||
|
||||
Revision ID: e4f5a6b7c8d9
|
||||
Revises: d9e8f7a6b5c4
|
||||
Create Date: 2026-06-24 20:15:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
from libs.uuid_utils import uuidv7
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "e4f5a6b7c8d9"
|
||||
down_revision = "d9e8f7a6b5c4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _is_pg(conn) -> bool:
|
||||
return conn.dialect.name == "postgresql"
|
||||
|
||||
|
||||
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
|
||||
kwargs = {"nullable": nullable, "primary_key": primary_key}
|
||||
if primary_key and _is_pg(op.get_bind()):
|
||||
kwargs["server_default"] = sa.text("uuidv7()")
|
||||
return sa.Column(name, models.types.StringUUID(), **kwargs)
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"agent_config_drafts",
|
||||
_uuid_column("id", primary_key=True),
|
||||
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("draft_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("account_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("draft_owner_key", sa.String(length=255), server_default="", nullable=False),
|
||||
sa.Column("base_snapshot_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("config_snapshot", models.types.LongText(), nullable=False),
|
||||
sa.Column("created_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("updated_by", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("agent_config_draft_pkey")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"draft_type",
|
||||
"draft_owner_key",
|
||||
name=op.f("agent_config_draft_agent_type_account_unique"),
|
||||
),
|
||||
)
|
||||
op.create_index("agent_config_draft_tenant_agent_idx", "agent_config_drafts", ["tenant_id", "agent_id"])
|
||||
op.create_index(
|
||||
"agent_config_draft_base_snapshot_idx",
|
||||
"agent_config_drafts",
|
||||
["tenant_id", "base_snapshot_id"],
|
||||
)
|
||||
|
||||
bind = op.get_bind()
|
||||
now = datetime.now(UTC).replace(tzinfo=None)
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO agent_config_drafts (
|
||||
id, tenant_id, agent_id, draft_type, account_id, draft_owner_key, base_snapshot_id,
|
||||
config_snapshot, created_by, updated_by, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
uuidv7(), a.tenant_id, a.id, 'draft', NULL, '', s.id,
|
||||
s.config_snapshot, a.created_by, a.updated_by, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM agents a
|
||||
JOIN agent_config_snapshots s
|
||||
ON s.tenant_id = a.tenant_id
|
||||
AND s.agent_id = a.id
|
||||
AND s.id = a.active_config_snapshot_id
|
||||
WHERE a.active_config_snapshot_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
else:
|
||||
agents = bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT
|
||||
a.tenant_id, a.id AS agent_id, a.created_by, a.updated_by,
|
||||
s.id AS snapshot_id, s.config_snapshot
|
||||
FROM agents a
|
||||
JOIN agent_config_snapshots s
|
||||
ON s.tenant_id = a.tenant_id
|
||||
AND s.agent_id = a.id
|
||||
AND s.id = a.active_config_snapshot_id
|
||||
WHERE a.active_config_snapshot_id IS NOT NULL
|
||||
"""
|
||||
)
|
||||
).mappings()
|
||||
for row in agents:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO agent_config_drafts (
|
||||
id, tenant_id, agent_id, draft_type, account_id, draft_owner_key, base_snapshot_id,
|
||||
config_snapshot, created_by, updated_by, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
:id, :tenant_id, :agent_id, 'draft', NULL, '', :snapshot_id,
|
||||
:config_snapshot, :created_by, :updated_by, :created_at, :updated_at
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": str(uuidv7()),
|
||||
"tenant_id": row["tenant_id"],
|
||||
"agent_id": row["agent_id"],
|
||||
"snapshot_id": row["snapshot_id"],
|
||||
"config_snapshot": row["config_snapshot"],
|
||||
"created_by": row["created_by"],
|
||||
"updated_by": row["updated_by"],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("agent_config_draft_base_snapshot_idx", table_name="agent_config_drafts")
|
||||
op.drop_index("agent_config_draft_tenant_agent_idx", table_name="agent_config_drafts")
|
||||
op.drop_table("agent_config_drafts")
|
||||
@@ -10,6 +10,8 @@ from .account import (
|
||||
)
|
||||
from .agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -154,6 +156,8 @@ __all__ = [
|
||||
"AccountStatus",
|
||||
"AccountTrialAppRecord",
|
||||
"Agent",
|
||||
"AgentConfigDraft",
|
||||
"AgentConfigDraftType",
|
||||
"AgentConfigRevision",
|
||||
"AgentConfigRevisionOperation",
|
||||
"AgentConfigSnapshot",
|
||||
|
||||
+54
-3
@@ -85,6 +85,17 @@ class AgentConfigRevisionOperation(StrEnum):
|
||||
SAVE_TO_ROSTER = "save_to_roster"
|
||||
# Switches the Agent's current published config back to an existing version.
|
||||
RESTORE_VERSION = "restore_version"
|
||||
# Publishes the editable Agent Soul draft as a new immutable version.
|
||||
PUBLISH_DRAFT = "publish_draft"
|
||||
|
||||
|
||||
class AgentConfigDraftType(StrEnum):
|
||||
"""Editable Agent Soul draft workspace type."""
|
||||
|
||||
# Shared Agent Console draft edited by users before publishing.
|
||||
DRAFT = "draft"
|
||||
# Per-editor build draft mutated during debug/build mode.
|
||||
DEBUG_BUILD = "debug_build"
|
||||
|
||||
|
||||
class WorkflowAgentBindingType(StrEnum):
|
||||
@@ -210,6 +221,46 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
|
||||
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
|
||||
|
||||
class AgentConfigDraft(DefaultFieldsMixin, Base):
|
||||
"""Editable Agent Soul draft separated from immutable published snapshots."""
|
||||
|
||||
__tablename__ = "agent_config_drafts"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="agent_config_draft_pkey"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"agent_id",
|
||||
"draft_type",
|
||||
"draft_owner_key",
|
||||
name="agent_config_draft_agent_type_account_unique",
|
||||
),
|
||||
Index("agent_config_draft_tenant_agent_idx", "tenant_id", "agent_id"),
|
||||
Index("agent_config_draft_base_snapshot_idx", "tenant_id", "base_snapshot_id"),
|
||||
)
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
draft_type: Mapped[AgentConfigDraftType] = mapped_column(
|
||||
EnumText(AgentConfigDraftType, length=32), nullable=False
|
||||
)
|
||||
account_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
draft_owner_key: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
base_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
config_snapshot: Mapped[Any] = mapped_column(JSONModelColumn(AgentSoulConfig), nullable=False)
|
||||
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
|
||||
|
||||
@property
|
||||
def config_snapshot_dict(self) -> dict[str, Any]:
|
||||
if not self.config_snapshot:
|
||||
return {}
|
||||
if hasattr(self.config_snapshot, "model_dump"):
|
||||
return self.config_snapshot.model_dump(mode="json")
|
||||
if isinstance(self.config_snapshot, str):
|
||||
return json.loads(self.config_snapshot)
|
||||
return dict(self.config_snapshot)
|
||||
|
||||
|
||||
class AgentConfigSnapshot(DefaultFieldsMixin, Base):
|
||||
"""Immutable Agent Soul snapshot.
|
||||
|
||||
@@ -355,9 +406,9 @@ class AgentRuntimeSession(DefaultFieldsMixin, Base):
|
||||
agent_config_snapshot_id / composition_layer_specs`` columns are set.
|
||||
- Agent App conversations: ``owner_type = conversation``; the
|
||||
``conversation_id`` column is set and the workflow columns stay NULL.
|
||||
Published/web/API runs scope runtime state by ``agent_config_snapshot_id``;
|
||||
console debugger runs may keep it NULL so prompt-only draft saves can reuse
|
||||
the same preview conversation state while executing the latest Agent Soul.
|
||||
Runtime state is scoped by ``agent_config_snapshot_id``. For published
|
||||
web/API runs this points to an immutable AgentConfigSnapshot; for console
|
||||
debugger/build runs it points to the editable AgentConfigDraft row.
|
||||
|
||||
The snapshot is runtime state returned by Agent backend, kept separate from
|
||||
Agent Soul snapshots and workflow node-job config.
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
from typing import Annotated, Any, Final, Literal, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
|
||||
|
||||
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
from graphon.file import FileTransferMethod
|
||||
|
||||
@@ -159,6 +160,22 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
|
||||
# Zip member path listing from standardization (ENG-371): lets infer-tools
|
||||
# show the model strong signals like ``scripts/*.sh`` without unpacking.
|
||||
manifest_files: list[str] | None = None
|
||||
# Versioned drive KV entries that belong to this skill package. The drive
|
||||
# table remains the storage index, but Agent Soul owns which concrete file
|
||||
# ids are visible for a snapshot/version.
|
||||
file_refs: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentSoulFilesConfig(BaseModel):
|
||||
"""Versioned Agent Soul references to drive-backed skills and files.
|
||||
|
||||
File bytes and drive value pointers stay in ``agent_drive_files``. This
|
||||
section records which drive keys belong to one Agent Soul snapshot so version
|
||||
restore/copy/runtime use the same skills/files view the user published.
|
||||
"""
|
||||
|
||||
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
|
||||
files: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentPermissionConfig(BaseModel):
|
||||
@@ -236,17 +253,161 @@ class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
inferred_from: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
|
||||
class AgentKnowledgeDatasetConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeQueryConfig(AgentFlexibleConfig):
|
||||
query: str | None = None
|
||||
class AgentKnowledgeQueryConfig(BaseModel):
|
||||
"""Per-set query policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
``value`` while ``generated_query`` leaves that value empty.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: AgentKnowledgeQueryMode
|
||||
value: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query(self) -> Self:
|
||||
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
|
||||
raise ValueError("knowledge query.value is required for user_query mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
mode: str = Field(min_length=1, max_length=64)
|
||||
completion_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentKnowledgeRerankingModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: str = Field(min_length=1, max_length=255)
|
||||
model: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeWeightedScoreConfig(AgentFlexibleConfig):
|
||||
weight_type: str | None = Field(default=None, max_length=64)
|
||||
vector_setting: dict[str, Any] | None = None
|
||||
keyword_setting: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeRetrievalConfig(BaseModel):
|
||||
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Retrieval settings now live on each knowledge set instead of one shared
|
||||
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
``single`` retrieval with a required model config.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: Literal["single", "multiple"]
|
||||
top_k: int | None = Field(default=None, ge=1)
|
||||
score_threshold: float | None = Field(default=None, ge=0, le=1)
|
||||
score_threshold_enabled: bool | None = None
|
||||
reranking_mode: str = "reranking_model"
|
||||
reranking_enable: bool = True
|
||||
reranking_model: AgentKnowledgeRerankingModelConfig | None = None
|
||||
weights: AgentKnowledgeWeightedScoreConfig | None = None
|
||||
model: AgentKnowledgeModelConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "multiple" and self.top_k is None:
|
||||
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
|
||||
if self.mode == "single" and self.model is None:
|
||||
raise ValueError("knowledge retrieval.model is required for single mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataCondition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
comparison_operator: SupportedComparisonOperator
|
||||
value: ConditionValue = None
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataConditions(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
logical_operator: Literal["and", "or"] = "and"
|
||||
conditions: list[AgentKnowledgeMetadataCondition] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataFilteringConfig(BaseModel):
|
||||
"""Per-set metadata filtering policy.
|
||||
|
||||
The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
model belongs to metadata filtering specifically, while the external API and
|
||||
generated schema keep the historical ``model_config`` field name via alias.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
mode: Literal["disabled", "automatic", "manual"] = "disabled"
|
||||
# Internal name is explicit; wire format remains ``model_config``.
|
||||
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
|
||||
conditions: AgentKnowledgeMetadataConditions | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "automatic" and self.metadata_model_config is None:
|
||||
raise ValueError("metadata_filtering.model_config is required for automatic mode")
|
||||
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
|
||||
raise ValueError("metadata_filtering.conditions is required for manual mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeSetConfig(BaseModel):
|
||||
"""One explicit knowledge set in Agent v2.
|
||||
|
||||
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
|
||||
its datasets plus query, retrieval, and metadata policies. An individual
|
||||
set must contain at least one dataset id even though the overall knowledge
|
||||
section may be empty, which is how callers express "no knowledge layer".
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
datasets: list[AgentKnowledgeDatasetConfig]
|
||||
query: AgentKnowledgeQueryConfig
|
||||
retrieval: AgentKnowledgeRetrievalConfig
|
||||
metadata_filtering: AgentKnowledgeMetadataFilteringConfig = Field(
|
||||
default_factory=AgentKnowledgeMetadataFilteringConfig
|
||||
)
|
||||
|
||||
@field_validator("id", "name")
|
||||
@classmethod
|
||||
def validate_non_blank_identity(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("knowledge set id and name must not be blank")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_datasets(self) -> Self:
|
||||
dataset_ids = [(dataset.id or "").strip() for dataset in self.datasets]
|
||||
if not dataset_ids or any(not dataset_id for dataset_id in dataset_ids):
|
||||
raise ValueError("knowledge set requires at least one dataset id")
|
||||
if len(dataset_ids) != len(set(dataset_ids)):
|
||||
raise ValueError("knowledge set dataset ids must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class AgentHumanContactConfig(AgentFlexibleConfig):
|
||||
@@ -453,9 +614,28 @@ class AgentSoulToolsConfig(BaseModel):
|
||||
|
||||
|
||||
class AgentSoulKnowledgeConfig(BaseModel):
|
||||
datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
query_mode: AgentKnowledgeQueryMode | None = None
|
||||
query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig)
|
||||
"""Top-level Agent v2 knowledge config.
|
||||
|
||||
Agent v2 models knowledge as explicit sets instead of one flat
|
||||
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
|
||||
list means no knowledge layer should be emitted at runtime, while set-name
|
||||
uniqueness stays case-insensitive because runtime selection addresses sets
|
||||
by name.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sets: list[AgentKnowledgeSetConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_sets(self) -> Self:
|
||||
set_ids = [item.id.strip() for item in self.sets]
|
||||
if len(set_ids) != len(set(set_ids)):
|
||||
raise ValueError("knowledge set ids must be unique")
|
||||
set_names = [item.name.strip().lower() for item in self.sets]
|
||||
if len(set_names) != len(set(set_names)):
|
||||
raise ValueError("knowledge set names must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class AgentSoulHumanConfig(BaseModel):
|
||||
@@ -515,6 +695,7 @@ class AgentSoulConfig(BaseModel):
|
||||
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
|
||||
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
|
||||
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
|
||||
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
|
||||
model: AgentSoulModelConfig | None = None
|
||||
app_features: AgentSoulAppFeaturesConfig = Field(default_factory=AgentSoulAppFeaturesConfig)
|
||||
app_variables: list[AppVariableConfig] = Field(default_factory=list)
|
||||
|
||||
@@ -12453,6 +12453,25 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| current_snapshot_id | string | | No |
|
||||
| workflow_node_count | integer | | Yes |
|
||||
|
||||
#### AgentComposerKnowledgeDatasetCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| id | string | | No |
|
||||
| missing | boolean | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### AgentComposerKnowledgeSetCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentComposerKnowledgeDatasetCandidateResponse](#agentcomposerknowledgedatasetcandidateresponse) ] | | No |
|
||||
| description | string | | No |
|
||||
| id | string | | Yes |
|
||||
| missing_dataset_ids | [ string ] | | No |
|
||||
| name | string | | Yes |
|
||||
|
||||
#### AgentComposerNodeJobCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -12468,7 +12487,7 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No |
|
||||
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No |
|
||||
|
||||
#### AgentComposerSoulLockResponse
|
||||
|
||||
@@ -12862,14 +12881,44 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### AgentKnowledgeMetadataCondition
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| comparison_operator | string, <br>**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes |
|
||||
| name | string | | Yes |
|
||||
| value | string<br>[ string ]<br>number | | No |
|
||||
|
||||
#### AgentKnowledgeMetadataConditions
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [AgentKnowledgeMetadataCondition](#agentknowledgemetadatacondition) ] | | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or", <br>**Default:** and | *Enum:* `"and"`, `"or"` | No |
|
||||
|
||||
#### AgentKnowledgeMetadataFilteringConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [AgentKnowledgeMetadataConditions](#agentknowledgemetadataconditions) | | No |
|
||||
| mode | string, <br>**Available values:** "automatic", "disabled", "manual", <br>**Default:** disabled | *Enum:* `"automatic"`, `"disabled"`, `"manual"` | No |
|
||||
| model_config | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
|
||||
|
||||
#### AgentKnowledgeModelConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| completion_params | object | | No |
|
||||
| mode | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### AgentKnowledgeQueryConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| query | string | | No |
|
||||
| score_threshold | number | | No |
|
||||
| score_threshold_enabled | boolean | | No |
|
||||
| top_k | integer | | No |
|
||||
| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes |
|
||||
| value | string | | No |
|
||||
|
||||
#### AgentKnowledgeQueryMode
|
||||
|
||||
@@ -12877,6 +12926,46 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentKnowledgeQueryMode | string | | |
|
||||
|
||||
#### AgentKnowledgeRerankingModelConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| model | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### AgentKnowledgeRetrievalConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| mode | string, <br>**Available values:** "multiple", "single" | *Enum:* `"multiple"`, `"single"` | Yes |
|
||||
| model | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
|
||||
| reranking_enable | boolean, <br>**Default:** true | | No |
|
||||
| reranking_mode | string, <br>**Default:** reranking_model | | No |
|
||||
| reranking_model | [AgentKnowledgeRerankingModelConfig](#agentknowledgererankingmodelconfig) | | No |
|
||||
| score_threshold | number | | No |
|
||||
| top_k | integer | | No |
|
||||
| weights | [AgentKnowledgeWeightedScoreConfig](#agentknowledgeweightedscoreconfig) | | No |
|
||||
|
||||
#### AgentKnowledgeSetConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | Yes |
|
||||
| description | string | | No |
|
||||
| id | string | | Yes |
|
||||
| metadata_filtering | [AgentKnowledgeMetadataFilteringConfig](#agentknowledgemetadatafilteringconfig) | | No |
|
||||
| name | string | | Yes |
|
||||
| query | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | Yes |
|
||||
| retrieval | [AgentKnowledgeRetrievalConfig](#agentknowledgeretrievalconfig) | | Yes |
|
||||
|
||||
#### AgentKnowledgeWeightedScoreConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | object | | No |
|
||||
| vector_setting | object | | No |
|
||||
| weight_type | string | | No |
|
||||
|
||||
#### AgentLogConversationItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13278,9 +13367,7 @@ old Agent tool payloads can be read while new payloads stay explicit.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No |
|
||||
| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No |
|
||||
| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No |
|
||||
|
||||
#### AgentSoulMemoryConfig
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
)
|
||||
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
|
||||
|
||||
MAX_CANDIDATES_PER_LIST = 200
|
||||
|
||||
@@ -139,30 +140,49 @@ def soul_candidates(
|
||||
|
||||
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
|
||||
|
||||
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
|
||||
dataset_ids = list_agent_soul_knowledge_dataset_ids(soul)
|
||||
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
|
||||
knowledge_datasets: list[dict[str, Any]] = []
|
||||
for dataset in soul.knowledge.datasets:
|
||||
if not dataset.id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset.id)
|
||||
knowledge_datasets.append(
|
||||
knowledge_sets: list[dict[str, Any]] = []
|
||||
for knowledge_set in soul.knowledge.sets:
|
||||
missing_dataset_ids: list[str] = []
|
||||
datasets: list[dict[str, Any]] = []
|
||||
for dataset in knowledge_set.datasets:
|
||||
dataset_id = (dataset.id or "").strip()
|
||||
if not dataset_id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset_id)
|
||||
if row is None:
|
||||
missing_dataset_ids.append(dataset_id)
|
||||
datasets.append(
|
||||
{
|
||||
"id": dataset_id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset_id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
}
|
||||
)
|
||||
knowledge_sets.append(
|
||||
{
|
||||
"id": dataset.id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
"id": knowledge_set.id,
|
||||
"name": knowledge_set.name,
|
||||
"description": knowledge_set.description,
|
||||
"datasets": datasets,
|
||||
"missing_dataset_ids": missing_dataset_ids,
|
||||
}
|
||||
)
|
||||
|
||||
human_contacts = [contact.model_dump(exclude_none=True) for contact in soul.human.contacts]
|
||||
skills = [skill.model_dump(exclude_none=True) for skill in soul.files.skills]
|
||||
files = [file_ref.model_dump(exclude_none=True) for file_ref in soul.files.files]
|
||||
dify_tools = workspace_tools_loader()
|
||||
|
||||
lists = {
|
||||
"dify_tools": dify_tools,
|
||||
"cli_tools": cli_tools,
|
||||
"knowledge_datasets": knowledge_datasets,
|
||||
"knowledge_sets": knowledge_sets,
|
||||
"human_contacts": human_contacts,
|
||||
"skills": skills,
|
||||
"files": files,
|
||||
}
|
||||
capped: dict[str, list[dict[str, Any]]] = {}
|
||||
for key, values in lists.items():
|
||||
|
||||
@@ -11,6 +11,8 @@ from libs.helper import to_timestamp
|
||||
from models import Account
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -37,7 +39,12 @@ from services.agent.errors import (
|
||||
AgentVersionNotFoundError,
|
||||
InvalidComposerConfigError,
|
||||
)
|
||||
from services.agent.knowledge_datasets import (
|
||||
get_tenant_knowledge_dataset_rows,
|
||||
list_missing_tenant_knowledge_dataset_ids,
|
||||
)
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
from services.app_service import AppService, CreateAppParams
|
||||
from services.entities.agent_entities import (
|
||||
AgentSoulConfig,
|
||||
@@ -120,6 +127,7 @@ class AgentComposerService:
|
||||
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
|
||||
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
|
||||
|
||||
@@ -259,27 +267,23 @@ class AgentComposerService:
|
||||
|
||||
@classmethod
|
||||
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
agent = cls._require_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=agent.updated_by or agent.created_by,
|
||||
)
|
||||
if not agent:
|
||||
raise AgentNotFoundError()
|
||||
version = cls._require_version(
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
return {
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"agent": cls._serialize_agent(agent),
|
||||
"active_config_snapshot": cls._serialize_version(version),
|
||||
"agent_soul": version.config_snapshot_dict,
|
||||
"draft": cls._serialize_draft(draft),
|
||||
"agent_soul": draft.config_snapshot_dict,
|
||||
"save_options": [
|
||||
ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value,
|
||||
@@ -294,20 +298,11 @@ class AgentComposerService:
|
||||
raise ValueError("Agent App composer endpoint only accepts agent_app variant")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
_validate_composer_payload_for_strategy(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if not agent:
|
||||
agent = Agent(
|
||||
tenant_id=tenant_id,
|
||||
@@ -327,32 +322,20 @@ class AgentComposerService:
|
||||
except IntegrityError as exc:
|
||||
db.session.rollback()
|
||||
raise AgentNameConflictError() from exc
|
||||
|
||||
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
version_note=payload.version_note,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
else:
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
)
|
||||
version = cls._update_current_version(
|
||||
current_snapshot=current_snapshot,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=payload.version_note,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(payload.agent_soul)
|
||||
agent.updated_by = account_id
|
||||
payload.agent_soul = cls._preserve_agent_draft_soul_files(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
agent_soul=payload.agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
|
||||
db.session.commit()
|
||||
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
|
||||
@@ -363,6 +346,167 @@ class AgentComposerService:
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def publish_agent_app_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP:
|
||||
raise AgentNotFoundError()
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=account_id,
|
||||
)
|
||||
agent_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
ComposerConfigValidator.validate_publish_payload(
|
||||
ComposerSavePayload(
|
||||
variant=ComposerVariant.AGENT_APP,
|
||||
agent_soul=agent_soul,
|
||||
save_strategy=ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
|
||||
version_note=version_note,
|
||||
)
|
||||
)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=agent_soul)
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
version_note=version_note,
|
||||
previous_snapshot_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.updated_by = account_id
|
||||
draft.base_snapshot_id = version.id
|
||||
draft.updated_by = account_id
|
||||
db.session.commit()
|
||||
return {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": version.id,
|
||||
"active_config_snapshot": cls._serialize_version(version),
|
||||
"draft": cls._serialize_draft(draft),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def checkout_agent_app_build_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent.scope != AgentScope.ROSTER or agent.source != AgentSource.AGENT_APP:
|
||||
raise AgentNotFoundError()
|
||||
normal_draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=account_id,
|
||||
)
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is not None and not force:
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
if build_draft is None:
|
||||
build_draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
draft_owner_key=account_id,
|
||||
created_by=account_id,
|
||||
)
|
||||
db.session.add(build_draft)
|
||||
build_draft.base_snapshot_id = normal_draft.base_snapshot_id
|
||||
build_draft.config_snapshot = AgentSoulConfig.model_validate(normal_draft.config_snapshot_dict)
|
||||
build_draft.updated_by = account_id
|
||||
db.session.commit()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def load_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def save_agent_app_build_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, payload: ComposerSavePayload
|
||||
) -> dict[str, Any]:
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
_backfill_cli_tool_ids(payload.agent_soul)
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
cls.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload.agent_soul = cls._preserve_agent_draft_soul_files(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
agent_soul=payload.agent_soul,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
build_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
db.session.commit()
|
||||
return cls._serialize_build_draft_state(build_draft)
|
||||
|
||||
@classmethod
|
||||
def apply_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
agent = cls._require_agent(tenant_id=tenant_id, agent_id=agent_id)
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
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),
|
||||
account_id_for_audit=account_id,
|
||||
base_snapshot_id=build_draft.base_snapshot_id,
|
||||
)
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
return {"result": "success", "draft": cls._serialize_draft(normal_draft)}
|
||||
|
||||
@classmethod
|
||||
def discard_agent_app_build_draft(cls, *, tenant_id: str, agent_id: str, account_id: str) -> dict[str, Any]:
|
||||
build_draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id=account_id,
|
||||
)
|
||||
if build_draft is not None:
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
return {"result": "success"}
|
||||
|
||||
@classmethod
|
||||
def collect_validation_findings(
|
||||
cls,
|
||||
@@ -372,29 +516,43 @@ class AgentComposerService:
|
||||
agent_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 soft findings, with DB-backed dataset and drive mention checks."""
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
|
||||
mentioned_ids: set[str] = set()
|
||||
if payload.agent_soul is not None:
|
||||
mentioned_ids |= {
|
||||
mention.ref_id
|
||||
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
|
||||
if mention.kind == MentionKind.KNOWLEDGE
|
||||
}
|
||||
existing_dataset_ids: set[str] | None = None
|
||||
if mentioned_ids:
|
||||
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
|
||||
findings = ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
|
||||
existing_knowledge_set_ids = (
|
||||
{knowledge_set.id for knowledge_set in payload.agent_soul.knowledge.sets}
|
||||
if payload.agent_soul is not None
|
||||
else None
|
||||
)
|
||||
findings = ComposerConfigValidator.collect_soft_findings(
|
||||
payload,
|
||||
existing_knowledge_set_ids=existing_knowledge_set_ids,
|
||||
)
|
||||
if agent_id and payload.agent_soul is not None:
|
||||
findings["warnings"].extend(
|
||||
cls._drive_mention_findings(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prompt=payload.agent_soul.prompt.system_prompt,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
)
|
||||
return findings
|
||||
|
||||
@classmethod
|
||||
def validate_knowledge_datasets(cls, *, tenant_id: str, agent_soul: AgentSoulConfig | None) -> None:
|
||||
"""Hard-validate tenant-scoped knowledge set datasets before saving.
|
||||
|
||||
DTO validators own set shape, duplicate set ids/names, and duplicate
|
||||
dataset ids within one set. This service-level check owns database
|
||||
existence and tenant ownership so invalid or cross-tenant datasets fail
|
||||
before Agent Soul snapshots are persisted.
|
||||
"""
|
||||
if agent_soul is None:
|
||||
return
|
||||
missing_ids = list_missing_tenant_knowledge_dataset_ids(tenant_id=tenant_id, agent_soul=agent_soul)
|
||||
if missing_ids:
|
||||
raise InvalidComposerConfigError(
|
||||
"knowledge_dataset_not_found: knowledge sets reference missing or out-of-scope datasets: "
|
||||
+ ", ".join(missing_ids)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve_bound_agent_id(cls, *, tenant_id: str, app_id: str) -> str | None:
|
||||
"""The Agent App's bound roster agent id, if any (validate-endpoint context)."""
|
||||
@@ -426,14 +584,16 @@ class AgentComposerService:
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prompt: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> list[dict[str, str | None]]:
|
||||
"""Soft warnings for missing drive-backed prompt mentions."""
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
from services.agent_drive_service import decode_drive_mention_ref
|
||||
|
||||
soul_skill_keys = {skill.skill_md_key for skill in agent_soul.files.skills if skill.skill_md_key}
|
||||
soul_file_keys = {file_ref.drive_key for file_ref in agent_soul.files.files if file_ref.drive_key}
|
||||
wanted_keys: dict[str, tuple[str, str]] = {}
|
||||
for mention in parse_prompt_mentions(prompt):
|
||||
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
|
||||
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
|
||||
continue
|
||||
decoded_key = decode_drive_mention_ref(mention.ref_id)
|
||||
@@ -454,6 +614,28 @@ class AgentComposerService:
|
||||
)
|
||||
findings: list[dict[str, str | None]] = []
|
||||
for key, (kind, display) in wanted_keys.items():
|
||||
if kind == MentionKind.SKILL.value and key not in soul_skill_keys:
|
||||
findings.append(
|
||||
{
|
||||
"code": "mention_target_missing",
|
||||
"surface": "agent_soul",
|
||||
"kind": kind,
|
||||
"id": key,
|
||||
"message": f"{kind} '{display}' is not recorded in this Agent Soul version.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if kind == MentionKind.FILE.value and key not in soul_file_keys:
|
||||
findings.append(
|
||||
{
|
||||
"code": "mention_target_missing",
|
||||
"surface": "agent_soul",
|
||||
"kind": kind,
|
||||
"id": key,
|
||||
"message": f"{kind} '{display}' is not recorded in this Agent Soul version.",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if key in existing_keys:
|
||||
continue
|
||||
findings.append(
|
||||
@@ -509,7 +691,7 @@ class AgentComposerService:
|
||||
|
||||
soul_lists, soul_truncated = soul_candidates(
|
||||
agent_soul=agent_soul,
|
||||
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
|
||||
)
|
||||
truncated = truncated or soul_truncated
|
||||
@@ -536,7 +718,7 @@ class AgentComposerService:
|
||||
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
|
||||
soul_lists, truncated = soul_candidates(
|
||||
agent_soul=agent_soul,
|
||||
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
dataset_lookup=lambda ids: get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
|
||||
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
|
||||
)
|
||||
response = ComposerCandidatesResponse(
|
||||
@@ -569,23 +751,17 @@ class AgentComposerService:
|
||||
|
||||
@classmethod
|
||||
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
|
||||
agent = db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if agent is None:
|
||||
return None
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
created_by=agent.updated_by or agent.created_by,
|
||||
)
|
||||
return cls._parse_soul_snapshot(version)
|
||||
return AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
|
||||
@staticmethod
|
||||
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
|
||||
@@ -629,30 +805,6 @@ class AgentComposerService:
|
||||
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
|
||||
return [(variable.name, variable.value_type.value) for variable in variables.variables]
|
||||
|
||||
@staticmethod
|
||||
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
|
||||
"""Tenant-scoped dataset lookup tolerating malformed ids.
|
||||
|
||||
Mention ids come from user-editable prompt text; a non-UUID id can never
|
||||
match a dataset row, so it is simply absent from the result (-> missing/
|
||||
placeholder semantics) instead of breaking the UUID-typed query.
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
valid_ids: list[str] = []
|
||||
for dataset_id in dataset_ids:
|
||||
try:
|
||||
UUID(dataset_id)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
valid_ids.append(dataset_id)
|
||||
if not valid_ids:
|
||||
return {}
|
||||
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
|
||||
return {str(row.id): row for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Workspace Dify Plugin tools, same source as the tool selector.
|
||||
@@ -763,6 +915,11 @@ class AgentComposerService:
|
||||
)
|
||||
binding.node_job_config = node_job
|
||||
if payload.agent_soul is not None and binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
|
||||
payload.agent_soul = cls._preserve_active_soul_files(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=binding.agent_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=binding.agent_id,
|
||||
@@ -863,6 +1020,11 @@ class AgentComposerService:
|
||||
binding = cls._require_binding(binding)
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
payload.agent_soul = cls._preserve_active_soul_files(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=binding.agent_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
current_snapshot = cls._require_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=binding.agent_id,
|
||||
@@ -897,6 +1059,11 @@ class AgentComposerService:
|
||||
binding = cls._require_binding(binding)
|
||||
if not binding.agent_id or payload.agent_soul is None:
|
||||
raise ValueError("agent_id and agent_soul are required")
|
||||
payload.agent_soul = cls._preserve_active_soul_files(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=binding.agent_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=binding.agent_id,
|
||||
@@ -929,6 +1096,12 @@ class AgentComposerService:
|
||||
) -> WorkflowAgentNodeBinding:
|
||||
if payload.agent_soul is None:
|
||||
raise ValueError("agent_soul is required")
|
||||
if binding and binding.agent_id:
|
||||
payload.agent_soul = cls._preserve_active_soul_files(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=binding.agent_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
agent_name = payload.new_agent_name or "Untitled Agent"
|
||||
agent = cls._create_roster_agent_for_composer(
|
||||
tenant_id=tenant_id,
|
||||
@@ -944,6 +1117,15 @@ class AgentComposerService:
|
||||
version_note=payload.version_note,
|
||||
)
|
||||
node_job = payload.node_job or WorkflowNodeJobConfig()
|
||||
if binding and binding.agent_id:
|
||||
cls._copy_agent_drive_rows(
|
||||
tenant_id=tenant_id,
|
||||
source_agent_id=binding.agent_id,
|
||||
target_agent_id=agent.id,
|
||||
account_id=account_id,
|
||||
agent_soul=payload.agent_soul,
|
||||
node_job=node_job,
|
||||
)
|
||||
if not binding:
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
tenant_id=tenant_id,
|
||||
@@ -979,6 +1161,9 @@ class AgentComposerService:
|
||||
version_id=binding.current_snapshot_id,
|
||||
)
|
||||
agent_soul = payload.agent_soul or AgentSoulConfig.model_validate(source_version.config_snapshot_dict)
|
||||
source_soul = AgentSoulConfig.model_validate(source_version.config_snapshot_dict)
|
||||
agent_soul = agent_soul.model_copy(deep=True)
|
||||
agent_soul.files = source_soul.files
|
||||
agent_name = payload.new_agent_name or source_agent.name
|
||||
roster_agent = cls._create_roster_agent_for_composer(
|
||||
tenant_id=tenant_id,
|
||||
@@ -1120,26 +1305,63 @@ class AgentComposerService:
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _preserve_active_soul_files(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str | None,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> AgentSoulConfig:
|
||||
"""Keep drive refs owned by drive APIs when saving non-file composer changes."""
|
||||
|
||||
if not agent_id:
|
||||
return agent_soul
|
||||
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=agent_id)
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return agent_soul
|
||||
version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
if version is None:
|
||||
return agent_soul
|
||||
existing_soul = AgentSoulConfig.model_validate(version.config_snapshot_dict)
|
||||
preserved = agent_soul.model_copy(deep=True)
|
||||
preserved.files = existing_soul.files
|
||||
return preserved
|
||||
|
||||
@classmethod
|
||||
def _preserve_agent_draft_soul_files(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str | None,
|
||||
agent_soul: AgentSoulConfig,
|
||||
draft_type: AgentConfigDraftType = AgentConfigDraftType.DRAFT,
|
||||
account_id: str | None = None,
|
||||
) -> AgentSoulConfig:
|
||||
if not agent_id:
|
||||
return agent_soul
|
||||
draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id,
|
||||
)
|
||||
if draft is not None:
|
||||
existing_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
preserved = agent_soul.model_copy(deep=True)
|
||||
preserved.files = existing_soul.files
|
||||
return preserved
|
||||
return cls._preserve_active_soul_files(tenant_id=tenant_id, agent_id=agent_id, agent_soul=agent_soul)
|
||||
|
||||
@staticmethod
|
||||
def _drive_copy_scopes_from_agent_configs(
|
||||
*, agent_soul: AgentSoulConfig, node_job: WorkflowNodeJobConfig | None = None
|
||||
) -> tuple[set[str], set[str]]:
|
||||
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
|
||||
from services.agent_drive_service import decode_drive_mention_ref
|
||||
|
||||
exact_keys: set[str] = set()
|
||||
prefixes: set[str] = set()
|
||||
|
||||
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
|
||||
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
|
||||
continue
|
||||
drive_key = decode_drive_mention_ref(mention.ref_id)
|
||||
if not drive_key:
|
||||
continue
|
||||
if mention.kind == MentionKind.SKILL and "/" in drive_key:
|
||||
prefixes.add(f"{drive_key.rsplit('/', 1)[0]}/")
|
||||
else:
|
||||
exact_keys.add(drive_key)
|
||||
exact_keys, prefixes = AgentSoulFilesService.drive_copy_scopes(agent_soul=agent_soul)
|
||||
|
||||
if node_job is not None:
|
||||
for file_ref in node_job.metadata.file_refs or []:
|
||||
@@ -1285,6 +1507,143 @@ class AgentComposerService:
|
||||
or 0
|
||||
) + 1
|
||||
|
||||
@classmethod
|
||||
def _get_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent | None:
|
||||
return db.session.scalar(
|
||||
select(Agent)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source == AgentSource.AGENT_APP,
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.order_by(Agent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _require_agent_app_agent(cls, *, tenant_id: str, app_id: str) -> Agent:
|
||||
agent = cls._get_agent_app_agent(tenant_id=tenant_id, app_id=app_id)
|
||||
if agent is None:
|
||||
raise AgentNotFoundError()
|
||||
return agent
|
||||
|
||||
@classmethod
|
||||
def _get_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
) -> AgentConfigDraft | None:
|
||||
stmt = select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.draft_type == draft_type,
|
||||
)
|
||||
if draft_type == AgentConfigDraftType.DEBUG_BUILD:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id == account_id)
|
||||
else:
|
||||
stmt = stmt.where(AgentConfigDraft.account_id.is_(None))
|
||||
return db.session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1))
|
||||
|
||||
@classmethod
|
||||
def _get_or_create_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
created_by: str | None,
|
||||
) -> AgentConfigDraft:
|
||||
draft = cls._get_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id,
|
||||
)
|
||||
if draft is not None:
|
||||
return draft
|
||||
base_snapshot = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
agent_soul = (
|
||||
AgentSoulConfig.model_validate(base_snapshot.config_snapshot_dict)
|
||||
if base_snapshot is not None
|
||||
else AgentSoulConfig()
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
|
||||
draft_owner_key=account_id if draft_type == AgentConfigDraftType.DEBUG_BUILD and account_id else "",
|
||||
base_snapshot_id=base_snapshot.id if base_snapshot else None,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
)
|
||||
db.session.add(draft)
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@classmethod
|
||||
def _save_agent_draft(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
draft_type: AgentConfigDraftType,
|
||||
account_id: str | None,
|
||||
agent_soul: AgentSoulConfig,
|
||||
account_id_for_audit: str,
|
||||
base_snapshot_id: str | None = None,
|
||||
) -> AgentConfigDraft:
|
||||
draft = cls._get_or_create_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=draft_type,
|
||||
account_id=account_id,
|
||||
created_by=account_id_for_audit,
|
||||
)
|
||||
draft.config_snapshot = agent_soul
|
||||
if base_snapshot_id is not None:
|
||||
draft.base_snapshot_id = base_snapshot_id
|
||||
elif draft.base_snapshot_id is None:
|
||||
draft.base_snapshot_id = agent.active_config_snapshot_id
|
||||
draft.updated_by = account_id_for_audit
|
||||
db.session.flush()
|
||||
return draft
|
||||
|
||||
@classmethod
|
||||
def _serialize_draft(cls, draft: AgentConfigDraft | None) -> dict[str, Any] | None:
|
||||
if draft is None:
|
||||
return None
|
||||
return {
|
||||
"id": draft.id,
|
||||
"agent_id": draft.agent_id,
|
||||
"draft_type": draft.draft_type.value,
|
||||
"account_id": draft.account_id,
|
||||
"base_snapshot_id": draft.base_snapshot_id,
|
||||
"created_by": draft.created_by,
|
||||
"updated_by": draft.updated_by,
|
||||
"created_at": to_timestamp(draft.created_at),
|
||||
"updated_at": to_timestamp(draft.updated_at),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _serialize_build_draft_state(cls, draft: AgentConfigDraft) -> dict[str, Any]:
|
||||
return {
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"draft": cls._serialize_draft(draft),
|
||||
"agent_soul": draft.config_snapshot_dict,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_draft_workflow(cls, *, tenant_id: str, app_id: str) -> Workflow:
|
||||
workflow = db.session.scalar(
|
||||
@@ -1479,6 +1838,10 @@ class AgentComposerService:
|
||||
"id": agent.id,
|
||||
"name": agent.name,
|
||||
"description": agent.description,
|
||||
"role": agent.role,
|
||||
"icon_type": agent.icon_type,
|
||||
"icon": agent.icon,
|
||||
"icon_background": agent.icon_background,
|
||||
"scope": agent.scope.value,
|
||||
"status": agent.status.value,
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id,
|
||||
|
||||
@@ -148,15 +148,15 @@ class ComposerConfigValidator:
|
||||
cls,
|
||||
payload: ComposerSavePayload,
|
||||
*,
|
||||
existing_dataset_ids: set[str] | None = None,
|
||||
existing_knowledge_set_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 §5.3/§5.4 soft findings — never block save.
|
||||
|
||||
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set
|
||||
mentions with a placeholder name (0522 consensus) instead of dropping or
|
||||
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
|
||||
deleted datasets surface as placeholders too.
|
||||
rejecting them. With ``existing_knowledge_set_ids`` provided, mentions
|
||||
that no longer exist in the current Agent Soul surface as placeholders too.
|
||||
"""
|
||||
warnings: list[dict[str, Any]] = []
|
||||
placeholders: list[dict[str, str]] = []
|
||||
@@ -188,7 +188,7 @@ class ComposerConfigValidator:
|
||||
resolved = resolver(mention)
|
||||
if mention.kind == MentionKind.KNOWLEDGE:
|
||||
dangling = resolved is None or (
|
||||
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
|
||||
existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids
|
||||
)
|
||||
if dangling:
|
||||
placeholders.append(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
|
||||
"""Return normalized unique knowledge dataset ids in config order.
|
||||
|
||||
Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This
|
||||
helper keeps composer, workflow validation, candidates, and runtime
|
||||
diagnostics aligned on the same normalization rules: strip whitespace, drop
|
||||
blanks, preserve first-seen order, and deduplicate.
|
||||
"""
|
||||
dataset_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
for dataset in knowledge_set.datasets:
|
||||
dataset_id = (dataset.id or "").strip()
|
||||
if not dataset_id or dataset_id in seen:
|
||||
continue
|
||||
seen.add(dataset_id)
|
||||
dataset_ids.append(dataset_id)
|
||||
return dataset_ids
|
||||
|
||||
|
||||
def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
|
||||
"""Return tenant-scoped dataset rows for normalized knowledge dataset ids.
|
||||
|
||||
Knowledge ids come from user-editable config. Malformed ids can never match
|
||||
a dataset row, so they are treated as missing instead of breaking the
|
||||
UUID-typed dataset lookup.
|
||||
"""
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
valid_ids: list[str] = []
|
||||
for dataset_id in dataset_ids:
|
||||
try:
|
||||
UUID(dataset_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
valid_ids.append(dataset_id)
|
||||
|
||||
if not valid_ids:
|
||||
return {}
|
||||
|
||||
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
|
||||
return {str(row.id): row for row in rows}
|
||||
|
||||
|
||||
def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]:
|
||||
"""Return normalized knowledge dataset ids missing from the tenant scope."""
|
||||
if agent_soul is None:
|
||||
return []
|
||||
|
||||
dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul)
|
||||
if not dataset_ids:
|
||||
return []
|
||||
|
||||
rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids)
|
||||
return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows]
|
||||
@@ -6,7 +6,7 @@ Slash-menu insertions are stored inline in the plain-string prompt as tokens:
|
||||
|
||||
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent
|
||||
runtime context. For prompt-owned entities that means Agent Soul lists such as
|
||||
``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists
|
||||
``tools`` / ``knowledge.sets`` / ``human.contacts`` and workflow job lists
|
||||
such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed
|
||||
``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is
|
||||
resolved against ``agent_drive_files`` at runtime. ``label`` is an optional
|
||||
@@ -211,9 +211,9 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
if mention.ref_id in (cli_tool.id, cli_tool.name):
|
||||
return cli_tool.name or cli_tool.id
|
||||
case MentionKind.KNOWLEDGE:
|
||||
for dataset in agent_soul.knowledge.datasets:
|
||||
if mention.ref_id == dataset.id:
|
||||
return dataset.name or dataset.id
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
if mention.ref_id == knowledge_set.id:
|
||||
return knowledge_set.name or knowledge_set.id
|
||||
case MentionKind.HUMAN:
|
||||
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
|
||||
case _:
|
||||
|
||||
@@ -8,6 +8,8 @@ from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import to_timestamp
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -574,7 +576,7 @@ class AgentRosterService:
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
).all()
|
||||
return {agent.app_id: agent for agent in agents if agent.app_id}
|
||||
return {agent.app_id: agent for agent in agents if agent.app_id and agent.id}
|
||||
|
||||
def get_app_backing_agent(self, *, tenant_id: str, app_id: str) -> Agent | None:
|
||||
"""Return the roster Agent that backs the given Agent App, if any."""
|
||||
@@ -836,6 +838,7 @@ class AgentRosterService:
|
||||
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
|
||||
if agent.source == AgentSource.AGENT_APP:
|
||||
return {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
@@ -849,16 +852,37 @@ class AgentRosterService:
|
||||
}
|
||||
|
||||
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
|
||||
"""Return whether the Agent's current active snapshot is a visible published version."""
|
||||
"""Return whether the editable draft matches the active published snapshot."""
|
||||
return self.load_active_config_is_published_by_agent_id(tenant_id=tenant_id, agents=[agent]).get(
|
||||
agent.id,
|
||||
False,
|
||||
)
|
||||
|
||||
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
|
||||
"""Return publish-state flags for the active config snapshots of the given Agents."""
|
||||
"""Return whether each Agent's normal draft is aligned with its active published snapshot."""
|
||||
agents = [agent for agent in agents if agent.id]
|
||||
if not agents:
|
||||
return {}
|
||||
|
||||
published_agent_ids = self._load_published_active_snapshot_agent_ids(tenant_id=tenant_id, agents=agents)
|
||||
return {agent.id: agent.id in published_agent_ids for agent in agents}
|
||||
drafts = self._session.scalars(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id.in_([agent.id for agent in agents]),
|
||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
|
||||
AgentConfigDraft.account_id.is_(None),
|
||||
)
|
||||
).all()
|
||||
drafts_by_agent_id = {draft.agent_id: draft for draft in drafts}
|
||||
result: dict[str, bool] = {}
|
||||
for agent in agents:
|
||||
draft = drafts_by_agent_id.get(agent.id)
|
||||
result[agent.id] = (
|
||||
agent.id in published_agent_ids
|
||||
and bool(agent.active_config_snapshot_id)
|
||||
and (draft is None or draft.base_snapshot_id == agent.active_config_snapshot_id)
|
||||
)
|
||||
return result
|
||||
|
||||
def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
|
||||
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
|
||||
@@ -957,26 +981,37 @@ class AgentRosterService:
|
||||
raise AgentVersionNotFoundError()
|
||||
|
||||
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
|
||||
if agent.active_config_snapshot_id == version.id:
|
||||
return {"result": "success", "active_config_snapshot_id": version.id}
|
||||
|
||||
previous_snapshot_id = agent.active_config_snapshot_id
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(version.config_snapshot)
|
||||
agent.updated_by = account_id
|
||||
self._session.add(
|
||||
AgentConfigRevision(
|
||||
draft = self._session.scalar(
|
||||
select(AgentConfigDraft)
|
||||
.where(
|
||||
AgentConfigDraft.tenant_id == tenant_id,
|
||||
AgentConfigDraft.agent_id == agent_id,
|
||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
|
||||
AgentConfigDraft.account_id.is_(None),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if draft is None:
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
previous_snapshot_id=previous_snapshot_id,
|
||||
current_snapshot_id=version.id,
|
||||
revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id),
|
||||
operation=AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
created_by=account_id,
|
||||
)
|
||||
)
|
||||
self._session.add(draft)
|
||||
draft.base_snapshot_id = version.id
|
||||
draft.config_snapshot = AgentSoulConfig.model_validate(version.config_snapshot_dict)
|
||||
draft.updated_by = account_id
|
||||
agent.updated_by = account_id
|
||||
self._session.commit()
|
||||
return {"result": "success", "active_config_snapshot_id": version.id}
|
||||
return {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": agent.active_config_snapshot_id or version.id,
|
||||
"draft_config_id": draft.id,
|
||||
"restored_version_id": version.id,
|
||||
}
|
||||
|
||||
def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent:
|
||||
stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id)
|
||||
|
||||
@@ -50,6 +50,7 @@ class SkillStandardizeService:
|
||||
self._package = package_service or SkillPackageService()
|
||||
self._drive = drive_service or AgentDriveService()
|
||||
self._tool_files = tool_file_manager or ToolFileManager()
|
||||
self.last_committed_items: list[dict[str, Any]] = []
|
||||
|
||||
def standardize(
|
||||
self,
|
||||
@@ -109,7 +110,7 @@ class SkillStandardizeService:
|
||||
)
|
||||
)
|
||||
|
||||
self._drive.commit(
|
||||
committed_items = self._drive.commit(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
@@ -133,6 +134,7 @@ class SkillStandardizeService:
|
||||
*member_items,
|
||||
],
|
||||
)
|
||||
self.last_committed_items = committed_items
|
||||
|
||||
drive_skill = next(
|
||||
skill
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentDriveFile,
|
||||
)
|
||||
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig, AgentSoulConfig
|
||||
from services.agent.agent_soul_state import agent_soul_has_model
|
||||
from services.agent_drive_service import AgentDriveError, DriveSkillMetadata, normalize_drive_key
|
||||
|
||||
_SKILL_MD_SUFFIX = "/SKILL.md"
|
||||
_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
|
||||
_FILES_PREFIX = "files/"
|
||||
|
||||
|
||||
class AgentSoulFilesService:
|
||||
"""Versioned Agent Soul view of drive-backed skills and files.
|
||||
|
||||
``agent_drive_files`` remains the storage/index for bytes and drive values.
|
||||
``AgentSoulConfig.files`` records the versioned pointers that a specific
|
||||
Agent Soul snapshot owns, so restore/publish/runtime do not accidentally see
|
||||
later drive mutations.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def sync_drive_commit_to_active_soul(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
committed_items: list[dict[str, Any]],
|
||||
) -> AgentConfigSnapshot | None:
|
||||
if not committed_items:
|
||||
return None
|
||||
|
||||
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id))
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return None
|
||||
current_snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
)
|
||||
if current_snapshot is None:
|
||||
return None
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict).model_copy(deep=True)
|
||||
before = agent_soul.files.model_dump(mode="json")
|
||||
for item in committed_items:
|
||||
cls._apply_commit_item(agent_soul=agent_soul, item=item)
|
||||
if agent_soul.files.model_dump(mode="json") == before:
|
||||
return None
|
||||
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
previous_snapshot_id=current_snapshot.id,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.updated_by = account_id
|
||||
db.session.flush()
|
||||
return version
|
||||
|
||||
@classmethod
|
||||
def list_files(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prefix: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
file_keys = [file.drive_key for file in agent_soul.files.files if file.drive_key]
|
||||
if prefix:
|
||||
normalized_prefix = normalize_drive_key(prefix)
|
||||
file_keys = [key for key in file_keys if key.startswith(normalized_prefix)]
|
||||
if not file_keys:
|
||||
return []
|
||||
|
||||
rows = cls._drive_rows_by_key(session=session, tenant_id=tenant_id, agent_id=agent_id, keys=file_keys)
|
||||
items: list[dict[str, Any]] = []
|
||||
for file_ref in agent_soul.files.files:
|
||||
key = file_ref.drive_key
|
||||
if not key or key not in file_keys:
|
||||
continue
|
||||
row = rows.get(key)
|
||||
item = cls._file_item_from_ref(file_ref)
|
||||
item.update(cls._row_item(row) if row is not None else {"key": key, "missing": True})
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
def list_manifest_items(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prefix: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
refs = cls._all_file_refs(agent_soul)
|
||||
if prefix:
|
||||
normalized_prefix = normalize_drive_key(prefix)
|
||||
refs = [ref for ref in refs if (ref.drive_key or "").startswith(normalized_prefix)]
|
||||
keys = [ref.drive_key for ref in refs if ref.drive_key]
|
||||
rows = cls._drive_rows_by_key(session=session, tenant_id=tenant_id, agent_id=agent_id, keys=keys)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for file_ref in refs:
|
||||
key = file_ref.drive_key
|
||||
if not key:
|
||||
continue
|
||||
row = rows.get(key)
|
||||
item = cls._file_item_from_ref(file_ref)
|
||||
item.update(cls._row_item(row) if row is not None else {"key": key, "missing": True})
|
||||
item["file_id"] = file_ref.file_id or file_ref.upload_file_id
|
||||
items.append(item)
|
||||
return sorted(items, key=lambda item: str(item.get("key") or ""))
|
||||
|
||||
@classmethod
|
||||
def list_skills(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
skill_keys = [skill.skill_md_key for skill in agent_soul.files.skills if skill.skill_md_key]
|
||||
archive_keys = [skill.full_archive_key for skill in agent_soul.files.skills if skill.full_archive_key]
|
||||
rows = cls._drive_rows_by_key(
|
||||
session=session, tenant_id=tenant_id, agent_id=agent_id, keys=[*skill_keys, *archive_keys]
|
||||
)
|
||||
items: list[dict[str, Any]] = []
|
||||
for skill in agent_soul.files.skills:
|
||||
if not skill.skill_md_key:
|
||||
continue
|
||||
row = rows.get(skill.skill_md_key)
|
||||
archive_key = skill.full_archive_key if skill.full_archive_key in rows else None
|
||||
skill_md_ref = cls.file_ref_for_key(agent_soul=agent_soul, key=skill.skill_md_key)
|
||||
items.append(
|
||||
{
|
||||
"path": skill.path or cls.skill_path_from_key(skill.skill_md_key),
|
||||
"skill_md_key": skill.skill_md_key,
|
||||
"archive_key": archive_key or skill.full_archive_key,
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"size": row.size if row is not None else None,
|
||||
"mime_type": row.mime_type if row is not None else (skill_md_ref.type if skill_md_ref else None),
|
||||
"hash": row.hash if row is not None else None,
|
||||
"created_at": int(row.created_at.timestamp()) if row is not None and row.created_at else None,
|
||||
"missing": row is None,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
def allowed_drive_keys(cls, agent_soul: AgentSoulConfig) -> set[str]:
|
||||
keys: set[str] = set()
|
||||
for file_ref in agent_soul.files.files:
|
||||
if file_ref.drive_key:
|
||||
keys.add(file_ref.drive_key)
|
||||
for skill in agent_soul.files.skills:
|
||||
if skill.skill_md_key:
|
||||
keys.add(skill.skill_md_key)
|
||||
if skill.full_archive_key:
|
||||
keys.add(skill.full_archive_key)
|
||||
for file_ref in skill.file_refs:
|
||||
if file_ref.drive_key:
|
||||
keys.add(file_ref.drive_key)
|
||||
return keys
|
||||
|
||||
@classmethod
|
||||
def allowed_skill_prefixes(cls, agent_soul: AgentSoulConfig) -> set[str]:
|
||||
prefixes: set[str] = set()
|
||||
for skill in agent_soul.files.skills:
|
||||
path = skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else None)
|
||||
if path:
|
||||
prefixes.add(f"{path}/")
|
||||
return prefixes
|
||||
|
||||
@classmethod
|
||||
def key_allowed_by_soul(cls, *, agent_soul: AgentSoulConfig, key: str) -> bool:
|
||||
normalized_key = normalize_drive_key(key)
|
||||
if normalized_key in cls.allowed_drive_keys(agent_soul):
|
||||
return True
|
||||
return any(normalized_key.startswith(prefix) for prefix in cls.allowed_skill_prefixes(agent_soul))
|
||||
|
||||
@classmethod
|
||||
def file_ref_for_key(cls, *, agent_soul: AgentSoulConfig, key: str) -> AgentFileRefConfig | None:
|
||||
normalized_key = normalize_drive_key(key)
|
||||
for file_ref in agent_soul.files.files:
|
||||
if file_ref.drive_key == normalized_key:
|
||||
return file_ref
|
||||
for skill in agent_soul.files.skills:
|
||||
for file_ref in skill.file_refs:
|
||||
if file_ref.drive_key == normalized_key:
|
||||
return file_ref
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def drive_copy_scopes(cls, *, agent_soul: AgentSoulConfig) -> tuple[set[str], set[str]]:
|
||||
exact_keys = cls.allowed_drive_keys(agent_soul)
|
||||
prefixes = cls.allowed_skill_prefixes(agent_soul)
|
||||
return exact_keys, prefixes
|
||||
|
||||
@staticmethod
|
||||
def active_agent_soul(*, session: Session, tenant_id: str, agent_id: str) -> AgentSoulConfig:
|
||||
snapshot = session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.join(Agent, Agent.active_config_snapshot_id == AgentConfigSnapshot.id)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
if snapshot is None:
|
||||
raise AgentDriveError(
|
||||
"agent_snapshot_not_found",
|
||||
"agent has no active Agent Soul snapshot",
|
||||
status_code=404,
|
||||
)
|
||||
return AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
|
||||
@staticmethod
|
||||
def skill_path_from_key(key: str) -> str:
|
||||
if not key.endswith(_SKILL_MD_SUFFIX):
|
||||
raise AgentDriveError(
|
||||
"invalid_skill_key",
|
||||
"skill rows must use the canonical '<path>/SKILL.md' key",
|
||||
status_code=500,
|
||||
)
|
||||
return key[: -len(_SKILL_MD_SUFFIX)]
|
||||
|
||||
@staticmethod
|
||||
def skill_archive_key(skill_md_key: str) -> str:
|
||||
return f"{AgentSoulFilesService.skill_path_from_key(skill_md_key)}/{_SKILL_ARCHIVE_NAME}"
|
||||
|
||||
@classmethod
|
||||
def _apply_commit_item(cls, *, agent_soul: AgentSoulConfig, item: dict[str, Any]) -> None:
|
||||
key = normalize_drive_key(str(item.get("key") or ""))
|
||||
if item.get("removed"):
|
||||
cls._remove_ref(agent_soul=agent_soul, key=key)
|
||||
return
|
||||
|
||||
if item.get("is_skill"):
|
||||
cls._upsert_skill_ref(agent_soul=agent_soul, key=key, item=item)
|
||||
return
|
||||
if key.startswith(_FILES_PREFIX):
|
||||
cls._upsert_file_ref(agent_soul=agent_soul, key=key, item=item)
|
||||
return
|
||||
cls._upsert_skill_file_ref(agent_soul=agent_soul, key=key, item=item)
|
||||
|
||||
@classmethod
|
||||
def _upsert_skill_ref(cls, *, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
|
||||
metadata = cls._parse_skill_metadata(item.get("skill_metadata"))
|
||||
path = cls.skill_path_from_key(key)
|
||||
ref = AgentSkillRefConfig(
|
||||
id=path,
|
||||
name=metadata.name,
|
||||
description=metadata.description,
|
||||
file_id=str(item.get("file_id") or ""),
|
||||
path=path,
|
||||
skill_md_key=key,
|
||||
skill_md_file_id=str(item.get("file_id") or ""),
|
||||
full_archive_key=cls.skill_archive_key(key),
|
||||
manifest_files=metadata.manifest_files,
|
||||
)
|
||||
existing_ref = next(
|
||||
(
|
||||
existing
|
||||
for existing in agent_soul.files.skills
|
||||
if existing.skill_md_key == key or existing.path == path
|
||||
),
|
||||
None,
|
||||
)
|
||||
file_refs = list(existing_ref.file_refs) if existing_ref else []
|
||||
file_refs = [file_ref for file_ref in file_refs if file_ref.drive_key != key]
|
||||
file_refs.append(cls._file_ref_from_item(key=key, item=item, name="SKILL.md"))
|
||||
archive_key = cls.skill_archive_key(key)
|
||||
archive_ref = next((file_ref for file_ref in file_refs if file_ref.drive_key == archive_key), None)
|
||||
if archive_ref:
|
||||
ref.full_archive_file_id = archive_ref.file_id
|
||||
ref.file_refs = sorted(file_refs, key=lambda value: value.drive_key or value.name)
|
||||
skills = [
|
||||
existing
|
||||
for existing in agent_soul.files.skills
|
||||
if existing.skill_md_key != key and existing.path != path
|
||||
]
|
||||
skills.append(ref)
|
||||
skills.sort(key=lambda value: value.path or value.skill_md_key or "")
|
||||
agent_soul.files.skills = skills
|
||||
|
||||
@staticmethod
|
||||
def _upsert_file_ref(*, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
|
||||
name = key.removeprefix(_FILES_PREFIX) or key.rsplit("/", 1)[-1]
|
||||
file_id = str(item.get("file_id") or "")
|
||||
ref = AgentFileRefConfig(
|
||||
id=key,
|
||||
file_id=file_id,
|
||||
upload_file_id=file_id if item.get("file_kind") == "upload_file" else None,
|
||||
name=name,
|
||||
type=str(item.get("mime_type") or ""),
|
||||
transfer_method=str(item.get("file_kind") or ""),
|
||||
drive_key=key,
|
||||
size=item.get("size"),
|
||||
hash=item.get("hash"),
|
||||
)
|
||||
files = [existing for existing in agent_soul.files.files if existing.drive_key != key]
|
||||
files.append(ref)
|
||||
files.sort(key=lambda value: value.drive_key or value.name)
|
||||
agent_soul.files.files = files
|
||||
|
||||
@classmethod
|
||||
def _upsert_skill_file_ref(cls, *, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
|
||||
path = key.split("/", 1)[0]
|
||||
if not path:
|
||||
return
|
||||
updated: list[AgentSkillRefConfig] = []
|
||||
changed = False
|
||||
for skill in agent_soul.files.skills:
|
||||
skill_path = skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")
|
||||
if skill_path != path:
|
||||
updated.append(skill)
|
||||
continue
|
||||
file_ref = cls._file_ref_from_item(key=key, item=item)
|
||||
file_refs = [existing for existing in skill.file_refs if existing.drive_key != key]
|
||||
file_refs.append(file_ref)
|
||||
replacement = skill.model_copy(
|
||||
update={"file_refs": sorted(file_refs, key=lambda value: value.drive_key or value.name)}
|
||||
)
|
||||
if key.endswith(f"/{_SKILL_ARCHIVE_NAME}"):
|
||||
replacement = replacement.model_copy(
|
||||
update={
|
||||
"full_archive_key": key,
|
||||
"full_archive_file_id": file_ref.file_id,
|
||||
}
|
||||
)
|
||||
updated.append(replacement)
|
||||
changed = True
|
||||
if changed:
|
||||
agent_soul.files.skills = updated
|
||||
|
||||
@staticmethod
|
||||
def _file_ref_from_item(*, key: str, item: dict[str, Any], name: str | None = None) -> AgentFileRefConfig:
|
||||
file_id = str(item.get("file_id") or "")
|
||||
return AgentFileRefConfig(
|
||||
id=key,
|
||||
file_id=file_id,
|
||||
upload_file_id=file_id if item.get("file_kind") == "upload_file" else None,
|
||||
name=name or key.rsplit("/", 1)[-1],
|
||||
type=str(item.get("mime_type") or ""),
|
||||
transfer_method=str(item.get("file_kind") or ""),
|
||||
drive_key=key,
|
||||
size=item.get("size"),
|
||||
hash=item.get("hash"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _remove_ref(cls, *, agent_soul: AgentSoulConfig, key: str) -> None:
|
||||
agent_soul.files.files = [file_ref for file_ref in agent_soul.files.files if file_ref.drive_key != key]
|
||||
if key.endswith(_SKILL_MD_SUFFIX):
|
||||
path = cls.skill_path_from_key(key)
|
||||
agent_soul.files.skills = [
|
||||
skill for skill in agent_soul.files.skills if skill.skill_md_key != key and skill.path != path
|
||||
]
|
||||
return
|
||||
if key.endswith(f"/{_SKILL_ARCHIVE_NAME}"):
|
||||
agent_soul.files.skills = [
|
||||
skill.model_copy(
|
||||
update={
|
||||
"full_archive_key": None,
|
||||
"full_archive_file_id": None,
|
||||
"file_refs": [file_ref for file_ref in skill.file_refs if file_ref.drive_key != key],
|
||||
}
|
||||
)
|
||||
if skill.full_archive_key == key
|
||||
else skill
|
||||
for skill in agent_soul.files.skills
|
||||
]
|
||||
return
|
||||
path = key.split("/", 1)[0]
|
||||
if path:
|
||||
agent_soul.files.skills = [
|
||||
skill.model_copy(
|
||||
update={"file_refs": [file_ref for file_ref in skill.file_refs if file_ref.drive_key != key]}
|
||||
)
|
||||
if (skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")) == path
|
||||
else skill
|
||||
for skill in agent_soul.files.skills
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _all_file_refs(agent_soul: AgentSoulConfig) -> list[AgentFileRefConfig]:
|
||||
refs = list(agent_soul.files.files)
|
||||
for skill in agent_soul.files.skills:
|
||||
refs.extend(skill.file_refs)
|
||||
return refs
|
||||
|
||||
@staticmethod
|
||||
def _parse_skill_metadata(raw_metadata: Any) -> DriveSkillMetadata:
|
||||
if isinstance(raw_metadata, DriveSkillMetadata):
|
||||
return raw_metadata
|
||||
if isinstance(raw_metadata, str):
|
||||
return DriveSkillMetadata.model_validate(json.loads(raw_metadata))
|
||||
return DriveSkillMetadata.model_validate(raw_metadata or {})
|
||||
|
||||
@staticmethod
|
||||
def _drive_rows_by_key(
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
keys: list[str],
|
||||
) -> dict[str, AgentDriveFile]:
|
||||
if not keys:
|
||||
return {}
|
||||
return {
|
||||
row.key: row
|
||||
for row in session.scalars(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key.in_(sorted(set(keys))),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _row_item(row: AgentDriveFile | None) -> dict[str, Any]:
|
||||
if row is None:
|
||||
return {}
|
||||
return {
|
||||
"key": row.key,
|
||||
"size": row.size,
|
||||
"hash": row.hash,
|
||||
"mime_type": row.mime_type,
|
||||
"file_kind": row.file_kind.value,
|
||||
"is_skill": row.is_skill,
|
||||
"skill_metadata": row.skill_metadata,
|
||||
"created_at": int(row.created_at.timestamp()) if row.created_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _file_item_from_ref(file_ref: AgentFileRefConfig) -> dict[str, Any]:
|
||||
key = file_ref.drive_key or file_ref.name
|
||||
return {
|
||||
"key": key,
|
||||
"name": file_ref.name,
|
||||
"mime_type": file_ref.type,
|
||||
"file_kind": file_ref.transfer_method,
|
||||
"is_skill": False,
|
||||
"size": file_ref.get("size"),
|
||||
"hash": file_ref.get("hash"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _create_config_version(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
previous_snapshot_id: str,
|
||||
) -> AgentConfigSnapshot:
|
||||
next_version = (
|
||||
db.session.scalar(
|
||||
select(func.max(AgentConfigSnapshot.version)).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) + 1
|
||||
version = AgentConfigSnapshot(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
version=next_version,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=account_id,
|
||||
)
|
||||
db.session.add(version)
|
||||
db.session.flush()
|
||||
revision = AgentConfigRevision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
previous_snapshot_id=previous_snapshot_id,
|
||||
current_snapshot_id=version.id,
|
||||
revision=cls._next_revision(tenant_id=tenant_id, agent_id=agent_id),
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
created_by=account_id,
|
||||
)
|
||||
db.session.add(revision)
|
||||
db.session.flush()
|
||||
return version
|
||||
|
||||
@staticmethod
|
||||
def _next_revision(*, tenant_id: str, agent_id: str) -> int:
|
||||
return (
|
||||
db.session.scalar(
|
||||
select(func.max(AgentConfigRevision.revision)).where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) + 1
|
||||
@@ -9,10 +9,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator
|
||||
from models import ToolFile, UploadFile
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigSnapshot,
|
||||
AgentDriveFile,
|
||||
AgentDriveFileKind,
|
||||
AgentScope,
|
||||
AgentStatus,
|
||||
WorkflowAgentBindingType,
|
||||
@@ -21,6 +22,7 @@ from models.agent import (
|
||||
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
|
||||
from models.workflow import Workflow
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
from services.entities.agent_entities import (
|
||||
ComposerSavePayload,
|
||||
ComposerSaveStrategy,
|
||||
@@ -39,10 +41,10 @@ class WorkflowAgentPublishService:
|
||||
|
||||
@classmethod
|
||||
def project_draft_bindings_to_graph(cls, *, session: Session, draft_workflow: Workflow) -> dict[str, Any]:
|
||||
"""Return draft graph with persisted Agent node job config projected into node data.
|
||||
"""Return draft graph with persisted Agent binding fields projected into node data.
|
||||
|
||||
Workflow draft graph is the front-end's editing source of truth, while
|
||||
runtime/publish reads WorkflowAgentNodeBinding.node_job_config. This
|
||||
runtime/publish reads WorkflowAgentNodeBinding. This
|
||||
response-only projection keeps reads aligned without writing binding
|
||||
details back into the stored graph JSON.
|
||||
"""
|
||||
@@ -64,6 +66,18 @@ class WorkflowAgentPublishService:
|
||||
node_data = agent_nodes.get(binding.node_id)
|
||||
if not isinstance(node_data, dict):
|
||||
continue
|
||||
graph_binding = node_data.get(cls._AGENT_BINDING_KEY)
|
||||
is_pending_inline_graph_binding = (
|
||||
isinstance(graph_binding, Mapping)
|
||||
and graph_binding.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value
|
||||
and (not graph_binding.get("agent_id") or not graph_binding.get("current_snapshot_id"))
|
||||
)
|
||||
if not is_pending_inline_graph_binding or binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT:
|
||||
node_data[cls._AGENT_BINDING_KEY] = {
|
||||
"binding_type": binding.binding_type.value,
|
||||
"agent_id": binding.agent_id,
|
||||
"current_snapshot_id": binding.current_snapshot_id,
|
||||
}
|
||||
node_job = WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
|
||||
if node_job.workflow_prompt is not None:
|
||||
node_data[cls._AGENT_TASK_KEY] = node_job.workflow_prompt
|
||||
@@ -169,6 +183,8 @@ class WorkflowAgentPublishService:
|
||||
from services.agent_drive_service import decode_drive_mention_ref
|
||||
|
||||
wanted_keys: dict[str, tuple[str, str]] = {}
|
||||
soul_skill_keys = {skill.skill_md_key for skill in agent_soul.files.skills if skill.skill_md_key}
|
||||
soul_file_keys = AgentSoulFilesService.allowed_drive_keys(agent_soul=agent_soul) - soul_skill_keys
|
||||
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt):
|
||||
if mention.kind not in {MentionKind.SKILL, MentionKind.FILE}:
|
||||
continue
|
||||
@@ -177,29 +193,67 @@ class WorkflowAgentPublishService:
|
||||
continue
|
||||
code = "skill_ref_dangling" if mention.kind == MentionKind.SKILL else "file_ref_dangling"
|
||||
wanted_keys[drive_key] = (code, mention.label or drive_key)
|
||||
if not wanted_keys or not binding.agent_id:
|
||||
if not binding.agent_id:
|
||||
return
|
||||
|
||||
existing_keys = set(
|
||||
session.scalars(
|
||||
select(AgentDriveFile.key).where(
|
||||
AgentDriveFile.tenant_id == binding.tenant_id,
|
||||
AgentDriveFile.agent_id == binding.agent_id,
|
||||
AgentDriveFile.key.in_(sorted(wanted_keys)),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
declared_keys, _ = AgentSoulFilesService.drive_copy_scopes(agent_soul=agent_soul)
|
||||
check_keys = sorted(set(wanted_keys) | declared_keys)
|
||||
if not check_keys:
|
||||
return
|
||||
existing_keys = {
|
||||
key
|
||||
for key in check_keys
|
||||
if cls._soul_file_ref_exists(session=session, tenant_id=binding.tenant_id, agent_soul=agent_soul, key=key)
|
||||
}
|
||||
messages: list[str] = []
|
||||
for key, (code, display) in wanted_keys.items():
|
||||
if code == "skill_ref_dangling" and key not in soul_skill_keys:
|
||||
messages.append(f"{code}: skill '{display}' is not recorded in this Agent Soul version.")
|
||||
continue
|
||||
if code == "file_ref_dangling" and key not in soul_file_keys:
|
||||
messages.append(f"{code}: file '{display}' is not recorded in this Agent Soul version.")
|
||||
continue
|
||||
if key in existing_keys:
|
||||
continue
|
||||
kind = "skill" if code == "skill_ref_dangling" else "file"
|
||||
messages.append(f"{code}: {kind} '{display}' has no drive entry for key '{key}'.")
|
||||
for key in declared_keys:
|
||||
if key not in existing_keys:
|
||||
messages.append(f"drive_ref_dangling: Agent Soul drive ref '{key}' has no backing drive entry.")
|
||||
if messages:
|
||||
raise WorkflowAgentNodeValidationError(
|
||||
f"Workflow Agent node {binding.node_id} has invalid Agent Soul drive refs: {'; '.join(messages)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _soul_file_ref_exists(
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
key: str,
|
||||
) -> bool:
|
||||
file_ref = AgentSoulFilesService.file_ref_for_key(agent_soul=agent_soul, key=key)
|
||||
if file_ref is None:
|
||||
return False
|
||||
file_id = file_ref.file_id or file_ref.upload_file_id
|
||||
if not file_id:
|
||||
return False
|
||||
raw_kind = file_ref.transfer_method or ("upload_file" if file_ref.upload_file_id else None)
|
||||
try:
|
||||
file_kind = AgentDriveFileKind(str(raw_kind))
|
||||
except ValueError:
|
||||
return False
|
||||
if file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
return (
|
||||
session.scalar(select(ToolFile.id).where(ToolFile.tenant_id == tenant_id, ToolFile.id == file_id))
|
||||
is not None
|
||||
)
|
||||
return (
|
||||
session.scalar(select(UploadFile.id).where(UploadFile.tenant_id == tenant_id, UploadFile.id == file_id))
|
||||
is not None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def sync_agent_bindings_for_draft(
|
||||
cls,
|
||||
@@ -231,6 +285,11 @@ class WorkflowAgentPublishService:
|
||||
continue
|
||||
if not isinstance(binding_payload, Mapping):
|
||||
raise ValueError(f"Workflow Agent node {node_id} has invalid agent_binding.")
|
||||
if (
|
||||
binding_payload.get("binding_type") == WorkflowAgentBindingType.INLINE_AGENT.value
|
||||
and (not binding_payload.get("agent_id") or not binding_payload.get("current_snapshot_id"))
|
||||
):
|
||||
continue
|
||||
cls._sync_agent_binding_for_node(
|
||||
session=session,
|
||||
draft_workflow=draft_workflow,
|
||||
|
||||
@@ -825,6 +825,24 @@ class AgentDriveService:
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def resolve_download_url_for_ref(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
file_kind: AgentDriveFileKind,
|
||||
file_id: str,
|
||||
for_external: bool = False,
|
||||
as_attachment: bool = False,
|
||||
) -> str | None:
|
||||
return cls._resolve_download_url(
|
||||
tenant_id=tenant_id,
|
||||
file_kind=file_kind,
|
||||
file_id=file_id,
|
||||
for_external=for_external,
|
||||
as_attachment=as_attachment,
|
||||
)
|
||||
|
||||
# ── console drive inspector (ENG-624) ────────────────────────────────────
|
||||
|
||||
# SKILL.md is the primary preview use case; 64 KiB covers it with headroom
|
||||
@@ -844,15 +862,30 @@ class AgentDriveService:
|
||||
return row
|
||||
|
||||
def _storage_key_for_row(self, session: Session, *, tenant_id: str, row: AgentDriveFile) -> str:
|
||||
if row.file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
return self._storage_key_for_ref(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=row.file_kind,
|
||||
file_id=row.file_id,
|
||||
)
|
||||
|
||||
def _storage_key_for_ref(
|
||||
self,
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
file_kind: AgentDriveFileKind,
|
||||
file_id: str,
|
||||
) -> str:
|
||||
if file_kind == AgentDriveFileKind.TOOL_FILE:
|
||||
tool_file = session.scalar(
|
||||
select(ToolFile).where(ToolFile.id == row.file_id, ToolFile.tenant_id == tenant_id)
|
||||
select(ToolFile).where(ToolFile.id == file_id, ToolFile.tenant_id == tenant_id)
|
||||
)
|
||||
if tool_file is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
|
||||
return tool_file.file_key
|
||||
upload_file = session.scalar(
|
||||
select(UploadFile).where(UploadFile.id == row.file_id, UploadFile.tenant_id == tenant_id)
|
||||
select(UploadFile).where(UploadFile.id == file_id, UploadFile.tenant_id == tenant_id)
|
||||
)
|
||||
if upload_file is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value record is missing", status_code=404)
|
||||
@@ -889,6 +922,47 @@ class AgentDriveService:
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": row.key, "size": size, "truncated": truncated, "binary": False, "text": text}
|
||||
|
||||
def preview_file_ref(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
key: str,
|
||||
file_kind: AgentDriveFileKind,
|
||||
file_id: str,
|
||||
size: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Preview a concrete versioned file ref recorded in Agent Soul."""
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
storage_key = self._storage_key_for_ref(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
file_kind=file_kind,
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
data = bytearray()
|
||||
for chunk in storage.load_stream(storage_key):
|
||||
data.extend(chunk)
|
||||
if len(data) > self.PREVIEW_MAX_BYTES:
|
||||
break
|
||||
truncated = len(data) > self.PREVIEW_MAX_BYTES
|
||||
sample = bytes(data[: self.PREVIEW_MAX_BYTES])
|
||||
if b"\x00" in sample:
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
try:
|
||||
text = sample.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
if truncated:
|
||||
try:
|
||||
text = sample[:-3].decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError:
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
else:
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": True, "text": None}
|
||||
return {"key": key, "size": size, "truncated": truncated, "binary": False, "text": text}
|
||||
|
||||
def download_url(self, *, tenant_id: str, agent_id: str, key: str) -> str:
|
||||
"""External signed URL for a browser download of one drive value."""
|
||||
with session_factory.create_session() as session:
|
||||
@@ -905,6 +979,27 @@ class AgentDriveService:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
|
||||
return url
|
||||
|
||||
def download_url_for_ref(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
file_kind: AgentDriveFileKind,
|
||||
file_id: str,
|
||||
) -> str:
|
||||
with session_factory.create_session() as session:
|
||||
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
url = self._resolve_download_url(
|
||||
tenant_id=tenant_id,
|
||||
file_kind=file_kind,
|
||||
file_id=file_id,
|
||||
for_external=True,
|
||||
as_attachment=True,
|
||||
)
|
||||
if url is None:
|
||||
raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404)
|
||||
return url
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveError",
|
||||
|
||||
@@ -173,14 +173,6 @@ class InnerKnowledgeRetrieveRequest(BaseModel):
|
||||
class InnerKnowledgeRetrieveUsage(ResponseModel):
|
||||
"""Serialized LLM usage payload returned by dataset retrieval."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
extra="forbid",
|
||||
populate_by_name=True,
|
||||
serialize_by_alias=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
total_tokens: int
|
||||
|
||||
@@ -162,8 +162,15 @@ def test_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _run_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -174,7 +181,7 @@ def test_request_builder_adds_knowledge_layer_when_configured():
|
||||
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
|
||||
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config)
|
||||
assert knowledge_config.dataset_ids == ["dataset-1"]
|
||||
assert knowledge_config.sets[0].dataset_ids == ["dataset-1"]
|
||||
|
||||
|
||||
def test_request_builder_can_delete_on_exit_for_cleanup_paths():
|
||||
@@ -386,8 +393,15 @@ def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
|
||||
run_input = _agent_app_input()
|
||||
run_input.knowledge = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"dataset_ids": ["dataset-1", "dataset-2"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 2},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 2},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -398,7 +412,7 @@ def test_agent_app_request_builder_adds_knowledge_layer_when_configured():
|
||||
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].type == DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
|
||||
assert layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].deps == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
knowledge_config = cast(DifyKnowledgeBaseLayerConfig, layers[DIFY_KNOWLEDGE_BASE_LAYER_ID].config)
|
||||
assert knowledge_config.dataset_ids == ["dataset-1", "dataset-2"]
|
||||
assert knowledge_config.sets[0].dataset_ids == ["dataset-1", "dataset-2"]
|
||||
|
||||
|
||||
# ── ENG-635 / ENG-638: ask_human layer injection + deferred_tool_results ─────
|
||||
|
||||
@@ -149,3 +149,55 @@ def test_generate_specs_is_idempotent(tmp_path):
|
||||
assert [path.name for path in first_paths] == [path.name for path in second_paths]
|
||||
for first_path, second_path in zip(first_paths, second_paths):
|
||||
assert first_path.read_text(encoding="utf-8") == second_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_generate_specs_include_agent_v2_knowledge_set_schema_and_query_enums(tmp_path):
|
||||
module = _load_generate_swagger_specs_module()
|
||||
|
||||
written_paths = module.generate_specs(tmp_path)
|
||||
console_path = next(path for path in written_paths if path.name == "console-openapi.json")
|
||||
payload = json.loads(console_path.read_text(encoding="utf-8"))
|
||||
schemas = payload["components"]["schemas"]
|
||||
|
||||
assert "AgentKnowledgeSetConfig" in schemas
|
||||
assert schemas["AgentSoulKnowledgeConfig"]["properties"]["sets"]["items"]["$ref"] == (
|
||||
"#/components/schemas/AgentKnowledgeSetConfig"
|
||||
)
|
||||
assert schemas["AgentKnowledgeQueryMode"]["enum"] == ["generated_query", "user_query"]
|
||||
|
||||
|
||||
def test_checked_in_agent_v2_knowledge_openapi_and_generated_contracts_are_in_sync():
|
||||
api_dir = Path(__file__).resolve().parents[3]
|
||||
repo_root = api_dir.parent
|
||||
|
||||
markdown = (api_dir / "openapi" / "markdown" / "console-openapi.md").read_text(encoding="utf-8")
|
||||
agent_types = (
|
||||
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "types.gen.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
apps_types = (
|
||||
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "types.gen.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
agent_zod = (
|
||||
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "agent" / "zod.gen.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
apps_zod = (
|
||||
repo_root / "packages" / "contracts" / "generated" / "api" / "console" / "apps" / "zod.gen.ts"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "#### AgentKnowledgeSetConfig" in markdown
|
||||
assert "#### AgentSoulKnowledgeConfig" in markdown
|
||||
assert "#### AgentKnowledgeQueryMode" in markdown
|
||||
|
||||
for content in (agent_types, apps_types):
|
||||
assert "export type AgentKnowledgeSetConfig = {" in content
|
||||
assert "export type AgentSoulKnowledgeConfig = {" in content
|
||||
assert "AgentKnowledgeQueryMode" in content
|
||||
assert "generated_query" in content
|
||||
assert "user_query" in content
|
||||
|
||||
for content in (agent_zod, apps_zod):
|
||||
assert "export const zAgentKnowledgeSetConfig = z.object({" in content
|
||||
assert "export const zAgentSoulKnowledgeConfig = z.object({" in content
|
||||
assert "zAgentKnowledgeQueryMode = z.enum([" in content
|
||||
assert "generated_query" in content
|
||||
assert "user_query" in content
|
||||
|
||||
@@ -28,11 +28,15 @@ from controllers.console.agent.roster import (
|
||||
AgentAppApi,
|
||||
AgentAppCopyApi,
|
||||
AgentAppListApi,
|
||||
AgentBuildDraftApi,
|
||||
AgentBuildDraftApplyApi,
|
||||
AgentBuildDraftCheckoutApi,
|
||||
AgentDebugConversationRefreshApi,
|
||||
AgentInviteOptionsApi,
|
||||
AgentLogMessagesApi,
|
||||
AgentLogsApi,
|
||||
AgentLogSourcesApi,
|
||||
AgentPublishApi,
|
||||
AgentRosterVersionDetailApi,
|
||||
AgentRosterVersionRestoreApi,
|
||||
AgentRosterVersionsApi,
|
||||
@@ -151,6 +155,10 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
||||
"/agent/<uuid:agent_id>/composer/candidates",
|
||||
"/agent/<uuid:agent_id>/features",
|
||||
"/agent/<uuid:agent_id>/copy",
|
||||
"/agent/<uuid:agent_id>/publish",
|
||||
"/agent/<uuid:agent_id>/build-draft/checkout",
|
||||
"/agent/<uuid:agent_id>/build-draft",
|
||||
"/agent/<uuid:agent_id>/build-draft/apply",
|
||||
"/agent/<uuid:agent_id>/referencing-workflows",
|
||||
"/agent/<uuid:agent_id>/drive/files",
|
||||
"/agent/<uuid:agent_id>/sandbox/files",
|
||||
@@ -520,6 +528,129 @@ def test_agent_debug_conversation_refresh_uses_current_user(
|
||||
}
|
||||
|
||||
|
||||
def test_agent_publish_and_build_draft_routes_call_composer_service(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
||||
) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
current_user = SimpleNamespace(id=account_id)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def publish_agent_app_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["publish"] = kwargs
|
||||
return {"result": "success", "active_config_snapshot_id": "version-1"}
|
||||
|
||||
def checkout_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["checkout"] = kwargs
|
||||
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
||||
|
||||
def load_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["load"] = kwargs
|
||||
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
||||
|
||||
def save_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["save"] = kwargs
|
||||
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
||||
|
||||
def apply_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["apply"] = kwargs
|
||||
return {"result": "success", "draft": {"id": "draft-1"}}
|
||||
|
||||
def discard_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
||||
captured["discard"] = kwargs
|
||||
return {"result": "success"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"publish_agent_app_draft",
|
||||
publish_agent_app_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"checkout_agent_app_build_draft",
|
||||
checkout_agent_app_build_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"load_agent_app_build_draft",
|
||||
load_agent_app_build_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"save_agent_app_build_draft",
|
||||
save_agent_app_build_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"apply_agent_app_build_draft",
|
||||
apply_agent_app_build_draft,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentComposerService,
|
||||
"discard_agent_app_build_draft",
|
||||
discard_agent_app_build_draft,
|
||||
)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/publish",
|
||||
json={"version_note": "publish v1"},
|
||||
):
|
||||
published = unwrap(AgentPublishApi.post)(AgentPublishApi(), "tenant-1", current_user, agent_id)
|
||||
assert published["active_config_snapshot_id"] == "version-1"
|
||||
assert captured["publish"] == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": agent_id,
|
||||
"account_id": account_id,
|
||||
"version_note": "publish v1",
|
||||
}
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/checkout",
|
||||
json={"force": True},
|
||||
):
|
||||
checked_out = unwrap(AgentBuildDraftCheckoutApi.post)(
|
||||
AgentBuildDraftCheckoutApi(), "tenant-1", current_user, agent_id
|
||||
)
|
||||
assert checked_out["draft"]["id"] == "build-draft-1"
|
||||
assert captured["checkout"] == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": agent_id,
|
||||
"account_id": account_id,
|
||||
"force": True,
|
||||
}
|
||||
|
||||
with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft"):
|
||||
loaded = unwrap(AgentBuildDraftApi.get)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
|
||||
assert loaded["draft"]["id"] == "build-draft-1"
|
||||
assert captured["load"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
|
||||
json={"variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": {}},
|
||||
):
|
||||
saved = unwrap(AgentBuildDraftApi.put)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
|
||||
assert saved["draft"]["id"] == "build-draft-1"
|
||||
assert captured["save"]["tenant_id"] == "tenant-1"
|
||||
assert captured["save"]["agent_id"] == agent_id
|
||||
assert captured["save"]["account_id"] == account_id
|
||||
assert captured["save"]["payload"].variant == ComposerVariant.AGENT_APP
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/apply",
|
||||
method="POST",
|
||||
):
|
||||
applied = unwrap(AgentBuildDraftApplyApi.post)(AgentBuildDraftApplyApi(), "tenant-1", current_user, agent_id)
|
||||
assert applied == {"result": "success", "draft": {"id": "draft-1"}}
|
||||
assert captured["apply"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
|
||||
method="DELETE",
|
||||
):
|
||||
discarded = unwrap(AgentBuildDraftApi.delete)(AgentBuildDraftApi(), "tenant-1", current_user, agent_id)
|
||||
assert discarded == {"result": "success"}
|
||||
assert captured["discard"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
||||
|
||||
|
||||
def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -69,7 +69,7 @@ class TestGenerateSuccess:
|
||||
def test_runtime_session_snapshot_id_is_stable_for_debugger_only(self):
|
||||
assert (
|
||||
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.DEBUGGER, snapshot_id="snap-1")
|
||||
is None
|
||||
== "snap-1"
|
||||
)
|
||||
assert (
|
||||
AgentAppGenerator._runtime_session_snapshot_id(invoke_from=InvokeFrom.WEB_APP, snapshot_id="snap-1")
|
||||
@@ -111,7 +111,12 @@ class TestGenerateSuccess:
|
||||
|
||||
assert result == {"result": "ok"}
|
||||
thread_obj.start.assert_called_once()
|
||||
generator._resolve_agent.assert_called_once_with(app_model)
|
||||
generator._resolve_agent.assert_called_once_with(
|
||||
app_model,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=user,
|
||||
)
|
||||
|
||||
def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture):
|
||||
app_model = mocker.MagicMock(id="app1", tenant_id="tenant", mode="agent")
|
||||
|
||||
@@ -169,12 +169,19 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
"model": "gpt-4o-mini",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query_config": {
|
||||
"top_k": 3,
|
||||
"score_threshold": 0.5,
|
||||
"score_threshold_enabled": False,
|
||||
},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 3,
|
||||
"score_threshold": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -189,10 +196,12 @@ class TestAgentAppRuntimeRequestBuilder:
|
||||
assert knowledge.type == "dify.knowledge_base"
|
||||
assert knowledge.deps == {"execution_context": "execution_context"}
|
||||
dumped_config = knowledge.config.model_dump(mode="json", by_alias=True)
|
||||
assert dumped_config["dataset_ids"] == ["dataset-1", "dataset-2"]
|
||||
assert dumped_config["retrieval"]["mode"] == "multiple"
|
||||
assert dumped_config["retrieval"]["top_k"] == 3
|
||||
assert dumped_config["retrieval"]["score_threshold"] == 0.0
|
||||
knowledge_set = dumped_config["sets"][0]
|
||||
assert [dataset["id"] for dataset in knowledge_set["datasets"]] == ["dataset-1", "dataset-2"]
|
||||
assert knowledge_set["query"] == {"mode": "generated_query", "value": None}
|
||||
assert knowledge_set["retrieval"]["mode"] == "multiple"
|
||||
assert knowledge_set["retrieval"]["top_k"] == 3
|
||||
assert knowledge_set["retrieval"]["score_threshold"] == 0.0
|
||||
|
||||
def test_build_raises_when_model_missing(self):
|
||||
builder = AgentAppRuntimeRequestBuilder(
|
||||
|
||||
+158
-72
@@ -512,12 +512,55 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1"}, {"id": " "}, {"id": "dataset-2"}],
|
||||
"query_config": {
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"score_threshold_enabled": True,
|
||||
},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"description": "Support content",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
|
||||
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "manual",
|
||||
"conditions": {
|
||||
"logical_operator": "and",
|
||||
"conditions": [
|
||||
{"name": "category", "comparison_operator": "contains", "value": "auth"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "release",
|
||||
"name": "Release Notes",
|
||||
"datasets": [{"id": "dataset-3"}],
|
||||
"query": {"mode": "user_query", "value": "release notes"},
|
||||
"retrieval": {
|
||||
"mode": "single",
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "automatic",
|
||||
"model_config": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
@@ -531,25 +574,75 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
|
||||
knowledge_layer = layers["knowledge"]
|
||||
assert knowledge_layer["type"] == "dify.knowledge_base"
|
||||
assert knowledge_layer["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
assert knowledge_layer["config"] == {
|
||||
"dataset_ids": ["dataset-1", "dataset-2"],
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": None,
|
||||
"weights": None,
|
||||
"model": None,
|
||||
assert knowledge_layer["config"]["sets"] == [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"description": "Support content",
|
||||
"datasets": [
|
||||
{"id": "dataset-1", "name": None, "description": None},
|
||||
{"id": "dataset-2", "name": None, "description": None},
|
||||
],
|
||||
"query": {"mode": "generated_query", "value": None},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
|
||||
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
|
||||
"model": None,
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "manual",
|
||||
"metadata_model_config": None,
|
||||
"conditions": {
|
||||
"logical_operator": "and",
|
||||
"conditions": [
|
||||
{"name": "category", "comparison_operator": "contains", "value": "auth"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {"mode": "disabled", "metadata_model_config": None, "conditions": None},
|
||||
"max_result_content_chars": 2000,
|
||||
"max_observation_chars": 12000,
|
||||
}
|
||||
{
|
||||
"id": "release",
|
||||
"name": "Release Notes",
|
||||
"description": None,
|
||||
"datasets": [{"id": "dataset-3", "name": None, "description": None}],
|
||||
"query": {"mode": "user_query", "value": "release notes"},
|
||||
"retrieval": {
|
||||
"mode": "single",
|
||||
"top_k": None,
|
||||
"score_threshold": 0.0,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": None,
|
||||
"weights": None,
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "automatic",
|
||||
"metadata_model_config": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {},
|
||||
},
|
||||
"conditions": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
assert knowledge_layer["config"]["max_result_content_chars"] == 2000
|
||||
assert knowledge_layer["config"]["max_observation_chars"] == 12000
|
||||
|
||||
|
||||
def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits_it():
|
||||
def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero():
|
||||
context = _context()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
@@ -565,8 +658,19 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query_config": {},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 4,
|
||||
"score_threshold": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
@@ -577,10 +681,10 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge")
|
||||
assert knowledge_layer["config"]["retrieval"]["top_k"] == 4
|
||||
assert knowledge_layer["config"]["sets"][0]["retrieval"]["score_threshold"] == 0.0
|
||||
|
||||
|
||||
def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
|
||||
def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets():
|
||||
context = _context()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
@@ -595,9 +699,7 @@ def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
|
||||
"model_provider": "openai",
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": " "}, {}],
|
||||
},
|
||||
"knowledge": {"sets": []},
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -840,46 +942,29 @@ def _soul_with_drive_skill() -> AgentSoulConfig:
|
||||
"and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
)
|
||||
},
|
||||
files={
|
||||
"skills": [
|
||||
{
|
||||
"id": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
}
|
||||
],
|
||||
"files": [{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"}],
|
||||
},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
|
||||
|
||||
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 123,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": "hash-1",
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
|
||||
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
|
||||
{"key": "files/sample.pdf", "is_skill": False},
|
||||
],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def test_build_drive_layer_config_catalogs_drive_skills_and_mentions(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -1005,14 +1090,6 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = AgentSoulConfig(
|
||||
prompt={
|
||||
@@ -1021,6 +1098,7 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
|
||||
"and [§file:files%2Fno-label.txt§]."
|
||||
)
|
||||
},
|
||||
files={"files": [{"id": "files/no-label.txt", "name": "no-label.txt", "drive_key": "files/no-label.txt"}]},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
|
||||
@@ -1094,7 +1172,15 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
|
||||
"sets": [
|
||||
{
|
||||
"id": "product",
|
||||
"name": "Product Docs",
|
||||
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1106,13 +1192,13 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
|
||||
assert all("knowledge" not in w["section"] for w in manifest["unsupported_runtime_warnings"])
|
||||
|
||||
|
||||
def test_feature_manifest_treats_blank_knowledge_dataset_ids_as_not_configured():
|
||||
def test_feature_manifest_treats_empty_knowledge_sets_as_not_configured():
|
||||
from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest
|
||||
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"datasets": [{"id": " "}, {}],
|
||||
"sets": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -55,6 +55,33 @@ def _snapshot() -> AgentConfigSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_with_knowledge_dataset(dataset_id: str) -> AgentConfigSnapshot:
|
||||
return AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
version=1,
|
||||
config_snapshot=AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
knowledge={
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": dataset_id}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _graph(edges: list[dict]) -> dict:
|
||||
return {
|
||||
"nodes": [
|
||||
@@ -515,6 +542,35 @@ def test_publish_validation_rejects_missing_file_ref():
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
dataset_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot_with_knowledge_dataset(dataset_id)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_get_datasets_by_ids(ids, tenant_id):
|
||||
captured["ids"] = ids
|
||||
captured["tenant_id"] = tenant_id
|
||||
return [], 0
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
assert captured == {"ids": [dataset_id], "tenant_id": "tenant-1"}
|
||||
|
||||
|
||||
def test_publish_validation_accepts_tool_node_agentic_manual_mode():
|
||||
session = Mock()
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
@@ -34,6 +36,9 @@ def test_agent_enums_match_prd_boundaries():
|
||||
assert AgentStatus.ARCHIVED.value == "archived"
|
||||
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version"
|
||||
assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version"
|
||||
assert AgentConfigRevisionOperation.PUBLISH_DRAFT.value == "publish_draft"
|
||||
assert AgentConfigDraftType.DRAFT.value == "draft"
|
||||
assert AgentConfigDraftType.DEBUG_BUILD.value == "debug_build"
|
||||
assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent"
|
||||
assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent"
|
||||
|
||||
@@ -136,6 +141,23 @@ def test_current_snapshot_stores_agent_soul_snapshot_as_long_text_json():
|
||||
assert version.config_snapshot_dict["env"]["secret_refs"][0]["provider_credential_id"] == "cred-1"
|
||||
|
||||
|
||||
def test_agent_config_draft_stores_editable_agent_soul_as_long_text_json():
|
||||
config_snapshot = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "draft prompt"}})
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
draft_owner_key="",
|
||||
config_snapshot=config_snapshot,
|
||||
)
|
||||
|
||||
config_snapshot_column = AgentConfigDraft.__table__.c.config_snapshot
|
||||
assert isinstance(config_snapshot_column.type, JSONModelColumn)
|
||||
assert config_snapshot_column.server_default is None
|
||||
assert draft.config_snapshot_dict == config_snapshot.model_dump(mode="json")
|
||||
assert draft.config_snapshot_dict["prompt"]["system_prompt"] == "draft prompt"
|
||||
|
||||
|
||||
def test_workflow_binding_stores_node_job_config_separately_from_agent_soul():
|
||||
node_job_config = {
|
||||
"schema_version": 1,
|
||||
@@ -166,6 +188,7 @@ def test_long_text_columns_do_not_use_mysql_incompatible_server_defaults():
|
||||
assert isinstance(column.type, LongText)
|
||||
assert column.server_default is None
|
||||
assert AgentConfigSnapshot.__table__.c.config_snapshot.server_default is None
|
||||
assert AgentConfigDraft.__table__.c.config_snapshot.server_default is None
|
||||
assert WorkflowAgentNodeBinding.__table__.c.node_job_config.server_default is None
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from models.agent_config_entities import AgentKnowledgeQueryMode, AgentSoulModelConfig, DeclaredOutputType
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
@@ -131,14 +132,144 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
|
||||
config = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"datasets": [{"dataset_id": "dataset-1"}],
|
||||
"query_mode": "generated_query",
|
||||
"query_config": {"generation_prompt": "Create a retrieval query."},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert config.knowledge.query_mode == AgentKnowledgeQueryMode.GENERATED_QUERY
|
||||
assert config.knowledge.sets[0].query.mode == AgentKnowledgeQueryMode.GENERATED_QUERY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("knowledge_payload", "match"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Billing KB",
|
||||
"datasets": [{"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge set ids must be unique",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Shared KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
{
|
||||
"id": "billing",
|
||||
"name": "Shared KB",
|
||||
"datasets": [{"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge set names must be unique",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": " dataset-1 "}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge set dataset ids must be unique",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "user_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge query.value is required for user_query mode",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "single"},
|
||||
},
|
||||
]
|
||||
},
|
||||
"knowledge retrieval.model is required for single mode",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"metadata_filtering": {"mode": "automatic"},
|
||||
},
|
||||
]
|
||||
},
|
||||
"metadata_filtering.model_config is required for automatic mode",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"metadata_filtering": {"mode": "manual"},
|
||||
},
|
||||
]
|
||||
},
|
||||
"metadata_filtering.conditions is required for manual mode",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
|
||||
|
||||
|
||||
def test_agent_soul_model_config_is_first_class_without_credentials():
|
||||
|
||||
@@ -8,6 +8,8 @@ from sqlalchemy.exc import IntegrityError
|
||||
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigDraft,
|
||||
AgentConfigDraftType,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentDebugConversation,
|
||||
@@ -271,11 +273,11 @@ def test_publish_save_strategies_run_publish_validation(strategy: ComposerSaveSt
|
||||
|
||||
def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.MonkeyPatch):
|
||||
fake_session = FakeSession(scalar=[None])
|
||||
created_version = SimpleNamespace(id="version-1")
|
||||
saved_draft = SimpleNamespace(id="draft-1", config_snapshot_dict={"prompt": {"system_prompt": "x"}})
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
|
||||
monkeypatch.setattr(AgentComposerService, "_create_config_version", lambda **kwargs: created_version)
|
||||
monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **kwargs: saved_draft)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
@@ -293,23 +295,21 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.
|
||||
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
assert result == {"loaded": True}
|
||||
assert fake_session.added[0].name == "Analyst"
|
||||
assert fake_session.added[0].active_config_snapshot_id == "version-1"
|
||||
assert fake_session.added[0].active_config_has_model is False
|
||||
assert fake_session.added[0].active_config_snapshot_id is None
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1", updated_by=None)
|
||||
fake_session = FakeSession(scalar=[agent])
|
||||
updated = {}
|
||||
saved = {}
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
|
||||
monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id="version-1"))
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_update_current_version",
|
||||
lambda **kwargs: updated.update(kwargs) or SimpleNamespace(id="version-2"),
|
||||
"_save_agent_draft",
|
||||
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_app_composer", lambda **kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
@@ -326,12 +326,118 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch: pytest.Mon
|
||||
|
||||
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
|
||||
assert result == {"loaded": True}
|
||||
assert updated["operation"].value == "save_current_version"
|
||||
assert agent.active_config_has_model is True
|
||||
assert saved["draft_type"] == AgentConfigDraftType.DRAFT
|
||||
assert saved["agent_soul"].model_dump(mode="json") == _agent_soul_with_model().model_dump(mode="json")
|
||||
assert fake_session._scalar == []
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_publish_agent_app_draft_creates_published_snapshot(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",
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=_agent_soul_with_model(),
|
||||
)
|
||||
version = SimpleNamespace(id="version-2")
|
||||
fake_session = FakeSession(scalar=[agent, draft])
|
||||
created: dict[str, object] = {}
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_create_config_version",
|
||||
lambda **kwargs: created.update(kwargs) or version,
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda _version: {"id": _version.id})
|
||||
|
||||
result = AgentComposerService.publish_agent_app_draft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
version_note="ship it",
|
||||
)
|
||||
|
||||
assert result["result"] == "success"
|
||||
assert result["active_config_snapshot_id"] == "version-2"
|
||||
assert result["draft"]["base_snapshot_id"] == "version-2"
|
||||
assert created["operation"] == AgentConfigRevisionOperation.PUBLISH_DRAFT
|
||||
assert created["previous_snapshot_id"] == "version-1"
|
||||
assert agent.active_config_snapshot_id == "version-2"
|
||||
assert agent.active_config_has_model is True
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(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",
|
||||
)
|
||||
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=_agent_soul_with_model(),
|
||||
)
|
||||
fake_session = FakeSession(scalar=[agent, normal_draft, None])
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
|
||||
checked_out = AgentComposerService.checkout_agent_app_build_draft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
build_draft = fake_session.added[0]
|
||||
assert checked_out["draft"]["id"] == build_draft.id
|
||||
assert checked_out["draft"]["draft_type"] == AgentConfigDraftType.DEBUG_BUILD.value
|
||||
assert checked_out["draft"]["account_id"] == "account-1"
|
||||
assert checked_out["draft"]["base_snapshot_id"] == "version-1"
|
||||
assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict
|
||||
assert fake_session.commits == 1
|
||||
|
||||
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
|
||||
applied = AgentComposerService.apply_agent_app_build_draft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert applied["result"] == "success"
|
||||
assert applied["draft"]["id"] == normal_draft.id
|
||||
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
|
||||
assert fake_session.deleted == [build_draft]
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_agent_app_composer_candidates_and_impact(monkeypatch: pytest.MonkeyPatch):
|
||||
bindings = [
|
||||
SimpleNamespace(app_id="app-1", workflow_id="workflow-1", node_id="node-1"),
|
||||
@@ -376,13 +482,27 @@ def test_serialize_workflow_state_changes_lock_and_save_options(monkeypatch: pyt
|
||||
node_id="node-1",
|
||||
node_job_config='{"workflow_prompt":"do work"}',
|
||||
)
|
||||
agent = Agent(id="agent-1", name="Analyst", description="", scope=AgentScope.ROSTER, status=AgentStatus.ACTIVE)
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
name="Analyst",
|
||||
description="Clarifies tenders",
|
||||
role="Tender Analyst",
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
icon_background="#F5F3FF",
|
||||
scope=AgentScope.ROSTER,
|
||||
status=AgentStatus.ACTIVE,
|
||||
)
|
||||
version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}')
|
||||
monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1})
|
||||
|
||||
state = AgentComposerService._serialize_workflow_state(binding=binding, agent=agent, version=version)
|
||||
|
||||
assert state["soul_lock"]["locked"] is True
|
||||
assert state["agent"]["role"] == "Tender Analyst"
|
||||
assert state["agent"]["icon_type"] == "emoji"
|
||||
assert state["agent"]["icon"] == "robot"
|
||||
assert state["agent"]["icon_background"] == "#F5F3FF"
|
||||
assert "save_as_new_version" in state["save_options"]
|
||||
assert state["agent_soul"]["app_features"] == {}
|
||||
# Stage 4 §10.1 (D-3): binding with no declared_outputs → response surfaces
|
||||
@@ -1081,6 +1201,16 @@ def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest
|
||||
"prompt": {
|
||||
"system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]",
|
||||
},
|
||||
"files": {
|
||||
"skills": [
|
||||
{
|
||||
"id": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(
|
||||
@@ -1160,6 +1290,17 @@ def test_drive_copy_scopes_include_declared_output_benchmark_files():
|
||||
"[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]"
|
||||
)
|
||||
},
|
||||
"files": {
|
||||
"skills": [
|
||||
{
|
||||
"id": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
}
|
||||
],
|
||||
"files": [{"id": "files/source.pdf", "name": "source.pdf", "drive_key": "files/source.pdf"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
node_job = WorkflowNodeJobConfig.model_validate(
|
||||
@@ -1568,7 +1709,17 @@ def test_active_config_is_published_flags_handle_matching_and_empty_snapshots():
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id=None,
|
||||
)
|
||||
service = AgentRosterService(FakeSession(scalars=[["agent-1"], ["agent-1"]]))
|
||||
published_draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
)
|
||||
service = AgentRosterService(
|
||||
FakeSession(scalars=[["agent-1"], [published_draft], ["agent-1"], [published_draft]])
|
||||
)
|
||||
|
||||
flags = service.load_active_config_is_published_by_agent_id(tenant_id="tenant-1", agents=[agent, draft_agent])
|
||||
|
||||
@@ -1580,6 +1731,59 @@ def test_active_config_is_published_flags_handle_matching_and_empty_snapshots():
|
||||
) == {"agent-2": False}
|
||||
|
||||
|
||||
def test_active_config_is_published_skips_empty_agent_ids():
|
||||
empty_id_agent = Agent(
|
||||
id="",
|
||||
tenant_id="tenant-1",
|
||||
name="Broken",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id=None,
|
||||
)
|
||||
fake_session = FakeSession(scalars=[["should-not-be-read"]])
|
||||
|
||||
assert AgentRosterService(fake_session).load_active_config_is_published_by_agent_id(
|
||||
tenant_id="tenant-1",
|
||||
agents=[empty_id_agent],
|
||||
) == {}
|
||||
assert fake_session._scalars == [["should-not-be-read"]]
|
||||
|
||||
|
||||
def test_load_app_backing_agents_skips_empty_agent_ids():
|
||||
valid_agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Valid",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
app_id="app-1",
|
||||
status=AgentStatus.ACTIVE,
|
||||
)
|
||||
empty_id_agent = Agent(
|
||||
id="",
|
||||
tenant_id="tenant-1",
|
||||
name="Broken",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
app_id="app-2",
|
||||
status=AgentStatus.ACTIVE,
|
||||
)
|
||||
|
||||
result = AgentRosterService(FakeSession(scalars=[[valid_agent, empty_id_agent]])).load_app_backing_agents_by_app_id(
|
||||
tenant_id="tenant-1",
|
||||
app_ids=["app-1", "app-2"],
|
||||
)
|
||||
|
||||
assert result == {"app-1": valid_agent}
|
||||
|
||||
|
||||
def test_published_references_include_app_display_fields_and_sort_by_updated_at():
|
||||
recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC)
|
||||
stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC)
|
||||
@@ -1902,6 +2106,7 @@ def test_agent_app_visible_versions_exclude_draft_saves():
|
||||
roster_operations = AgentRosterService._visible_version_operations(roster_agent)
|
||||
|
||||
assert agent_app_operations == {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
@@ -1913,7 +2118,7 @@ def test_agent_app_visible_versions_exclude_draft_saves():
|
||||
|
||||
|
||||
def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
fake_session = FakeSession(scalar=["version-2", 6])
|
||||
fake_session = FakeSession(scalar=["version-2", None])
|
||||
service = AgentRosterService(fake_session)
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
@@ -1944,19 +2149,22 @@ def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pyte
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert restored == {"result": "success", "active_config_snapshot_id": "version-2"}
|
||||
assert agent.active_config_snapshot_id == "version-2"
|
||||
assert agent.active_config_has_model is True
|
||||
assert restored == {
|
||||
"result": "success",
|
||||
"active_config_snapshot_id": "version-4",
|
||||
"draft_config_id": fake_session.added[0].id,
|
||||
"restored_version_id": "version-2",
|
||||
}
|
||||
assert agent.active_config_snapshot_id == "version-4"
|
||||
assert agent.updated_by == "account-1"
|
||||
assert fake_session.commits == 1
|
||||
revision = fake_session.added[0]
|
||||
assert revision.tenant_id == "tenant-1"
|
||||
assert revision.agent_id == "agent-1"
|
||||
assert revision.previous_snapshot_id == "version-4"
|
||||
assert revision.current_snapshot_id == "version-2"
|
||||
assert revision.revision == 7
|
||||
assert revision.operation == AgentConfigRevisionOperation.RESTORE_VERSION
|
||||
assert revision.created_by == "account-1"
|
||||
draft = fake_session.added[0]
|
||||
assert draft.tenant_id == "tenant-1"
|
||||
assert draft.agent_id == "agent-1"
|
||||
assert draft.draft_type == AgentConfigDraftType.DRAFT
|
||||
assert draft.base_snapshot_id == "version-2"
|
||||
assert draft.config_snapshot_dict == _agent_soul_with_model().model_dump(mode="json")
|
||||
assert draft.updated_by == "account-1"
|
||||
|
||||
|
||||
def test_restore_roster_agent_version_rejects_invisible_versions(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -2843,6 +3051,11 @@ class TestWorkflowAgentDraftBindingSync:
|
||||
)
|
||||
|
||||
node_data = graph["nodes"][0]["data"]
|
||||
assert node_data["agent_binding"] == {
|
||||
"binding_type": "roster_agent",
|
||||
"agent_id": "agent-1",
|
||||
"current_snapshot_id": "snapshot-1",
|
||||
}
|
||||
assert node_data["agent_task"] == "Summarize the upstream result."
|
||||
assert node_data["agent_declared_outputs"][0]["name"] == "summary"
|
||||
assert node_data["agent_declared_outputs"][0]["type"] == "string"
|
||||
@@ -2852,6 +3065,103 @@ class TestWorkflowAgentDraftBindingSync:
|
||||
assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city"
|
||||
assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"]
|
||||
|
||||
def test_projects_inline_binding_over_pending_inline_graph_response(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node",
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_binding": {
|
||||
"binding_type": "inline_agent",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
),
|
||||
)
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
id="binding-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_version=Workflow.VERSION_DRAFT,
|
||||
node_id="agent-node",
|
||||
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
|
||||
agent_id="inline-agent-1",
|
||||
current_snapshot_id="inline-snapshot-1",
|
||||
)
|
||||
session = FakeSession(scalars=[[binding]])
|
||||
|
||||
graph = WorkflowAgentPublishService.project_draft_bindings_to_graph(
|
||||
session=session,
|
||||
draft_workflow=workflow,
|
||||
)
|
||||
|
||||
assert graph["nodes"][0]["data"]["agent_binding"] == {
|
||||
"binding_type": "inline_agent",
|
||||
"agent_id": "inline-agent-1",
|
||||
"current_snapshot_id": "inline-snapshot-1",
|
||||
}
|
||||
assert workflow.graph_dict["nodes"][0]["data"]["agent_binding"] == {
|
||||
"binding_type": "inline_agent",
|
||||
}
|
||||
|
||||
def test_keeps_pending_inline_graph_response_over_existing_roster_binding(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node",
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_binding": {
|
||||
"binding_type": "inline_agent",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
),
|
||||
)
|
||||
binding = WorkflowAgentNodeBinding(
|
||||
id="binding-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_version=Workflow.VERSION_DRAFT,
|
||||
node_id="agent-node",
|
||||
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
|
||||
agent_id="agent-1",
|
||||
current_snapshot_id="snapshot-1",
|
||||
)
|
||||
session = FakeSession(scalars=[[binding]])
|
||||
|
||||
graph = WorkflowAgentPublishService.project_draft_bindings_to_graph(
|
||||
session=session,
|
||||
draft_workflow=workflow,
|
||||
)
|
||||
|
||||
assert graph["nodes"][0]["data"]["agent_binding"] == {
|
||||
"binding_type": "inline_agent",
|
||||
}
|
||||
|
||||
def test_creates_roster_binding_from_agent_node_graph(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
@@ -2979,6 +3289,52 @@ class TestWorkflowAgentDraftBindingSync:
|
||||
workflow_prompt="Use the current node context.",
|
||||
).model_dump(mode="json")
|
||||
|
||||
def test_keeps_pending_inline_binding_in_draft_graph_without_db_binding(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
graph=json.dumps(
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent-node",
|
||||
"data": {
|
||||
"type": "agent",
|
||||
"version": "2",
|
||||
"agent_binding": {
|
||||
"binding_type": "inline_agent",
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
)
|
||||
existing_binding = WorkflowAgentNodeBinding(
|
||||
id="binding-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
workflow_version=Workflow.VERSION_DRAFT,
|
||||
node_id="agent-node",
|
||||
binding_type=WorkflowAgentBindingType.ROSTER_AGENT,
|
||||
agent_id="agent-1",
|
||||
current_snapshot_id="snapshot-1",
|
||||
)
|
||||
session = FakeSession(scalars=[[existing_binding]])
|
||||
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=session,
|
||||
draft_workflow=workflow,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert session.deleted == []
|
||||
assert session.added == []
|
||||
assert session.flushes == 1
|
||||
|
||||
def test_rejects_inline_binding_for_agent_owned_by_another_node(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
@@ -3058,7 +3414,7 @@ class TestWorkflowAgentDraftBindingSync:
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
def test_rejects_inline_binding_without_current_snapshot_id(self):
|
||||
def test_treats_partial_inline_binding_as_pending_draft_state(self):
|
||||
workflow = Workflow(
|
||||
id="workflow-1",
|
||||
tenant_id="tenant-1",
|
||||
@@ -3083,12 +3439,17 @@ class TestWorkflowAgentDraftBindingSync:
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="inline_agent binding requires current_snapshot_id"):
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=FakeSession(scalars=[[]]),
|
||||
draft_workflow=workflow,
|
||||
account_id="account-1",
|
||||
)
|
||||
session = FakeSession(scalars=[[]])
|
||||
|
||||
WorkflowAgentPublishService.sync_agent_bindings_for_draft(
|
||||
session=session,
|
||||
draft_workflow=workflow,
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert session.added == []
|
||||
assert session.deleted == []
|
||||
assert session.flushes == 1
|
||||
|
||||
def test_rejects_inline_binding_with_missing_snapshot(self):
|
||||
workflow = Workflow(
|
||||
@@ -3312,20 +3673,151 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch):
|
||||
return [], 0
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
valid = "550e8400-e29b-41d4-a716-446655440000"
|
||||
rows = AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid])
|
||||
rows = get_tenant_knowledge_dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid])
|
||||
assert rows == {}
|
||||
assert captured["ids"] == [valid]
|
||||
|
||||
# all-malformed input never touches the DB
|
||||
captured.clear()
|
||||
assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
|
||||
assert get_tenant_knowledge_dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
|
||||
assert captured == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variant", "save_call"),
|
||||
[
|
||||
(
|
||||
ComposerVariant.AGENT_APP,
|
||||
lambda payload: AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
(
|
||||
ComposerVariant.WORKFLOW,
|
||||
lambda payload: AgentComposerService.save_workflow_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch, variant, save_call):
|
||||
captured = {"calls": 0}
|
||||
|
||||
def fake_get_datasets_by_ids(ids, tenant_id):
|
||||
captured["calls"] += 1
|
||||
captured["ids"] = ids
|
||||
captured["tenant_id"] = tenant_id
|
||||
return [], 0
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": variant.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"soul_lock": {"locked": False},
|
||||
"agent_soul": {
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "not-a-uuid"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="not-a-uuid"):
|
||||
save_call(payload)
|
||||
|
||||
assert captured == {"calls": 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variant", "save_call"),
|
||||
[
|
||||
(
|
||||
ComposerVariant.AGENT_APP,
|
||||
lambda payload: AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
(
|
||||
ComposerVariant.WORKFLOW,
|
||||
lambda payload: AgentComposerService.save_workflow_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
monkeypatch: pytest.MonkeyPatch, variant, save_call
|
||||
):
|
||||
captured = {}
|
||||
missing_dataset_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
def fake_get_datasets_by_ids(ids, tenant_id):
|
||||
captured["ids"] = ids
|
||||
captured["tenant_id"] = tenant_id
|
||||
return [], 0
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": variant.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"soul_lock": {"locked": False},
|
||||
"agent_soul": {
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": missing_dataset_id}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id):
|
||||
save_call(payload)
|
||||
|
||||
assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"}
|
||||
|
||||
|
||||
def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch):
|
||||
"""The slash-menu Tools tab needs both selection granularities: a provider
|
||||
hosts many tools (like an MCP server), so candidates return one
|
||||
@@ -3378,6 +3870,18 @@ def _drive_soul(**overrides):
|
||||
"Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
)
|
||||
},
|
||||
"files": {
|
||||
"skills": [
|
||||
{
|
||||
"id": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
}
|
||||
],
|
||||
"files": [{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"}],
|
||||
},
|
||||
}
|
||||
base.update(overrides)
|
||||
return AgentSoulConfig.model_validate(base)
|
||||
@@ -3402,7 +3906,7 @@ def test_drive_mention_findings_reports_missing_keys(monkeypatch: pytest.MonkeyP
|
||||
findings = AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=_drive_soul().prompt.system_prompt,
|
||||
agent_soul=_drive_soul(),
|
||||
)
|
||||
|
||||
assert [(f["code"], f["id"]) for f in findings] == [("mention_target_missing", "files/sample.pdf")]
|
||||
@@ -3417,7 +3921,7 @@ def test_drive_mention_findings_clean_when_all_keys_exist(monkeypatch: pytest.Mo
|
||||
AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=_drive_soul().prompt.system_prompt,
|
||||
agent_soul=_drive_soul(),
|
||||
)
|
||||
== []
|
||||
)
|
||||
@@ -3429,7 +3933,7 @@ def test_drive_mention_findings_skips_prompt_without_drive_mentions(monkeypatch:
|
||||
findings = AgentComposerService._drive_mention_findings(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
prompt=soul.prompt.system_prompt,
|
||||
agent_soul=soul,
|
||||
)
|
||||
assert findings == []
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
|
||||
|
||||
def test_apply_drive_commit_records_skill_and_file_refs_in_agent_soul():
|
||||
soul = AgentSoulConfig()
|
||||
|
||||
AgentSoulFilesService._apply_commit_item(
|
||||
agent_soul=soul,
|
||||
item={
|
||||
"key": "tender-analyzer/SKILL.md",
|
||||
"file_kind": "tool_file",
|
||||
"file_id": "skill-md-file",
|
||||
"is_skill": True,
|
||||
"skill_metadata": json.dumps(
|
||||
{
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses tenders.",
|
||||
"manifest_files": ["SKILL.md", "src/main.py"],
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
AgentSoulFilesService._apply_commit_item(
|
||||
agent_soul=soul,
|
||||
item={
|
||||
"key": "files/sample.pdf",
|
||||
"file_kind": "upload_file",
|
||||
"file_id": "upload-file",
|
||||
"mime_type": "application/pdf",
|
||||
"is_skill": False,
|
||||
},
|
||||
)
|
||||
AgentSoulFilesService._apply_commit_item(
|
||||
agent_soul=soul,
|
||||
item={
|
||||
"key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"file_kind": "tool_file",
|
||||
"file_id": "archive-file",
|
||||
"mime_type": "application/zip",
|
||||
"is_skill": False,
|
||||
},
|
||||
)
|
||||
AgentSoulFilesService._apply_commit_item(
|
||||
agent_soul=soul,
|
||||
item={
|
||||
"key": "tender-analyzer/src/main.py",
|
||||
"file_kind": "tool_file",
|
||||
"file_id": "member-file",
|
||||
"mime_type": "text/x-python",
|
||||
"is_skill": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert [skill.model_dump(mode="json", exclude_none=True) for skill in soul.files.skills] == [
|
||||
{
|
||||
"id": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses tenders.",
|
||||
"file_id": "skill-md-file",
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"skill_md_file_id": "skill-md-file",
|
||||
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"full_archive_file_id": "archive-file",
|
||||
"manifest_files": ["SKILL.md", "src/main.py"],
|
||||
"file_refs": [
|
||||
{
|
||||
"id": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"file_id": "archive-file",
|
||||
"name": ".DIFY-SKILL-FULL.zip",
|
||||
"type": "application/zip",
|
||||
"transfer_method": "tool_file",
|
||||
"drive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
},
|
||||
{
|
||||
"id": "tender-analyzer/SKILL.md",
|
||||
"file_id": "skill-md-file",
|
||||
"name": "SKILL.md",
|
||||
"type": "",
|
||||
"transfer_method": "tool_file",
|
||||
"drive_key": "tender-analyzer/SKILL.md",
|
||||
},
|
||||
{
|
||||
"id": "tender-analyzer/src/main.py",
|
||||
"file_id": "member-file",
|
||||
"name": "main.py",
|
||||
"type": "text/x-python",
|
||||
"transfer_method": "tool_file",
|
||||
"drive_key": "tender-analyzer/src/main.py",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert [file_ref.model_dump(mode="json", exclude_none=True) for file_ref in soul.files.files] == [
|
||||
{
|
||||
"id": "files/sample.pdf",
|
||||
"file_id": "upload-file",
|
||||
"upload_file_id": "upload-file",
|
||||
"name": "sample.pdf",
|
||||
"type": "application/pdf",
|
||||
"transfer_method": "upload_file",
|
||||
"drive_key": "files/sample.pdf",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_apply_drive_commit_removes_refs_without_touching_unrelated_entries():
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"files": {
|
||||
"skills": [
|
||||
{
|
||||
"id": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"file_refs": [
|
||||
{
|
||||
"id": "tender-analyzer/SKILL.md",
|
||||
"file_id": "skill-md-file",
|
||||
"name": "SKILL.md",
|
||||
"type": "",
|
||||
"transfer_method": "tool_file",
|
||||
"drive_key": "tender-analyzer/SKILL.md",
|
||||
},
|
||||
{
|
||||
"id": "tender-analyzer/src/main.py",
|
||||
"file_id": "member-file",
|
||||
"name": "main.py",
|
||||
"transfer_method": "tool_file",
|
||||
"drive_key": "tender-analyzer/src/main.py",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"},
|
||||
{"id": "files/keep.pdf", "name": "keep.pdf", "drive_key": "files/keep.pdf"},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
AgentSoulFilesService._apply_commit_item(agent_soul=soul, item={"key": "files/sample.pdf", "removed": True})
|
||||
AgentSoulFilesService._apply_commit_item(
|
||||
agent_soul=soul, item={"key": "tender-analyzer/SKILL.md", "removed": True}
|
||||
)
|
||||
|
||||
assert [file_ref.drive_key for file_ref in soul.files.files] == ["files/keep.pdf"]
|
||||
assert soul.files.skills == []
|
||||
|
||||
|
||||
def test_drive_copy_and_access_scopes_come_from_agent_soul_files():
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"files": {
|
||||
"skills": [
|
||||
{
|
||||
"id": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
}
|
||||
],
|
||||
"files": [{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"}],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
exact_keys, prefixes = AgentSoulFilesService.drive_copy_scopes(agent_soul=soul)
|
||||
|
||||
assert exact_keys == {
|
||||
"tender-analyzer/SKILL.md",
|
||||
"tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"files/sample.pdf",
|
||||
}
|
||||
assert prefixes == {"tender-analyzer/"}
|
||||
assert AgentSoulFilesService.key_allowed_by_soul(agent_soul=soul, key="tender-analyzer/src/main.py") is True
|
||||
assert AgentSoulFilesService.key_allowed_by_soul(agent_soul=soul, key="files/other.pdf") is False
|
||||
|
||||
|
||||
def test_file_ref_for_key_resolves_skill_member_refs():
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"files": {
|
||||
"skills": [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"file_refs": [
|
||||
{
|
||||
"id": "tender-analyzer/src/main.py",
|
||||
"file_id": "member-file",
|
||||
"name": "main.py",
|
||||
"transfer_method": "tool_file",
|
||||
"drive_key": "tender-analyzer/src/main.py",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
file_ref = AgentSoulFilesService.file_ref_for_key(agent_soul=soul, key="tender-analyzer/src/main.py")
|
||||
|
||||
assert file_ref is not None
|
||||
assert file_ref.file_id == "member-file"
|
||||
@@ -124,7 +124,18 @@ def _soul() -> AgentSoulConfig:
|
||||
{"id": "ct-2", "name": "disabled-one", "enabled": False},
|
||||
],
|
||||
},
|
||||
"knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]},
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "kb-1",
|
||||
"name": "产品知识",
|
||||
"description": "knowledge set",
|
||||
"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
|
||||
}
|
||||
)
|
||||
@@ -143,12 +154,16 @@ def test_soul_candidates_lists_configured_items_only():
|
||||
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
|
||||
# the stable mention id flows through so the frontend can mint [§cli_tool:<id>§]
|
||||
assert [item["id"] for item in lists["cli_tools"]] == ["ct-1"]
|
||||
# enriched from DB; dangling dataset kept with missing flag (placeholder, 0522)
|
||||
knowledge = {item["id"]: item for item in lists["knowledge_datasets"]}
|
||||
assert knowledge["ds-1"]["name"] == "产品手册"
|
||||
assert knowledge["ds-1"]["missing"] is False
|
||||
assert knowledge["ds-gone"]["missing"] is True
|
||||
assert knowledge["ds-gone"]["name"] == "已删"
|
||||
# Knowledge mentions point at set ids; nested datasets are hydrated for context.
|
||||
knowledge_set = lists["knowledge_sets"][0]
|
||||
assert knowledge_set["id"] == "kb-1"
|
||||
assert knowledge_set["name"] == "产品知识"
|
||||
assert knowledge_set["missing_dataset_ids"] == ["ds-gone"]
|
||||
datasets = {item["id"]: item for item in knowledge_set["datasets"]}
|
||||
assert datasets["ds-1"]["name"] == "产品手册"
|
||||
assert datasets["ds-1"]["missing"] is False
|
||||
assert datasets["ds-gone"]["missing"] is True
|
||||
assert datasets["ds-gone"]["name"] == "已删"
|
||||
assert lists["human_contacts"][0]["id"] == "c-1"
|
||||
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"
|
||||
|
||||
|
||||
@@ -149,22 +149,32 @@ def test_dangling_knowledge_without_label_gets_fallback_name():
|
||||
]
|
||||
|
||||
|
||||
def test_configured_but_deleted_dataset_surfaces_as_placeholder():
|
||||
def test_configured_but_deleted_knowledge_set_surfaces_as_placeholder():
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": "agent_app",
|
||||
"agent_soul": {
|
||||
"prompt": {"system_prompt": "see [§knowledge:ds-1:产品手册§]"},
|
||||
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
|
||||
"prompt": {"system_prompt": "see [§knowledge:kb-1:产品手册§]"},
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "kb-1",
|
||||
"name": "产品手册",
|
||||
"datasets": [{"id": "ds-1", "name": "产品手册"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
"save_strategy": "save_to_current_version",
|
||||
}
|
||||
)
|
||||
# configured + DB row exists -> clean
|
||||
assert _findings(payload, existing_dataset_ids={"ds-1"})["knowledge_retrieval_placeholder"] == []
|
||||
# configured but deleted in DB -> placeholder
|
||||
assert _findings(payload, existing_dataset_ids=set())["knowledge_retrieval_placeholder"] == [
|
||||
{"id": "ds-1", "placeholder_name": "产品手册"}
|
||||
# configured + current Agent Soul row exists -> clean
|
||||
assert _findings(payload, existing_knowledge_set_ids={"kb-1"})["knowledge_retrieval_placeholder"] == []
|
||||
# configured but removed from the current Agent Soul surface -> placeholder
|
||||
assert _findings(payload, existing_knowledge_set_ids=set())["knowledge_retrieval_placeholder"] == [
|
||||
{"id": "kb-1", "placeholder_name": "产品手册"}
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,17 @@ def soul() -> AgentSoulConfig:
|
||||
],
|
||||
"cli_tools": [{"id": "ct-1", "name": "ffmpeg"}],
|
||||
},
|
||||
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "kb-1",
|
||||
"name": "产品手册",
|
||||
"datasets": [{"id": "ds-1", "name": "产品手册"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
|
||||
}
|
||||
)
|
||||
@@ -117,7 +127,7 @@ def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
|
||||
resolver = build_soul_mention_resolver(soul)
|
||||
prompt = (
|
||||
"Use [§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
|
||||
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
|
||||
"ground in [§knowledge:kb-1§], ask [§human:c-1§]."
|
||||
)
|
||||
|
||||
expanded = expand_prompt_mentions(prompt, resolver)
|
||||
|
||||
@@ -65,6 +65,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
|
||||
user_id="user-1",
|
||||
agent_id="agent-1",
|
||||
)
|
||||
assert service.last_committed_items == []
|
||||
|
||||
# ToolFiles: SKILL.md, full archive, and each inspectable package member.
|
||||
assert tool_files.create_file_by_raw.call_count == 3
|
||||
@@ -102,3 +103,4 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_members()
|
||||
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
|
||||
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
|
||||
assert result["manifest"]["files"] == ["SKILL.md", "scripts/run.py"]
|
||||
assert "_committed_items" not in result
|
||||
|
||||
@@ -98,6 +98,8 @@ class TestInnerKnowledgeRetrievalService:
|
||||
"total_price": "0",
|
||||
"currency": "USD",
|
||||
"latency": 0,
|
||||
"time_to_first_token": None,
|
||||
"time_to_generate": None,
|
||||
}
|
||||
mock_rag_cls.return_value = rag
|
||||
|
||||
|
||||
@@ -7,21 +7,31 @@ root stays import-safe for callers that only need to construct run requests.
|
||||
from dify_agent.layers.knowledge.configs import (
|
||||
DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
|
||||
DifyKnowledgeBaseLayerConfig,
|
||||
DifyKnowledgeDatasetConfig,
|
||||
DifyKnowledgeEagerResult,
|
||||
DifyKnowledgeMetadataCondition,
|
||||
DifyKnowledgeMetadataConditions,
|
||||
DifyKnowledgeMetadataFilteringConfig,
|
||||
DifyKnowledgeModelConfig,
|
||||
DifyKnowledgeQueryConfig,
|
||||
DifyKnowledgeRerankingModelConfig,
|
||||
DifyKnowledgeRetrievalConfig,
|
||||
DifyKnowledgeRuntimeState,
|
||||
DifyKnowledgeSetConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID",
|
||||
"DifyKnowledgeBaseLayerConfig",
|
||||
"DifyKnowledgeDatasetConfig",
|
||||
"DifyKnowledgeEagerResult",
|
||||
"DifyKnowledgeMetadataCondition",
|
||||
"DifyKnowledgeMetadataConditions",
|
||||
"DifyKnowledgeMetadataFilteringConfig",
|
||||
"DifyKnowledgeModelConfig",
|
||||
"DifyKnowledgeQueryConfig",
|
||||
"DifyKnowledgeRerankingModelConfig",
|
||||
"DifyKnowledgeRetrievalConfig",
|
||||
"DifyKnowledgeRuntimeState",
|
||||
"DifyKnowledgeSetConfig",
|
||||
]
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
"""Client-safe DTOs for the Dify knowledge-base Agenton layer.
|
||||
|
||||
The public layer config exposes only static retrieval controls: dataset ids,
|
||||
retrieval strategy, metadata filtering, and observation-size limits. The agent
|
||||
model itself should only ever see a single ``query`` tool argument; tenant/
|
||||
app/user context comes from the execution-context layer and the actual
|
||||
retrieval is delegated to the Dify API inner endpoint. Tool naming is not
|
||||
caller-configurable: the runtime always exposes the same stable knowledge-base
|
||||
search tool.
|
||||
The public layer config carries one or more named knowledge sets. Each set owns
|
||||
its dataset ids plus query, retrieval, and metadata-filtering policy. Generated-
|
||||
query sets are exposed through one stable model-visible search tool whose
|
||||
schema lets the model pick ``set_name`` and ``query``; user-query sets are
|
||||
retrieved eagerly when the layer enters a run and their formatted observations
|
||||
are kept only in JSON-safe ``runtime_state`` for session snapshots.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -61,6 +60,44 @@ class DifyKnowledgeRerankingModelConfig(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DifyKnowledgeDatasetConfig(BaseModel):
|
||||
"""One dataset selected by a knowledge set.
|
||||
|
||||
Only ``id`` is used for retrieval. ``name`` and ``description`` are retained
|
||||
because callers already have them and they are useful in runtime/debug
|
||||
snapshots without changing the inner retrieval request contract.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def validate_id(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("dataset id must not be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class DifyKnowledgeQueryConfig(BaseModel):
|
||||
"""Query policy for one knowledge set."""
|
||||
|
||||
mode: Literal["user_query", "generated_query"]
|
||||
value: str | None = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_specific_fields(self) -> DifyKnowledgeQueryConfig:
|
||||
if self.mode == "user_query" and not (self.value or "").strip():
|
||||
raise ValueError("query.value is required for user_query mode")
|
||||
return self
|
||||
|
||||
|
||||
class DifyKnowledgeRetrievalConfig(BaseModel):
|
||||
"""Static retrieval controls mirrored into the inner API request."""
|
||||
|
||||
@@ -151,38 +188,90 @@ class DifyKnowledgeMetadataFilteringConfig(BaseModel):
|
||||
return payload
|
||||
|
||||
|
||||
class DifyKnowledgeBaseLayerConfig(LayerConfig):
|
||||
"""Public config for one model-visible knowledge search tool.
|
||||
class DifyKnowledgeSetConfig(BaseModel):
|
||||
"""One independently searchable or eagerly-preloaded knowledge set."""
|
||||
|
||||
The model only gets to choose whether to call the tool and what ``query``
|
||||
to send. Dataset ids, retrieval settings, metadata filtering, and caller
|
||||
context remain config/runtime concerns outside the model-visible tool
|
||||
schema. The tool name and description are fixed by the layer runtime and do
|
||||
not appear in the public config DTO.
|
||||
"""
|
||||
|
||||
dataset_ids: list[str]
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
datasets: list[DifyKnowledgeDatasetConfig]
|
||||
query: DifyKnowledgeQueryConfig
|
||||
retrieval: DifyKnowledgeRetrievalConfig
|
||||
metadata_filtering: DifyKnowledgeMetadataFilteringConfig = Field(
|
||||
default_factory=DifyKnowledgeMetadataFilteringConfig
|
||||
)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator("id", "name")
|
||||
@classmethod
|
||||
def validate_non_blank_identity(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("knowledge set id and name must not be blank")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_dataset_ids(self) -> DifyKnowledgeSetConfig:
|
||||
if not self.datasets:
|
||||
raise ValueError("knowledge set requires at least one dataset")
|
||||
dataset_ids = [dataset.id for dataset in self.datasets]
|
||||
if len(dataset_ids) != len(set(dataset_ids)):
|
||||
raise ValueError("knowledge set dataset ids must be unique")
|
||||
return self
|
||||
|
||||
@property
|
||||
def dataset_ids(self) -> list[str]:
|
||||
"""Return the selected dataset ids for the inner retrieval request."""
|
||||
return [dataset.id for dataset in self.datasets]
|
||||
|
||||
|
||||
class DifyKnowledgeEagerResult(BaseModel):
|
||||
"""JSON-safe eager user-query result stored in layer runtime state."""
|
||||
|
||||
set_id: str
|
||||
set_name: str
|
||||
query: str
|
||||
observation: str
|
||||
status: Literal["success", "empty", "temporarily_unavailable"]
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DifyKnowledgeRuntimeState(BaseModel):
|
||||
"""Serializable eager-retrieval state stored in Agenton session snapshots."""
|
||||
|
||||
eager_config_fingerprint: str | None = None
|
||||
eager_results: list[DifyKnowledgeEagerResult] = Field(default_factory=list)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
|
||||
class DifyKnowledgeBaseLayerConfig(LayerConfig):
|
||||
"""Public config for one knowledge-base layer.
|
||||
|
||||
The model-visible surface stays fixed to ``knowledge_base_search``. Set
|
||||
names are the only model-visible selection labels; dataset ids, retrieval
|
||||
controls, metadata filtering, and caller identity remain config/runtime
|
||||
concerns outside the tool schema.
|
||||
"""
|
||||
|
||||
sets: list[DifyKnowledgeSetConfig]
|
||||
max_result_content_chars: int = Field(default=2000, ge=1)
|
||||
max_observation_chars: int = Field(default=12000, ge=1)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
@field_validator("dataset_ids")
|
||||
@classmethod
|
||||
def validate_dataset_ids(cls, value: list[str]) -> list[str]:
|
||||
if not value:
|
||||
raise ValueError("dataset_ids must contain at least one item")
|
||||
normalized_ids = [item.strip() for item in value]
|
||||
if any(not item for item in normalized_ids):
|
||||
raise ValueError("dataset_ids must not contain blank items")
|
||||
return normalized_ids
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_observation_limits(self) -> DifyKnowledgeBaseLayerConfig:
|
||||
def validate_sets_and_observation_limits(self) -> DifyKnowledgeBaseLayerConfig:
|
||||
if not self.sets:
|
||||
raise ValueError("sets must contain at least one knowledge set")
|
||||
set_ids = [knowledge_set.id for knowledge_set in self.sets]
|
||||
if len(set_ids) != len(set(set_ids)):
|
||||
raise ValueError("knowledge set ids must be unique")
|
||||
normalized_names = [knowledge_set.name.strip().lower() for knowledge_set in self.sets]
|
||||
if len(normalized_names) != len(set(normalized_names)):
|
||||
raise ValueError("knowledge set names must be unique")
|
||||
if self.max_observation_chars < self.max_result_content_chars:
|
||||
raise ValueError("max_observation_chars must be greater than or equal to max_result_content_chars")
|
||||
return self
|
||||
@@ -191,10 +280,15 @@ class DifyKnowledgeBaseLayerConfig(LayerConfig):
|
||||
__all__ = [
|
||||
"DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID",
|
||||
"DifyKnowledgeBaseLayerConfig",
|
||||
"DifyKnowledgeDatasetConfig",
|
||||
"DifyKnowledgeEagerResult",
|
||||
"DifyKnowledgeMetadataCondition",
|
||||
"DifyKnowledgeMetadataConditions",
|
||||
"DifyKnowledgeMetadataFilteringConfig",
|
||||
"DifyKnowledgeModelConfig",
|
||||
"DifyKnowledgeQueryConfig",
|
||||
"DifyKnowledgeRerankingModelConfig",
|
||||
"DifyKnowledgeRetrievalConfig",
|
||||
"DifyKnowledgeRuntimeState",
|
||||
"DifyKnowledgeSetConfig",
|
||||
]
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Dify knowledge-base layer exposing one model-visible search tool.
|
||||
"""Dify knowledge-base layer exposing set-aware retrieval.
|
||||
|
||||
The layer depends on ``DifyExecutionContextLayer`` for tenant/app/user/invoke
|
||||
identity, keeps retrieval controls in config only, and borrows a lifespan-owned
|
||||
HTTP client for each tool invocation. It never owns live clients or stores
|
||||
retrieved source content in layer state. Tool identity is intentionally fixed at
|
||||
runtime: callers cannot rename the knowledge tool or override its description
|
||||
through public layer config because the model-visible surface must stay stable
|
||||
across API-side Agent Soul mappings.
|
||||
identity. Generated-query sets become one stable model-visible
|
||||
``knowledge_base_search(set_name, query)`` tool, while user-query sets are
|
||||
retrieved eagerly during context entry and exposed as additional user prompt
|
||||
content. Eager observations are persisted only as JSON-safe runtime state so
|
||||
Agenton session snapshots can resume without repeating unchanged retrievals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import ClassVar, cast
|
||||
|
||||
@@ -27,7 +28,13 @@ from dify_agent.layers.knowledge.client import (
|
||||
DifyKnowledgeBaseClientError,
|
||||
DifyKnowledgeRetrieveResponse,
|
||||
)
|
||||
from dify_agent.layers.knowledge.configs import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.knowledge.configs import (
|
||||
DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
|
||||
DifyKnowledgeBaseLayerConfig,
|
||||
DifyKnowledgeEagerResult,
|
||||
DifyKnowledgeRuntimeState,
|
||||
DifyKnowledgeSetConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,23 +42,14 @@ logger = logging.getLogger(__name__)
|
||||
# public DTO cannot grow a parallel naming contract that diverges from the
|
||||
# runtime knowledge-search surface.
|
||||
_KNOWLEDGE_BASE_TOOL_NAME = "knowledge_base_search"
|
||||
_KNOWLEDGE_BASE_TOOL_DESCRIPTION = "Search configured knowledge bases for information relevant to the query."
|
||||
_KNOWLEDGE_BASE_TOOL_DESCRIPTION = (
|
||||
"Search a configured knowledge set. Pick one configured set_name and provide a focused search query."
|
||||
)
|
||||
BLANK_QUERY_OBSERVATION = "knowledge base search requires a non-empty query"
|
||||
NO_RESULTS_OBSERVATION = "No relevant knowledge base results were found."
|
||||
TEMPORARY_UNAVAILABLE_OBSERVATION = (
|
||||
"Knowledge base search is temporarily unavailable. Please continue without it if possible."
|
||||
)
|
||||
QUERY_TOOL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query for the configured knowledge bases.",
|
||||
}
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
class DifyKnowledgeBaseDeps(LayerDeps):
|
||||
@@ -61,8 +59,10 @@ class DifyKnowledgeBaseDeps(LayerDeps):
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBaseLayerConfig]):
|
||||
"""Layer that resolves one config-scoped knowledge search tool."""
|
||||
class DifyKnowledgeBaseLayer(
|
||||
PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBaseLayerConfig, DifyKnowledgeRuntimeState]
|
||||
):
|
||||
"""Layer that resolves set-scoped knowledge tools and eager user prompts."""
|
||||
|
||||
type_id: ClassVar[str | None] = DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID
|
||||
|
||||
@@ -95,7 +95,7 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
|
||||
)
|
||||
|
||||
async def get_tools(self, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
"""Build one Pydantic AI tool that exposes only ``query`` to the model.
|
||||
"""Build the unified generated-query Pydantic AI tool, when needed.
|
||||
|
||||
Knowledge tools depend on execution-context identity that is optional for
|
||||
other run types but mandatory here: ``tenant_id``, ``user_id``,
|
||||
@@ -103,11 +103,15 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
|
||||
any HTTP request is attempted. Tool execution then follows a strict
|
||||
observation policy:
|
||||
|
||||
- unknown ``set_name`` returns a local validation observation;
|
||||
- blank ``query`` returns a local validation observation;
|
||||
- retryable client failures (timeouts, connection failures, HTTP
|
||||
``429``/``502``) become a temporary-unavailable observation;
|
||||
- non-retryable client failures are raised so the run fails fast.
|
||||
"""
|
||||
generated_sets = self._generated_query_sets()
|
||||
if not generated_sets:
|
||||
return []
|
||||
if http_client.is_closed:
|
||||
raise RuntimeError("DifyKnowledgeBaseLayer.get_tools() requires an open shared HTTP client.")
|
||||
|
||||
@@ -118,54 +122,28 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
|
||||
api_key=self.inner_api_key,
|
||||
http_client=http_client,
|
||||
)
|
||||
set_by_name = {knowledge_set.name: knowledge_set for knowledge_set in generated_sets}
|
||||
|
||||
async def knowledge_base_search(_ctx: RunContext[object], query: str) -> str:
|
||||
async def knowledge_base_search(_ctx: RunContext[object], set_name: str, query: str) -> str:
|
||||
knowledge_set = set_by_name.get(set_name)
|
||||
if knowledge_set is None:
|
||||
return f"unknown knowledge set: {set_name}"
|
||||
normalized_query = query.strip()
|
||||
if not normalized_query:
|
||||
return BLANK_QUERY_OBSERVATION
|
||||
try:
|
||||
response = await client.retrieve(
|
||||
tenant_id=caller["tenant_id"],
|
||||
user_id=caller["user_id"],
|
||||
app_id=caller["app_id"],
|
||||
user_from=caller["user_from"],
|
||||
invoke_from=caller["invoke_from"],
|
||||
dataset_ids=list(self.config.dataset_ids),
|
||||
query=normalized_query,
|
||||
retrieval=self.config.retrieval,
|
||||
metadata_filtering=self.config.metadata_filtering,
|
||||
)
|
||||
except DifyKnowledgeBaseClientError as exc:
|
||||
if exc.retryable:
|
||||
logger.warning(
|
||||
"knowledge base search temporarily unavailable",
|
||||
extra={
|
||||
"tenant_id": caller["tenant_id"],
|
||||
"app_id": caller["app_id"],
|
||||
"invoke_from": caller["invoke_from"],
|
||||
"error_code": exc.error_code,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
return TEMPORARY_UNAVAILABLE_OBSERVATION
|
||||
logger.error(
|
||||
"knowledge base search failed",
|
||||
extra={
|
||||
"tenant_id": caller["tenant_id"],
|
||||
"app_id": caller["app_id"],
|
||||
"invoke_from": caller["invoke_from"],
|
||||
"error_code": exc.error_code,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
raise
|
||||
return _format_observation(response, self.config)
|
||||
return await self._retrieve_for_set(
|
||||
client=client,
|
||||
caller=caller,
|
||||
knowledge_set=knowledge_set,
|
||||
query=normalized_query,
|
||||
retryable_observation=True,
|
||||
)
|
||||
|
||||
async def prepare_tool_definition(_ctx: RunContext[object], tool_def: ToolDefinition) -> ToolDefinition:
|
||||
return ToolDefinition(
|
||||
name=tool_def.name,
|
||||
description=tool_def.description,
|
||||
parameters_json_schema=QUERY_TOOL_SCHEMA,
|
||||
parameters_json_schema=_tool_schema(generated_sets),
|
||||
strict=tool_def.strict,
|
||||
sequential=tool_def.sequential,
|
||||
metadata=tool_def.metadata,
|
||||
@@ -181,11 +159,177 @@ class DifyKnowledgeBaseLayer(PlainLayer[DifyKnowledgeBaseDeps, DifyKnowledgeBase
|
||||
knowledge_base_search,
|
||||
takes_ctx=True,
|
||||
name=_KNOWLEDGE_BASE_TOOL_NAME,
|
||||
description=_KNOWLEDGE_BASE_TOOL_DESCRIPTION,
|
||||
description=_tool_description(generated_sets),
|
||||
prepare=prepare_tool_definition,
|
||||
)
|
||||
]
|
||||
|
||||
@property
|
||||
@override
|
||||
def user_prompts(self) -> list[str]:
|
||||
"""Expose eager user-query results as an additional user prompt."""
|
||||
if not self.runtime_state.eager_results:
|
||||
return []
|
||||
|
||||
sections: list[str] = []
|
||||
for result in self.runtime_state.eager_results:
|
||||
sections.append(
|
||||
"\n".join(
|
||||
[
|
||||
f"Set: {result.set_name}",
|
||||
f"Query: {result.query}",
|
||||
"Results:",
|
||||
result.observation,
|
||||
]
|
||||
)
|
||||
)
|
||||
return ["Knowledge retrieval results:\n\n" + "\n\n".join(sections)]
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
await self._refresh_eager_results_if_needed()
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
await self._refresh_eager_results_if_needed()
|
||||
|
||||
def _generated_query_sets(self) -> list[DifyKnowledgeSetConfig]:
|
||||
return [knowledge_set for knowledge_set in self.config.sets if knowledge_set.query.mode == "generated_query"]
|
||||
|
||||
def _user_query_sets(self) -> list[DifyKnowledgeSetConfig]:
|
||||
return [knowledge_set for knowledge_set in self.config.sets if knowledge_set.query.mode == "user_query"]
|
||||
|
||||
async def _refresh_eager_results_if_needed(self) -> None:
|
||||
user_query_sets = self._user_query_sets()
|
||||
if not user_query_sets:
|
||||
self.runtime_state.eager_config_fingerprint = None
|
||||
self.runtime_state.eager_results = []
|
||||
return
|
||||
|
||||
fingerprint = _eager_config_fingerprint(user_query_sets)
|
||||
if self.runtime_state.eager_config_fingerprint == fingerprint:
|
||||
return
|
||||
|
||||
caller = _build_caller_context(self.deps.execution_context.config)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
client = DifyKnowledgeBaseClient(
|
||||
base_url=self.inner_api_url,
|
||||
api_key=self.inner_api_key,
|
||||
http_client=http_client,
|
||||
)
|
||||
eager_results: list[DifyKnowledgeEagerResult] = []
|
||||
for knowledge_set in user_query_sets:
|
||||
query = (knowledge_set.query.value or "").strip()
|
||||
try:
|
||||
response = await client.retrieve(
|
||||
tenant_id=caller["tenant_id"],
|
||||
user_id=caller["user_id"],
|
||||
app_id=caller["app_id"],
|
||||
user_from=caller["user_from"],
|
||||
invoke_from=caller["invoke_from"],
|
||||
dataset_ids=knowledge_set.dataset_ids,
|
||||
query=query,
|
||||
retrieval=knowledge_set.retrieval,
|
||||
metadata_filtering=knowledge_set.metadata_filtering,
|
||||
)
|
||||
except DifyKnowledgeBaseClientError as exc:
|
||||
if exc.retryable:
|
||||
logger.warning(
|
||||
"eager knowledge retrieval temporarily unavailable",
|
||||
extra={
|
||||
"tenant_id": caller["tenant_id"],
|
||||
"app_id": caller["app_id"],
|
||||
"invoke_from": caller["invoke_from"],
|
||||
"knowledge_set_id": knowledge_set.id,
|
||||
"error_code": exc.error_code,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
eager_results.append(
|
||||
DifyKnowledgeEagerResult(
|
||||
set_id=knowledge_set.id,
|
||||
set_name=knowledge_set.name,
|
||||
query=query,
|
||||
observation=TEMPORARY_UNAVAILABLE_OBSERVATION,
|
||||
status="temporarily_unavailable",
|
||||
)
|
||||
)
|
||||
continue
|
||||
logger.error(
|
||||
"eager knowledge retrieval failed",
|
||||
extra={
|
||||
"tenant_id": caller["tenant_id"],
|
||||
"app_id": caller["app_id"],
|
||||
"invoke_from": caller["invoke_from"],
|
||||
"knowledge_set_id": knowledge_set.id,
|
||||
"error_code": exc.error_code,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
eager_results.append(
|
||||
DifyKnowledgeEagerResult(
|
||||
set_id=knowledge_set.id,
|
||||
set_name=knowledge_set.name,
|
||||
query=query,
|
||||
observation=_format_observation(response, self.config, include_heading=False),
|
||||
status="success" if response.results else "empty",
|
||||
)
|
||||
)
|
||||
|
||||
self.runtime_state.eager_results = eager_results
|
||||
self.runtime_state.eager_config_fingerprint = fingerprint
|
||||
|
||||
async def _retrieve_for_set(
|
||||
self,
|
||||
*,
|
||||
client: DifyKnowledgeBaseClient,
|
||||
caller: dict[str, str],
|
||||
knowledge_set: DifyKnowledgeSetConfig,
|
||||
query: str,
|
||||
retryable_observation: bool,
|
||||
) -> str:
|
||||
try:
|
||||
response = await client.retrieve(
|
||||
tenant_id=caller["tenant_id"],
|
||||
user_id=caller["user_id"],
|
||||
app_id=caller["app_id"],
|
||||
user_from=caller["user_from"],
|
||||
invoke_from=caller["invoke_from"],
|
||||
dataset_ids=knowledge_set.dataset_ids,
|
||||
query=query,
|
||||
retrieval=knowledge_set.retrieval,
|
||||
metadata_filtering=knowledge_set.metadata_filtering,
|
||||
)
|
||||
except DifyKnowledgeBaseClientError as exc:
|
||||
if exc.retryable and retryable_observation:
|
||||
logger.warning(
|
||||
"knowledge base search temporarily unavailable",
|
||||
extra={
|
||||
"tenant_id": caller["tenant_id"],
|
||||
"app_id": caller["app_id"],
|
||||
"invoke_from": caller["invoke_from"],
|
||||
"knowledge_set_id": knowledge_set.id,
|
||||
"error_code": exc.error_code,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
return TEMPORARY_UNAVAILABLE_OBSERVATION
|
||||
logger.error(
|
||||
"knowledge base search failed",
|
||||
extra={
|
||||
"tenant_id": caller["tenant_id"],
|
||||
"app_id": caller["app_id"],
|
||||
"invoke_from": caller["invoke_from"],
|
||||
"knowledge_set_id": knowledge_set.id,
|
||||
"error_code": exc.error_code,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
)
|
||||
raise
|
||||
return _format_observation(response, self.config)
|
||||
|
||||
|
||||
def _build_caller_context(execution_context: object) -> dict[str, str]:
|
||||
"""Extract the inner-API caller identity from execution-context config.
|
||||
@@ -232,7 +376,56 @@ def _build_caller_context(execution_context: object) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _format_observation(response: DifyKnowledgeRetrieveResponse, config: DifyKnowledgeBaseLayerConfig) -> str:
|
||||
def _tool_schema(generated_sets: list[DifyKnowledgeSetConfig]) -> dict[str, object]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"set_name": {
|
||||
"type": "string",
|
||||
"enum": [knowledge_set.name for knowledge_set in generated_sets],
|
||||
"description": "Knowledge set to search.",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query for the selected knowledge set.",
|
||||
},
|
||||
},
|
||||
"required": ["set_name", "query"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _tool_description(generated_sets: list[DifyKnowledgeSetConfig]) -> str:
|
||||
set_descriptions = []
|
||||
for knowledge_set in generated_sets:
|
||||
if knowledge_set.description:
|
||||
set_descriptions.append(f"{knowledge_set.name}: {knowledge_set.description}")
|
||||
else:
|
||||
set_descriptions.append(knowledge_set.name)
|
||||
return f"{_KNOWLEDGE_BASE_TOOL_DESCRIPTION} Configured sets: {', '.join(set_descriptions)}."
|
||||
|
||||
|
||||
def _eager_config_fingerprint(user_query_sets: list[DifyKnowledgeSetConfig]) -> str:
|
||||
payload = [
|
||||
{
|
||||
"id": knowledge_set.id,
|
||||
"query": knowledge_set.query.model_dump(mode="json"),
|
||||
"dataset_ids": knowledge_set.dataset_ids,
|
||||
"retrieval": knowledge_set.retrieval.model_dump(mode="json"),
|
||||
"metadata_filtering": knowledge_set.metadata_filtering.model_dump(mode="json", by_alias=True),
|
||||
}
|
||||
for knowledge_set in user_query_sets
|
||||
]
|
||||
serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _format_observation(
|
||||
response: DifyKnowledgeRetrieveResponse,
|
||||
config: DifyKnowledgeBaseLayerConfig,
|
||||
*,
|
||||
include_heading: bool = True,
|
||||
) -> str:
|
||||
"""Render inner-API retrieval results into the model-visible tool response.
|
||||
|
||||
The formatting contract is intentionally simple and stable for the model:
|
||||
@@ -248,7 +441,7 @@ def _format_observation(response: DifyKnowledgeRetrieveResponse, config: DifyKno
|
||||
if not response.results:
|
||||
return NO_RESULTS_OBSERVATION
|
||||
|
||||
lines = ["Knowledge base search results:"]
|
||||
lines = ["Knowledge base search results:"] if include_heading else []
|
||||
for index, result in enumerate(response.results, start=1):
|
||||
metadata = result.metadata
|
||||
title = result.title or metadata.document_name or "Untitled"
|
||||
@@ -280,6 +473,5 @@ __all__ = [
|
||||
"DifyKnowledgeBaseDeps",
|
||||
"DifyKnowledgeBaseLayer",
|
||||
"NO_RESULTS_OBSERVATION",
|
||||
"QUERY_TOOL_SCHEMA",
|
||||
"TEMPORARY_UNAVAILABLE_OBSERVATION",
|
||||
]
|
||||
|
||||
@@ -6,46 +6,142 @@ from dify_agent.layers.knowledge import DifyKnowledgeBaseLayerConfig
|
||||
|
||||
def _valid_config() -> dict[str, object]:
|
||||
return {
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 4,
|
||||
},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 4,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_knowledge_base_config_accepts_valid_multiple_mode() -> None:
|
||||
config = DifyKnowledgeBaseLayerConfig.model_validate(_valid_config())
|
||||
|
||||
assert config.dataset_ids == ["dataset-1"]
|
||||
assert config.retrieval.top_k == 4
|
||||
assert config.metadata_filtering.mode == "disabled"
|
||||
assert config.sets[0].dataset_ids == ["dataset-1"]
|
||||
assert config.sets[0].retrieval.top_k == 4
|
||||
assert config.sets[0].metadata_filtering.mode == "disabled"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload, expected_message",
|
||||
[
|
||||
({"dataset_ids": [], "retrieval": {"mode": "multiple", "top_k": 4}}, "dataset_ids"),
|
||||
({"sets": []}, "sets"),
|
||||
({"tool_name": "knowledge_base_search", **_valid_config()}, "Extra inputs are not permitted"),
|
||||
({"tool_description": "Search knowledge", **_valid_config()}, "Extra inputs are not permitted"),
|
||||
({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "multiple"}}, "top_k"),
|
||||
({"dataset_ids": ["dataset-1"], "retrieval": {"mode": "single"}}, "retrieval.model"),
|
||||
(
|
||||
{
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"metadata_filtering": {"mode": "automatic"},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": ""}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
"dataset id",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "user_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
"query.value",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple"},
|
||||
}
|
||||
]
|
||||
},
|
||||
"top_k",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "single"},
|
||||
}
|
||||
]
|
||||
},
|
||||
"retrieval.model",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"metadata_filtering": {"mode": "automatic"},
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata_filtering.model_config",
|
||||
),
|
||||
(
|
||||
{
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"metadata_filtering": {"mode": "manual"},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"metadata_filtering": {"mode": "manual"},
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata_filtering.conditions",
|
||||
),
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
{
|
||||
"id": "docs",
|
||||
"name": "support kb",
|
||||
"datasets": [{"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
},
|
||||
]
|
||||
},
|
||||
"names must be unique",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_knowledge_base_config_rejects_invalid_inputs(payload: dict[str, object], expected_message: str) -> None:
|
||||
@@ -57,8 +153,7 @@ def test_knowledge_base_config_rejects_observation_limit_smaller_than_result_lim
|
||||
with pytest.raises(ValidationError, match="max_observation_chars"):
|
||||
_ = DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
**_valid_config(),
|
||||
"max_result_content_chars": 50,
|
||||
"max_observation_chars": 20,
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ from pydantic_ai import Tool
|
||||
from agenton.compositor import Compositor, LayerNode, LayerProvider
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.knowledge.client import DifyKnowledgeBaseClientError
|
||||
from dify_agent.layers.knowledge.client import (
|
||||
DifyKnowledgeBaseClient,
|
||||
DifyKnowledgeBaseClientError,
|
||||
DifyKnowledgeRetrieveResponse,
|
||||
)
|
||||
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.knowledge.layer import (
|
||||
BLANK_QUERY_OBSERVATION,
|
||||
@@ -32,10 +36,23 @@ def _execution_context_config(**overrides: object) -> DifyExecutionContextLayerC
|
||||
|
||||
|
||||
def _knowledge_config(**overrides: object) -> DifyKnowledgeBaseLayerConfig:
|
||||
payload: dict[str, object] = {
|
||||
"dataset_ids": ["dataset-1"],
|
||||
set_payload: dict[str, object] = {
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
for key in ("id", "name", "description", "datasets", "query", "retrieval", "metadata_filtering"):
|
||||
if key in overrides:
|
||||
set_payload[key] = overrides.pop(key)
|
||||
if "dataset_ids" in overrides:
|
||||
dataset_ids = overrides.pop("dataset_ids")
|
||||
assert isinstance(dataset_ids, list)
|
||||
set_payload["datasets"] = [{"id": dataset_id} for dataset_id in dataset_ids]
|
||||
payload: dict[str, object] = {
|
||||
"sets": [set_payload],
|
||||
}
|
||||
payload.update(overrides)
|
||||
return DifyKnowledgeBaseLayerConfig.model_validate(payload)
|
||||
|
||||
@@ -62,7 +79,7 @@ def _knowledge_provider() -> LayerProvider[DifyKnowledgeBaseLayer]:
|
||||
)
|
||||
|
||||
|
||||
def test_knowledge_layer_exposes_one_query_only_tool_definition() -> None:
|
||||
def test_knowledge_layer_exposes_one_set_scoped_tool_definition() -> None:
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
@@ -82,20 +99,23 @@ def test_knowledge_layer_exposes_one_query_only_tool_definition() -> None:
|
||||
tool_def = await tool.prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
assert isinstance(tool, Tool)
|
||||
assert tool.name == "knowledge_base_search"
|
||||
assert tool.description == "Search configured knowledge bases for information relevant to the query."
|
||||
assert "Pick one configured set_name" in tool.description
|
||||
assert tool_def is not None
|
||||
assert (
|
||||
tool_def.description == "Search configured knowledge bases for information relevant to the query."
|
||||
)
|
||||
assert "Pick one configured set_name" in tool_def.description
|
||||
assert tool_def.parameters_json_schema == {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"set_name": {
|
||||
"type": "string",
|
||||
"enum": ["Support KB"],
|
||||
"description": "Knowledge set to search.",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query for the configured knowledge bases.",
|
||||
}
|
||||
"description": "Search query for the selected knowledge set.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
"required": ["set_name", "query"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
@@ -119,12 +139,105 @@ def test_knowledge_layer_rejects_blank_query_locally() -> None:
|
||||
) as run:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call({"query": " "}, None) # pyright: ignore[reportArgumentType]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": " "}, None
|
||||
)
|
||||
assert result == BLANK_QUERY_OBSERVATION
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_knowledge_layer_exposes_no_tool_when_all_sets_are_user_query(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_retrieve(self: DifyKnowledgeBaseClient, **_kwargs: object) -> DifyKnowledgeRetrieveResponse:
|
||||
del self
|
||||
return DifyKnowledgeRetrieveResponse.model_validate({"results": [], "usage": {}})
|
||||
|
||||
monkeypatch.setattr(DifyKnowledgeBaseClient, "retrieve", fake_retrieve)
|
||||
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with compositor.enter(
|
||||
configs={
|
||||
"execution_context": _execution_context_config(),
|
||||
"knowledge": _knowledge_config(query={"mode": "user_query", "value": "release notes"}),
|
||||
}
|
||||
) as run:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
assert await knowledge_layer.get_tools(http_client=http_client) == []
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_knowledge_layer_fetches_user_query_sets_on_context_entry(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen_requests: list[dict[str, object]] = []
|
||||
|
||||
async def fake_retrieve(self: DifyKnowledgeBaseClient, **kwargs: object) -> DifyKnowledgeRetrieveResponse:
|
||||
del self
|
||||
seen_requests.append(kwargs)
|
||||
return DifyKnowledgeRetrieveResponse.model_validate(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"metadata": {
|
||||
"_source": "knowledge",
|
||||
"dataset_name": "Docs",
|
||||
"document_name": "Release.md",
|
||||
"score": 0.8,
|
||||
},
|
||||
"title": "Release",
|
||||
"files": [],
|
||||
"content": "Version notes",
|
||||
"summary": None,
|
||||
}
|
||||
],
|
||||
"usage": {},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(DifyKnowledgeBaseClient, "retrieve", fake_retrieve)
|
||||
|
||||
async def scenario() -> None:
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("execution_context", _execution_context_provider()),
|
||||
LayerNode("knowledge", _knowledge_provider(), deps={"execution_context": "execution_context"}),
|
||||
]
|
||||
)
|
||||
async with compositor.enter(
|
||||
configs={
|
||||
"execution_context": _execution_context_config(),
|
||||
"knowledge": _knowledge_config(query={"mode": "user_query", "value": "release notes"}),
|
||||
}
|
||||
) as run:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
assert len(seen_requests) == 1
|
||||
assert seen_requests[0]["query"] == "release notes"
|
||||
assert seen_requests[0]["dataset_ids"] == ["dataset-1"]
|
||||
assert knowledge_layer.runtime_state.eager_config_fingerprint
|
||||
assert knowledge_layer.runtime_state.eager_results[0].status == "success"
|
||||
assert knowledge_layer.user_prompts == [
|
||||
"Knowledge retrieval results:\n\n"
|
||||
"Set: Support KB\n"
|
||||
"Query: release notes\n"
|
||||
"Results:\n"
|
||||
"1. Title: Release\n"
|
||||
" Dataset: Docs\n"
|
||||
" Document: Release.md\n"
|
||||
" Score: 0.8\n"
|
||||
" Content: Version notes"
|
||||
]
|
||||
await knowledge_layer.on_context_resume()
|
||||
assert len(seen_requests) == 1
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "field_value"),
|
||||
[
|
||||
@@ -199,7 +312,9 @@ def test_knowledge_layer_formats_results_and_truncates_observation() -> None:
|
||||
) as run:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
)
|
||||
assert result.startswith("Knowledge base search results:\n1. Title: Guide")
|
||||
assert "Dataset: Docs" in result
|
||||
assert "Document: Guide.md" in result
|
||||
@@ -229,7 +344,9 @@ def test_knowledge_layer_returns_no_results_observation() -> None:
|
||||
) as run:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
)
|
||||
assert result == NO_RESULTS_OBSERVATION
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -256,7 +373,9 @@ def test_knowledge_layer_converts_retryable_failures_into_observation() -> None:
|
||||
) as run:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
)
|
||||
assert result == TEMPORARY_UNAVAILABLE_OBSERVATION
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -289,7 +408,9 @@ def test_knowledge_layer_converts_retryable_transport_failures_into_observation(
|
||||
) as run:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
)
|
||||
assert result == TEMPORARY_UNAVAILABLE_OBSERVATION
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -317,7 +438,9 @@ def test_knowledge_layer_raises_non_retryable_client_errors() -> None:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
with pytest.raises(DifyKnowledgeBaseClientError) as exc_info:
|
||||
await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
|
||||
await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -343,7 +466,9 @@ def test_knowledge_layer_raises_for_malformed_success_responses() -> None:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
with pytest.raises(DifyKnowledgeBaseClientError) as exc_info:
|
||||
await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
|
||||
await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
)
|
||||
assert exc_info.value.error_code == "invalid_response"
|
||||
assert exc_info.value.retryable is False
|
||||
|
||||
@@ -411,7 +536,9 @@ def test_knowledge_layer_sends_execution_context_and_static_config_to_inner_api(
|
||||
) as run:
|
||||
knowledge_layer = run.get_layer("knowledge", DifyKnowledgeBaseLayer)
|
||||
tool = (await knowledge_layer.get_tools(http_client=http_client))[0]
|
||||
result = await tool.function_schema.call({"query": "reset"}, None) # pyright: ignore[reportArgumentType]
|
||||
result = await tool.function_schema.call( # pyright: ignore[reportArgumentType]
|
||||
{"set_name": "Support KB", "query": "reset"}, None
|
||||
)
|
||||
assert result == NO_RESULTS_OBSERVATION
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -995,7 +995,7 @@ def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(self: DifyKnowledgeBaseLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
assert self.config.dataset_ids == ["dataset-1"]
|
||||
assert self.config.sets[0].dataset_ids == ["dataset-1"]
|
||||
assert http_client.headers.get("X-Test-Client") == "dify-api"
|
||||
return [Tool(knowledge_tool, name="knowledge_base_search")]
|
||||
|
||||
@@ -1055,8 +1055,15 @@ def test_runner_passes_dynamic_dify_knowledge_tools_to_agent(monkeypatch: pytest
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
),
|
||||
|
||||
@@ -231,8 +231,15 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
knowledge_layer = knowledge_provider.create_layer(
|
||||
DifyKnowledgeBaseLayerConfig.model_validate(
|
||||
{
|
||||
"dataset_ids": ["dataset-1"],
|
||||
"retrieval": {"mode": "multiple", "top_k": 2},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 2},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextAgentMode', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig', 'DifyExecutionContextUserFrom']",
|
||||
"assert dify_agent_layers_ask_human.__all__ == ['AskHumanAction', 'AskHumanActionStyle', 'AskHumanField', 'AskHumanFieldType', 'AskHumanFileField', 'AskHumanFileListField', 'AskHumanParagraphField', 'AskHumanResultStatus', 'AskHumanSelectField', 'AskHumanSelectOption', 'AskHumanSelectedAction', 'AskHumanToolArgs', 'AskHumanToolResult', 'AskHumanUrgency', 'DEFAULT_ASK_HUMAN_TOOL_DESCRIPTION', 'DIFY_ASK_HUMAN_LAYER_TYPE_ID', 'DifyAskHumanLayerConfig']",
|
||||
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",
|
||||
"assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig']",
|
||||
"assert dify_agent_layers_knowledge.__all__ == ['DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID', 'DifyKnowledgeBaseLayerConfig', 'DifyKnowledgeDatasetConfig', 'DifyKnowledgeEagerResult', 'DifyKnowledgeMetadataCondition', 'DifyKnowledgeMetadataConditions', 'DifyKnowledgeMetadataFilteringConfig', 'DifyKnowledgeModelConfig', 'DifyKnowledgeQueryConfig', 'DifyKnowledgeRerankingModelConfig', 'DifyKnowledgeRetrievalConfig', 'DifyKnowledgeRuntimeState', 'DifyKnowledgeSetConfig']",
|
||||
"assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']",
|
||||
"assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellCliToolConfig', 'DifyShellEnvVarConfig', 'DifyShellLayerConfig', 'DifyShellSandboxConfig', 'DifyShellSecretRefConfig']",
|
||||
],
|
||||
|
||||
@@ -878,11 +878,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/app/overview/customize/index.tsx": {
|
||||
"jsx-a11y/anchor-has-content": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/app/overview/settings/index.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
|
||||
@@ -46,7 +46,7 @@ export type AgentAppDetailWithSite = {
|
||||
name: string
|
||||
permission_keys?: Array<string>
|
||||
role?: string | null
|
||||
site?: Site | null
|
||||
site?: AppDetailSiteResponse | null
|
||||
tags?: Array<Tag>
|
||||
tracing?: JsonValue | null
|
||||
updated_at?: number | null
|
||||
@@ -413,21 +413,31 @@ export type ModelConfig = {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type Site = {
|
||||
export type AppDetailSiteResponse = {
|
||||
access_token?: string | null
|
||||
app_base_url?: string | null
|
||||
chat_color_theme?: string | null
|
||||
chat_color_theme_inverted: boolean
|
||||
chat_color_theme_inverted?: boolean | null
|
||||
code?: string | null
|
||||
copyright?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
custom_disclaimer?: string | null
|
||||
default_language: string
|
||||
customize_domain?: string | null
|
||||
customize_token_strategy?: string | null
|
||||
default_language?: string | null
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
icon_type?: string | IconType | null
|
||||
readonly icon_url: string | null
|
||||
privacy_policy?: string | null
|
||||
show_workflow_steps: boolean
|
||||
title: string
|
||||
use_icon_as_answer_icon: boolean
|
||||
prompt_public?: boolean | null
|
||||
show_workflow_steps?: boolean | null
|
||||
title?: string | null
|
||||
updated_at?: number | null
|
||||
updated_by?: string | null
|
||||
use_icon_as_answer_icon?: boolean | null
|
||||
}
|
||||
|
||||
export type Tag = {
|
||||
@@ -502,8 +512,12 @@ export type AgentConfigSnapshotSummaryResponse = {
|
||||
export type AgentComposerAgentResponse = {
|
||||
active_config_snapshot_id?: string | null
|
||||
description: string
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
id: string
|
||||
name: string
|
||||
role?: string | null
|
||||
scope: AgentScope
|
||||
status: AgentStatus
|
||||
}
|
||||
@@ -570,7 +584,7 @@ export type AgentComposerSoulCandidatesResponse = {
|
||||
cli_tools?: Array<AgentCliToolConfig>
|
||||
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
|
||||
human_contacts?: Array<AgentHumanContactConfig>
|
||||
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
|
||||
knowledge_sets?: Array<AgentComposerKnowledgeSetCandidateResponse>
|
||||
}
|
||||
|
||||
export type ComposerCandidateCapabilities = {
|
||||
@@ -931,9 +945,7 @@ export type AgentSoulHumanConfig = {
|
||||
}
|
||||
|
||||
export type AgentSoulKnowledgeConfig = {
|
||||
datasets?: Array<AgentKnowledgeDatasetConfig>
|
||||
query_config?: AgentKnowledgeQueryConfig
|
||||
query_mode?: AgentKnowledgeQueryMode | null
|
||||
sets?: Array<AgentKnowledgeSetConfig>
|
||||
}
|
||||
|
||||
export type AgentSoulMemoryConfig = {
|
||||
@@ -1074,11 +1086,12 @@ export type AgentComposerDifyToolCandidateResponse = {
|
||||
tools_count?: number | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeDatasetConfig = {
|
||||
export type AgentComposerKnowledgeSetCandidateResponse = {
|
||||
datasets?: Array<AgentComposerKnowledgeDatasetCandidateResponse>
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
[key: string]: unknown
|
||||
id: string
|
||||
missing_dataset_ids?: Array<string>
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AgentModerationProviderConfig = {
|
||||
@@ -1233,16 +1246,16 @@ export type AgentHumanToolConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryConfig = {
|
||||
query?: string | null
|
||||
score_threshold?: number | null
|
||||
score_threshold_enabled?: boolean | null
|
||||
top_k?: number | null
|
||||
[key: string]: unknown
|
||||
export type AgentKnowledgeSetConfig = {
|
||||
datasets: Array<AgentKnowledgeDatasetConfig>
|
||||
description?: string | null
|
||||
id: string
|
||||
metadata_filtering?: AgentKnowledgeMetadataFilteringConfig
|
||||
name: string
|
||||
query: AgentKnowledgeQueryConfig
|
||||
retrieval: AgentKnowledgeRetrievalConfig
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
|
||||
|
||||
export type AgentMemoryArtifactConfig = {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
@@ -1381,6 +1394,13 @@ export type AgentPermissionConfig = {
|
||||
|
||||
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
|
||||
|
||||
export type AgentComposerKnowledgeDatasetCandidateResponse = {
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
missing?: boolean
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export type AgentModerationIoConfig = {
|
||||
enabled?: boolean
|
||||
preset_response?: string | null
|
||||
@@ -1409,6 +1429,34 @@ export type FormInputConfig
|
||||
|
||||
export type JsonValue2 = unknown
|
||||
|
||||
export type AgentKnowledgeDatasetConfig = {
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeMetadataFilteringConfig = {
|
||||
conditions?: AgentKnowledgeMetadataConditions | null
|
||||
mode?: 'automatic' | 'disabled' | 'manual'
|
||||
model_config?: AgentKnowledgeModelConfig | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryConfig = {
|
||||
mode: AgentKnowledgeQueryMode
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeRetrievalConfig = {
|
||||
mode: 'multiple' | 'single'
|
||||
model?: AgentKnowledgeModelConfig | null
|
||||
reranking_enable?: boolean
|
||||
reranking_mode?: string
|
||||
reranking_model?: AgentKnowledgeRerankingModelConfig | null
|
||||
score_threshold?: number | null
|
||||
top_k?: number | null
|
||||
weights?: AgentKnowledgeWeightedScoreConfig | null
|
||||
}
|
||||
|
||||
export type AgentModelResponseFormatConfig = {
|
||||
type?: string | null
|
||||
[key: string]: unknown
|
||||
@@ -1459,6 +1507,38 @@ export type FileListInputConfig = {
|
||||
type?: 'file-list'
|
||||
}
|
||||
|
||||
export type AgentKnowledgeMetadataConditions = {
|
||||
conditions?: Array<AgentKnowledgeMetadataCondition>
|
||||
logical_operator?: 'and' | 'or'
|
||||
}
|
||||
|
||||
export type AgentKnowledgeModelConfig = {
|
||||
completion_params?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
|
||||
|
||||
export type AgentKnowledgeRerankingModelConfig = {
|
||||
model: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type AgentKnowledgeWeightedScoreConfig = {
|
||||
keyword_setting?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
vector_setting?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
weight_type?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type StringSource = {
|
||||
selector?: Array<string>
|
||||
type: ValueSourceType
|
||||
@@ -1475,6 +1555,30 @@ export type FileType = 'audio' | 'custom' | 'document' | 'image' | 'video'
|
||||
|
||||
export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url' | 'tool_file'
|
||||
|
||||
export type AgentKnowledgeMetadataCondition = {
|
||||
comparison_operator:
|
||||
| '<'
|
||||
| '='
|
||||
| '>'
|
||||
| 'after'
|
||||
| 'before'
|
||||
| 'contains'
|
||||
| 'empty'
|
||||
| 'end with'
|
||||
| 'in'
|
||||
| 'is'
|
||||
| 'is not'
|
||||
| 'not contains'
|
||||
| 'not empty'
|
||||
| 'not in'
|
||||
| 'start with'
|
||||
| '≠'
|
||||
| '≤'
|
||||
| '≥'
|
||||
name: string
|
||||
value?: string | Array<string> | number | null
|
||||
}
|
||||
|
||||
export type ValueSourceType = 'constant' | 'variable'
|
||||
|
||||
export type AgentAppPaginationWritable = {
|
||||
@@ -1509,7 +1613,7 @@ export type AgentAppDetailWithSiteWritable = {
|
||||
name: string
|
||||
permission_keys?: Array<string>
|
||||
role?: string | null
|
||||
site?: SiteWritable | null
|
||||
site?: AppDetailSiteResponseWritable | null
|
||||
tags?: Array<Tag>
|
||||
tracing?: JsonValue | null
|
||||
updated_at?: number | null
|
||||
@@ -1551,20 +1655,30 @@ export type AgentAppPartialWritable = {
|
||||
workflow?: WorkflowPartial | null
|
||||
}
|
||||
|
||||
export type SiteWritable = {
|
||||
export type AppDetailSiteResponseWritable = {
|
||||
access_token?: string | null
|
||||
app_base_url?: string | null
|
||||
chat_color_theme?: string | null
|
||||
chat_color_theme_inverted: boolean
|
||||
chat_color_theme_inverted?: boolean | null
|
||||
code?: string | null
|
||||
copyright?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
custom_disclaimer?: string | null
|
||||
default_language: string
|
||||
customize_domain?: string | null
|
||||
customize_token_strategy?: string | null
|
||||
default_language?: string | null
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
icon_type?: string | IconType | null
|
||||
privacy_policy?: string | null
|
||||
show_workflow_steps: boolean
|
||||
title: string
|
||||
use_icon_as_answer_icon: boolean
|
||||
prompt_public?: boolean | null
|
||||
show_workflow_steps?: boolean | null
|
||||
title?: string | null
|
||||
updated_at?: number | null
|
||||
updated_by?: string | null
|
||||
use_icon_as_answer_icon?: boolean | null
|
||||
}
|
||||
|
||||
export type GetAgentData = {
|
||||
|
||||
@@ -190,23 +190,33 @@ export const zDeletedTool = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Site
|
||||
* AppDetailSiteResponse
|
||||
*/
|
||||
export const zSite = z.object({
|
||||
export const zAppDetailSiteResponse = z.object({
|
||||
access_token: z.string().nullish(),
|
||||
app_base_url: z.string().nullish(),
|
||||
chat_color_theme: z.string().nullish(),
|
||||
chat_color_theme_inverted: z.boolean(),
|
||||
chat_color_theme_inverted: z.boolean().nullish(),
|
||||
code: z.string().nullish(),
|
||||
copyright: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
custom_disclaimer: z.string().nullish(),
|
||||
default_language: z.string(),
|
||||
customize_domain: z.string().nullish(),
|
||||
customize_token_strategy: z.string().nullish(),
|
||||
default_language: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
icon_type: z.union([z.string(), zIconType]).nullish(),
|
||||
icon_url: z.string().nullable(),
|
||||
privacy_policy: z.string().nullish(),
|
||||
show_workflow_steps: z.boolean(),
|
||||
title: z.string(),
|
||||
use_icon_as_answer_icon: z.boolean(),
|
||||
prompt_public: z.boolean().nullish(),
|
||||
show_workflow_steps: z.boolean().nullish(),
|
||||
title: z.string().nullish(),
|
||||
updated_at: z.int().nullish(),
|
||||
updated_by: z.string().nullish(),
|
||||
use_icon_as_answer_icon: z.boolean().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -818,7 +828,7 @@ export const zAgentAppDetailWithSite = z.object({
|
||||
name: z.string(),
|
||||
permission_keys: z.array(z.string()).optional(),
|
||||
role: z.string().nullish(),
|
||||
site: zSite.nullish(),
|
||||
site: zAppDetailSiteResponse.nullish(),
|
||||
tags: z.array(zTag).optional(),
|
||||
tracing: zJsonValue.nullish(),
|
||||
updated_at: z.int().nullish(),
|
||||
@@ -926,8 +936,12 @@ export const zAgentInviteOptionsResponse = z.object({
|
||||
export const zAgentComposerAgentResponse = z.object({
|
||||
active_config_snapshot_id: z.string().nullish(),
|
||||
description: z.string(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string().nullish(),
|
||||
scope: zAgentScope,
|
||||
status: zAgentStatus,
|
||||
})
|
||||
@@ -1022,15 +1036,6 @@ export const zAgentComposerDifyToolCandidateResponse = z.object({
|
||||
tools_count: z.int().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeDatasetConfig
|
||||
*/
|
||||
export const zAgentKnowledgeDatasetConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleAccount
|
||||
*/
|
||||
@@ -1279,30 +1284,6 @@ export const zAgentSoulHumanConfig = z.object({
|
||||
tools: z.array(zAgentHumanToolConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryConfig
|
||||
*/
|
||||
export const zAgentKnowledgeQueryConfig = z.object({
|
||||
query: z.string().nullish(),
|
||||
score_threshold: z.number().gte(0).lte(1).nullish(),
|
||||
score_threshold_enabled: z.boolean().nullish(),
|
||||
top_k: z.int().gte(1).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryMode
|
||||
*/
|
||||
export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'])
|
||||
|
||||
/**
|
||||
* AgentSoulKnowledgeConfig
|
||||
*/
|
||||
export const zAgentSoulKnowledgeConfig = z.object({
|
||||
datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
query_config: zAgentKnowledgeQueryConfig.optional(),
|
||||
query_mode: zAgentKnowledgeQueryMode.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentMemoryArtifactConfig
|
||||
*/
|
||||
@@ -1521,6 +1502,27 @@ export const zAgentCliToolConfig = z.object({
|
||||
tool_name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerKnowledgeDatasetCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerKnowledgeDatasetCandidateResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
missing: z.boolean().optional().default(false),
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerKnowledgeSetCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerKnowledgeSetCandidateResponse = z.object({
|
||||
datasets: z.array(zAgentComposerKnowledgeDatasetCandidateResponse).optional(),
|
||||
description: z.string().nullish(),
|
||||
id: z.string(),
|
||||
missing_dataset_ids: z.array(z.string()).optional(),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerSoulCandidatesResponse
|
||||
*/
|
||||
@@ -1528,7 +1530,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
|
||||
cli_tools: z.array(zAgentCliToolConfig).optional(),
|
||||
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
knowledge_sets: z.array(zAgentComposerKnowledgeSetCandidateResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1583,6 +1585,15 @@ export const zHumanInputFormSubmissionData = z.object({
|
||||
submitted_data: z.record(z.string(), zJsonValue2).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeDatasetConfig
|
||||
*/
|
||||
export const zAgentKnowledgeDatasetConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentModelResponseFormatConfig
|
||||
*/
|
||||
@@ -1733,53 +1744,6 @@ export const zAgentSoulToolsConfig = z.object({
|
||||
dify_tools: z.array(zAgentSoulDifyToolConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
*/
|
||||
export const zAgentSoulConfig = z.object({
|
||||
app_features: zAgentSoulAppFeaturesConfig.optional(),
|
||||
app_variables: z.array(zAppVariableConfig).optional(),
|
||||
env: zAgentSoulEnvConfig.optional(),
|
||||
human: zAgentSoulHumanConfig.optional(),
|
||||
knowledge: zAgentSoulKnowledgeConfig.optional(),
|
||||
memory: zAgentSoulMemoryConfig.optional(),
|
||||
misc_legacy: zAgentSoulAppFeaturesConfig.optional(),
|
||||
model: zAgentSoulModelConfig.nullish(),
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentAppComposerResponse
|
||||
*/
|
||||
export const zAgentAppComposerResponse = z.object({
|
||||
active_config_snapshot: zAgentConfigSnapshotSummaryResponse,
|
||||
agent: zAgentComposerAgentResponse,
|
||||
agent_soul: zAgentSoulConfig,
|
||||
save_options: z.array(zComposerSaveStrategy),
|
||||
validation: zComposerValidationFindingsResponse.nullish(),
|
||||
variant: z.literal('agent_app'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentConfigSnapshotDetailResponse
|
||||
*/
|
||||
export const zAgentConfigSnapshotDetailResponse = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
config_snapshot: zAgentSoulConfig,
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
display_version: z.int().nullish(),
|
||||
id: z.string(),
|
||||
revisions: z.array(zAgentConfigRevisionResponse).optional(),
|
||||
snapshot_version: z.int().nullish(),
|
||||
summary: z.string().nullish(),
|
||||
version: z.int(),
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* OutputErrorStrategy
|
||||
*
|
||||
@@ -1869,27 +1833,6 @@ export const zWorkflowNodeJobConfig = z.object({
|
||||
workflow_prompt: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerSavePayload
|
||||
*/
|
||||
export const zComposerSavePayload = z.object({
|
||||
agent_soul: zAgentSoulConfig.nullish(),
|
||||
binding: zComposerBindingPayload.nullish(),
|
||||
client_revision_id: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().max(255).nullish(),
|
||||
icon_background: z.string().max(255).nullish(),
|
||||
icon_type: zAgentIconType.nullish(),
|
||||
idempotency_key: z.string().nullish(),
|
||||
new_agent_name: z.string().min(1).max(255).nullish(),
|
||||
node_job: zWorkflowNodeJobConfig.nullish(),
|
||||
role: z.string().max(255).nullish(),
|
||||
save_strategy: zComposerSaveStrategy,
|
||||
soul_lock: zComposerSoulLockPayload.optional(),
|
||||
variant: zComposerVariant,
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ButtonStyle
|
||||
*
|
||||
@@ -1908,6 +1851,73 @@ export const zUserActionConfig = z.object({
|
||||
title: z.string().max(100),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeModelConfig
|
||||
*/
|
||||
export const zAgentKnowledgeModelConfig = z.object({
|
||||
completion_params: z.record(z.string(), z.unknown()).optional(),
|
||||
mode: z.string().min(1).max(64),
|
||||
name: z.string().min(1).max(255),
|
||||
provider: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryMode
|
||||
*/
|
||||
export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'])
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryConfig
|
||||
*
|
||||
* Per-set query policy for Agent v2 knowledge retrieval.
|
||||
*
|
||||
* Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
* legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
* set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
* ``value`` while ``generated_query`` leaves that value empty.
|
||||
*/
|
||||
export const zAgentKnowledgeQueryConfig = z.object({
|
||||
mode: zAgentKnowledgeQueryMode,
|
||||
value: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeRerankingModelConfig
|
||||
*/
|
||||
export const zAgentKnowledgeRerankingModelConfig = z.object({
|
||||
model: z.string().min(1).max(255),
|
||||
provider: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeWeightedScoreConfig
|
||||
*/
|
||||
export const zAgentKnowledgeWeightedScoreConfig = z.object({
|
||||
keyword_setting: z.record(z.string(), z.unknown()).nullish(),
|
||||
vector_setting: z.record(z.string(), z.unknown()).nullish(),
|
||||
weight_type: z.string().max(64).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeRetrievalConfig
|
||||
*
|
||||
* Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
*
|
||||
* Retrieval settings now live on each knowledge set instead of one shared
|
||||
* flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
* ``single`` retrieval with a required model config.
|
||||
*/
|
||||
export const zAgentKnowledgeRetrievalConfig = z.object({
|
||||
mode: z.enum(['multiple', 'single']),
|
||||
model: zAgentKnowledgeModelConfig.nullish(),
|
||||
reranking_enable: z.boolean().optional().default(true),
|
||||
reranking_mode: z.string().optional().default('reranking_model'),
|
||||
reranking_model: zAgentKnowledgeRerankingModelConfig.nullish(),
|
||||
score_threshold: z.number().gte(0).lte(1).nullish(),
|
||||
top_k: z.int().gte(1).nullish(),
|
||||
weights: zAgentKnowledgeWeightedScoreConfig.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* FileType
|
||||
*/
|
||||
@@ -1946,6 +1956,160 @@ export const zFileListInputConfig = z.object({
|
||||
type: z.literal('file-list').optional().default('file-list'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeMetadataCondition
|
||||
*/
|
||||
export const zAgentKnowledgeMetadataCondition = z.object({
|
||||
comparison_operator: z.enum([
|
||||
'<',
|
||||
'=',
|
||||
'>',
|
||||
'after',
|
||||
'before',
|
||||
'contains',
|
||||
'empty',
|
||||
'end with',
|
||||
'in',
|
||||
'is',
|
||||
'is not',
|
||||
'not contains',
|
||||
'not empty',
|
||||
'not in',
|
||||
'start with',
|
||||
'≠',
|
||||
'≤',
|
||||
'≥',
|
||||
]),
|
||||
name: z.string().min(1).max(255),
|
||||
value: z.union([z.string(), z.array(z.string()), z.number()]).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeMetadataConditions
|
||||
*/
|
||||
export const zAgentKnowledgeMetadataConditions = z.object({
|
||||
conditions: z.array(zAgentKnowledgeMetadataCondition).optional(),
|
||||
logical_operator: z.enum(['and', 'or']).optional().default('and'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeMetadataFilteringConfig
|
||||
*
|
||||
* Per-set metadata filtering policy.
|
||||
*
|
||||
* The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
* model belongs to metadata filtering specifically, while the external API and
|
||||
* generated schema keep the historical ``model_config`` field name via alias.
|
||||
*/
|
||||
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
|
||||
conditions: zAgentKnowledgeMetadataConditions.nullish(),
|
||||
mode: z.enum(['automatic', 'disabled', 'manual']).optional().default('disabled'),
|
||||
model_config: zAgentKnowledgeModelConfig.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeSetConfig
|
||||
*
|
||||
* One explicit knowledge set in Agent v2.
|
||||
*
|
||||
* ``knowledge.sets`` replaces the old flat knowledge config. Each set owns
|
||||
* its datasets plus query, retrieval, and metadata policies. An individual
|
||||
* set must contain at least one dataset id even though the overall knowledge
|
||||
* section may be empty, which is how callers express "no knowledge layer".
|
||||
*/
|
||||
export const zAgentKnowledgeSetConfig = z.object({
|
||||
datasets: z.array(zAgentKnowledgeDatasetConfig),
|
||||
description: z.string().nullish(),
|
||||
id: z.string().min(1).max(255),
|
||||
metadata_filtering: zAgentKnowledgeMetadataFilteringConfig.optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
query: zAgentKnowledgeQueryConfig,
|
||||
retrieval: zAgentKnowledgeRetrievalConfig,
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulKnowledgeConfig
|
||||
*
|
||||
* Top-level Agent v2 knowledge config.
|
||||
*
|
||||
* Agent v2 models knowledge as explicit sets instead of one flat
|
||||
* ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
|
||||
* list means no knowledge layer should be emitted at runtime, while set-name
|
||||
* uniqueness stays case-insensitive because runtime selection addresses sets
|
||||
* by name.
|
||||
*/
|
||||
export const zAgentSoulKnowledgeConfig = z.object({
|
||||
sets: z.array(zAgentKnowledgeSetConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
*/
|
||||
export const zAgentSoulConfig = z.object({
|
||||
app_features: zAgentSoulAppFeaturesConfig.optional(),
|
||||
app_variables: z.array(zAppVariableConfig).optional(),
|
||||
env: zAgentSoulEnvConfig.optional(),
|
||||
human: zAgentSoulHumanConfig.optional(),
|
||||
knowledge: zAgentSoulKnowledgeConfig.optional(),
|
||||
memory: zAgentSoulMemoryConfig.optional(),
|
||||
misc_legacy: zAgentSoulAppFeaturesConfig.optional(),
|
||||
model: zAgentSoulModelConfig.nullish(),
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentAppComposerResponse
|
||||
*/
|
||||
export const zAgentAppComposerResponse = z.object({
|
||||
active_config_snapshot: zAgentConfigSnapshotSummaryResponse,
|
||||
agent: zAgentComposerAgentResponse,
|
||||
agent_soul: zAgentSoulConfig,
|
||||
save_options: z.array(zComposerSaveStrategy),
|
||||
validation: zComposerValidationFindingsResponse.nullish(),
|
||||
variant: z.literal('agent_app'),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerSavePayload
|
||||
*/
|
||||
export const zComposerSavePayload = z.object({
|
||||
agent_soul: zAgentSoulConfig.nullish(),
|
||||
binding: zComposerBindingPayload.nullish(),
|
||||
client_revision_id: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().max(255).nullish(),
|
||||
icon_background: z.string().max(255).nullish(),
|
||||
icon_type: zAgentIconType.nullish(),
|
||||
idempotency_key: z.string().nullish(),
|
||||
new_agent_name: z.string().min(1).max(255).nullish(),
|
||||
node_job: zWorkflowNodeJobConfig.nullish(),
|
||||
role: z.string().max(255).nullish(),
|
||||
save_strategy: zComposerSaveStrategy,
|
||||
soul_lock: zComposerSoulLockPayload.optional(),
|
||||
variant: zComposerVariant,
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentConfigSnapshotDetailResponse
|
||||
*/
|
||||
export const zAgentConfigSnapshotDetailResponse = z.object({
|
||||
agent_id: z.string().nullish(),
|
||||
config_snapshot: zAgentSoulConfig,
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
display_version: z.int().nullish(),
|
||||
id: z.string(),
|
||||
revisions: z.array(zAgentConfigRevisionResponse).optional(),
|
||||
snapshot_version: z.int().nullish(),
|
||||
summary: z.string().nullish(),
|
||||
version: z.int(),
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ValueSourceType
|
||||
*
|
||||
@@ -2115,22 +2279,32 @@ export const zAgentAppPaginationWritable = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Site
|
||||
* AppDetailSiteResponse
|
||||
*/
|
||||
export const zSiteWritable = z.object({
|
||||
export const zAppDetailSiteResponseWritable = z.object({
|
||||
access_token: z.string().nullish(),
|
||||
app_base_url: z.string().nullish(),
|
||||
chat_color_theme: z.string().nullish(),
|
||||
chat_color_theme_inverted: z.boolean(),
|
||||
chat_color_theme_inverted: z.boolean().nullish(),
|
||||
code: z.string().nullish(),
|
||||
copyright: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
custom_disclaimer: z.string().nullish(),
|
||||
default_language: z.string(),
|
||||
customize_domain: z.string().nullish(),
|
||||
customize_token_strategy: z.string().nullish(),
|
||||
default_language: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
icon_type: z.union([z.string(), zIconType]).nullish(),
|
||||
privacy_policy: z.string().nullish(),
|
||||
show_workflow_steps: z.boolean(),
|
||||
title: z.string(),
|
||||
use_icon_as_answer_icon: z.boolean(),
|
||||
prompt_public: z.boolean().nullish(),
|
||||
show_workflow_steps: z.boolean().nullish(),
|
||||
title: z.string().nullish(),
|
||||
updated_at: z.int().nullish(),
|
||||
updated_by: z.string().nullish(),
|
||||
use_icon_as_answer_icon: z.boolean().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -2160,7 +2334,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({
|
||||
name: z.string(),
|
||||
permission_keys: z.array(z.string()).optional(),
|
||||
role: z.string().nullish(),
|
||||
site: zSiteWritable.nullish(),
|
||||
site: zAppDetailSiteResponseWritable.nullish(),
|
||||
tags: z.array(zTag).optional(),
|
||||
tracing: zJsonValue.nullish(),
|
||||
updated_at: z.int().nullish(),
|
||||
|
||||
@@ -43,7 +43,7 @@ export type AppDetailWithSite = {
|
||||
model_config?: ModelConfig | null
|
||||
name: string
|
||||
permission_keys?: Array<string>
|
||||
site?: Site | null
|
||||
site?: AppDetailSiteResponse | null
|
||||
tags?: Array<Tag>
|
||||
tracing?: JsonValue | null
|
||||
updated_at?: number | null
|
||||
@@ -1231,21 +1231,31 @@ export type ModelConfig = {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type Site = {
|
||||
export type AppDetailSiteResponse = {
|
||||
access_token?: string | null
|
||||
app_base_url?: string | null
|
||||
chat_color_theme?: string | null
|
||||
chat_color_theme_inverted: boolean
|
||||
chat_color_theme_inverted?: boolean | null
|
||||
code?: string | null
|
||||
copyright?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
custom_disclaimer?: string | null
|
||||
default_language: string
|
||||
customize_domain?: string | null
|
||||
customize_token_strategy?: string | null
|
||||
default_language?: string | null
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
icon_type?: string | IconType | null
|
||||
readonly icon_url: string | null
|
||||
privacy_policy?: string | null
|
||||
show_workflow_steps: boolean
|
||||
title: string
|
||||
use_icon_as_answer_icon: boolean
|
||||
prompt_public?: boolean | null
|
||||
show_workflow_steps?: boolean | null
|
||||
title?: string | null
|
||||
updated_at?: number | null
|
||||
updated_by?: string | null
|
||||
use_icon_as_answer_icon?: boolean | null
|
||||
}
|
||||
|
||||
export type Tag = {
|
||||
@@ -1788,8 +1798,12 @@ export type AgentConfigSnapshotSummaryResponse = {
|
||||
export type AgentComposerAgentResponse = {
|
||||
active_config_snapshot_id?: string | null
|
||||
description: string
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
id: string
|
||||
name: string
|
||||
role?: string | null
|
||||
scope: AgentScope
|
||||
status: AgentStatus
|
||||
}
|
||||
@@ -1903,7 +1917,7 @@ export type AgentComposerSoulCandidatesResponse = {
|
||||
cli_tools?: Array<AgentCliToolConfig>
|
||||
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
|
||||
human_contacts?: Array<AgentHumanContactConfig>
|
||||
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
|
||||
knowledge_sets?: Array<AgentComposerKnowledgeSetCandidateResponse>
|
||||
}
|
||||
|
||||
export type ComposerCandidateCapabilities = {
|
||||
@@ -2137,9 +2151,7 @@ export type AgentSoulHumanConfig = {
|
||||
}
|
||||
|
||||
export type AgentSoulKnowledgeConfig = {
|
||||
datasets?: Array<AgentKnowledgeDatasetConfig>
|
||||
query_config?: AgentKnowledgeQueryConfig
|
||||
query_mode?: AgentKnowledgeQueryMode | null
|
||||
sets?: Array<AgentKnowledgeSetConfig>
|
||||
}
|
||||
|
||||
export type AgentSoulMemoryConfig = {
|
||||
@@ -2291,11 +2303,12 @@ export type AgentComposerDifyToolCandidateResponse = {
|
||||
tools_count?: number | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeDatasetConfig = {
|
||||
export type AgentComposerKnowledgeSetCandidateResponse = {
|
||||
datasets?: Array<AgentComposerKnowledgeDatasetCandidateResponse>
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
[key: string]: unknown
|
||||
id: string
|
||||
missing_dataset_ids?: Array<string>
|
||||
name: string
|
||||
}
|
||||
|
||||
export type CheckResultView = {
|
||||
@@ -2406,16 +2419,16 @@ export type AgentHumanToolConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryConfig = {
|
||||
query?: string | null
|
||||
score_threshold?: number | null
|
||||
score_threshold_enabled?: boolean | null
|
||||
top_k?: number | null
|
||||
[key: string]: unknown
|
||||
export type AgentKnowledgeSetConfig = {
|
||||
datasets: Array<AgentKnowledgeDatasetConfig>
|
||||
description?: string | null
|
||||
id: string
|
||||
metadata_filtering?: AgentKnowledgeMetadataFilteringConfig
|
||||
name: string
|
||||
query: AgentKnowledgeQueryConfig
|
||||
retrieval: AgentKnowledgeRetrievalConfig
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
|
||||
|
||||
export type AgentMemoryArtifactConfig = {
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
@@ -2519,6 +2532,13 @@ export type AgentPermissionConfig = {
|
||||
|
||||
export type AgentCliToolRiskLevel = 'dangerous' | 'safe' | 'unknown'
|
||||
|
||||
export type AgentComposerKnowledgeDatasetCandidateResponse = {
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
missing?: boolean
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export type ButtonStyle = 'accent' | 'default' | 'ghost' | 'primary'
|
||||
|
||||
export type ParagraphInputConfig = {
|
||||
@@ -2558,6 +2578,34 @@ export type AgentModerationProviderConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentKnowledgeDatasetConfig = {
|
||||
description?: string | null
|
||||
id?: string | null
|
||||
name?: string | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeMetadataFilteringConfig = {
|
||||
conditions?: AgentKnowledgeMetadataConditions | null
|
||||
mode?: 'automatic' | 'disabled' | 'manual'
|
||||
model_config?: AgentKnowledgeModelConfig | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryConfig = {
|
||||
mode: AgentKnowledgeQueryMode
|
||||
value?: string | null
|
||||
}
|
||||
|
||||
export type AgentKnowledgeRetrievalConfig = {
|
||||
mode: 'multiple' | 'single'
|
||||
model?: AgentKnowledgeModelConfig | null
|
||||
reranking_enable?: boolean
|
||||
reranking_mode?: string
|
||||
reranking_model?: AgentKnowledgeRerankingModelConfig | null
|
||||
score_threshold?: number | null
|
||||
top_k?: number | null
|
||||
weights?: AgentKnowledgeWeightedScoreConfig | null
|
||||
}
|
||||
|
||||
export type AgentModelResponseFormatConfig = {
|
||||
type?: string | null
|
||||
[key: string]: unknown
|
||||
@@ -2591,8 +2639,64 @@ export type AgentModerationIoConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AgentKnowledgeMetadataConditions = {
|
||||
conditions?: Array<AgentKnowledgeMetadataCondition>
|
||||
logical_operator?: 'and' | 'or'
|
||||
}
|
||||
|
||||
export type AgentKnowledgeModelConfig = {
|
||||
completion_params?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
mode: string
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type AgentKnowledgeQueryMode = 'generated_query' | 'user_query'
|
||||
|
||||
export type AgentKnowledgeRerankingModelConfig = {
|
||||
model: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type AgentKnowledgeWeightedScoreConfig = {
|
||||
keyword_setting?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
vector_setting?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
weight_type?: string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ValueSourceType = 'constant' | 'variable'
|
||||
|
||||
export type AgentKnowledgeMetadataCondition = {
|
||||
comparison_operator:
|
||||
| '<'
|
||||
| '='
|
||||
| '>'
|
||||
| 'after'
|
||||
| 'before'
|
||||
| 'contains'
|
||||
| 'empty'
|
||||
| 'end with'
|
||||
| 'in'
|
||||
| 'is'
|
||||
| 'is not'
|
||||
| 'not contains'
|
||||
| 'not empty'
|
||||
| 'not in'
|
||||
| 'start with'
|
||||
| '≠'
|
||||
| '≤'
|
||||
| '≥'
|
||||
name: string
|
||||
value?: string | Array<string> | number | null
|
||||
}
|
||||
|
||||
export type AppPaginationWritable = {
|
||||
data: Array<AppPartialWritable>
|
||||
has_more: boolean
|
||||
@@ -2622,7 +2726,7 @@ export type AppDetailWithSiteWritable = {
|
||||
model_config?: ModelConfig | null
|
||||
name: string
|
||||
permission_keys?: Array<string>
|
||||
site?: SiteWritable | null
|
||||
site?: AppDetailSiteResponseWritable | null
|
||||
tags?: Array<Tag>
|
||||
tracing?: JsonValue | null
|
||||
updated_at?: number | null
|
||||
@@ -2682,20 +2786,30 @@ export type AppPartialWritable = {
|
||||
workflow?: WorkflowPartial | null
|
||||
}
|
||||
|
||||
export type SiteWritable = {
|
||||
export type AppDetailSiteResponseWritable = {
|
||||
access_token?: string | null
|
||||
app_base_url?: string | null
|
||||
chat_color_theme?: string | null
|
||||
chat_color_theme_inverted: boolean
|
||||
chat_color_theme_inverted?: boolean | null
|
||||
code?: string | null
|
||||
copyright?: string | null
|
||||
created_at?: number | null
|
||||
created_by?: string | null
|
||||
custom_disclaimer?: string | null
|
||||
default_language: string
|
||||
customize_domain?: string | null
|
||||
customize_token_strategy?: string | null
|
||||
default_language?: string | null
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
icon_type?: string | IconType | null
|
||||
privacy_policy?: string | null
|
||||
show_workflow_steps: boolean
|
||||
title: string
|
||||
use_icon_as_answer_icon: boolean
|
||||
prompt_public?: boolean | null
|
||||
show_workflow_steps?: boolean | null
|
||||
title?: string | null
|
||||
updated_at?: number | null
|
||||
updated_by?: string | null
|
||||
use_icon_as_answer_icon?: boolean | null
|
||||
}
|
||||
|
||||
export type WorkflowCommentBasicWritable = {
|
||||
|
||||
@@ -845,23 +845,33 @@ export const zDeletedTool = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Site
|
||||
* AppDetailSiteResponse
|
||||
*/
|
||||
export const zSite = z.object({
|
||||
export const zAppDetailSiteResponse = z.object({
|
||||
access_token: z.string().nullish(),
|
||||
app_base_url: z.string().nullish(),
|
||||
chat_color_theme: z.string().nullish(),
|
||||
chat_color_theme_inverted: z.boolean(),
|
||||
chat_color_theme_inverted: z.boolean().nullish(),
|
||||
code: z.string().nullish(),
|
||||
copyright: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
custom_disclaimer: z.string().nullish(),
|
||||
default_language: z.string(),
|
||||
customize_domain: z.string().nullish(),
|
||||
customize_token_strategy: z.string().nullish(),
|
||||
default_language: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
icon_type: z.union([z.string(), zIconType]).nullish(),
|
||||
icon_url: z.string().nullable(),
|
||||
privacy_policy: z.string().nullish(),
|
||||
show_workflow_steps: z.boolean(),
|
||||
title: z.string(),
|
||||
use_icon_as_answer_icon: z.boolean(),
|
||||
prompt_public: z.boolean().nullish(),
|
||||
show_workflow_steps: z.boolean().nullish(),
|
||||
title: z.string().nullish(),
|
||||
updated_at: z.int().nullish(),
|
||||
updated_by: z.string().nullish(),
|
||||
use_icon_as_answer_icon: z.boolean().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -2098,7 +2108,7 @@ export const zAppDetailWithSite = z.object({
|
||||
model_config: zModelConfig.nullish(),
|
||||
name: z.string(),
|
||||
permission_keys: z.array(z.string()).optional(),
|
||||
site: zSite.nullish(),
|
||||
site: zAppDetailSiteResponse.nullish(),
|
||||
tags: z.array(zTag).optional(),
|
||||
tracing: zJsonValue.nullish(),
|
||||
updated_at: z.int().nullish(),
|
||||
@@ -2499,8 +2509,12 @@ export const zAgentStatus = z.enum(['active', 'archived'])
|
||||
export const zAgentComposerAgentResponse = z.object({
|
||||
active_config_snapshot_id: z.string().nullish(),
|
||||
description: z.string(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string().nullish(),
|
||||
scope: zAgentScope,
|
||||
status: zAgentStatus,
|
||||
})
|
||||
@@ -2645,15 +2659,6 @@ export const zAgentComposerDifyToolCandidateResponse = z.object({
|
||||
tools_count: z.int().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeDatasetConfig
|
||||
*/
|
||||
export const zAgentKnowledgeDatasetConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* CheckResultView
|
||||
*
|
||||
@@ -2783,30 +2788,6 @@ export const zAgentSoulHumanConfig = z.object({
|
||||
tools: z.array(zAgentHumanToolConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryConfig
|
||||
*/
|
||||
export const zAgentKnowledgeQueryConfig = z.object({
|
||||
query: z.string().nullish(),
|
||||
score_threshold: z.number().gte(0).lte(1).nullish(),
|
||||
score_threshold_enabled: z.boolean().nullish(),
|
||||
top_k: z.int().gte(1).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryMode
|
||||
*/
|
||||
export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'])
|
||||
|
||||
/**
|
||||
* AgentSoulKnowledgeConfig
|
||||
*/
|
||||
export const zAgentSoulKnowledgeConfig = z.object({
|
||||
datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
query_config: zAgentKnowledgeQueryConfig.optional(),
|
||||
query_mode: zAgentKnowledgeQueryMode.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentMemoryArtifactConfig
|
||||
*/
|
||||
@@ -3018,6 +2999,27 @@ export const zAgentCliToolConfig = z.object({
|
||||
tool_name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerKnowledgeDatasetCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerKnowledgeDatasetCandidateResponse = z.object({
|
||||
description: z.string().nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
missing: z.boolean().optional().default(false),
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerKnowledgeSetCandidateResponse
|
||||
*/
|
||||
export const zAgentComposerKnowledgeSetCandidateResponse = z.object({
|
||||
datasets: z.array(zAgentComposerKnowledgeDatasetCandidateResponse).optional(),
|
||||
description: z.string().nullish(),
|
||||
id: z.string(),
|
||||
missing_dataset_ids: z.array(z.string()).optional(),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentComposerSoulCandidatesResponse
|
||||
*/
|
||||
@@ -3025,7 +3027,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
|
||||
cli_tools: z.array(zAgentCliToolConfig).optional(),
|
||||
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
|
||||
human_contacts: z.array(zAgentHumanContactConfig).optional(),
|
||||
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
|
||||
knowledge_sets: z.array(zAgentComposerKnowledgeSetCandidateResponse).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -3057,6 +3059,15 @@ export const zUserActionConfig = z.object({
|
||||
title: z.string().max(100),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeDatasetConfig
|
||||
*/
|
||||
export const zAgentKnowledgeDatasetConfig = z.object({
|
||||
description: z.string().nullish(),
|
||||
id: z.string().max(255).nullish(),
|
||||
name: z.string().max(255).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentModelResponseFormatConfig
|
||||
*/
|
||||
@@ -3308,62 +3319,70 @@ export const zAgentSoulAppFeaturesConfig = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
* AgentKnowledgeModelConfig
|
||||
*/
|
||||
export const zAgentSoulConfig = z.object({
|
||||
app_features: zAgentSoulAppFeaturesConfig.optional(),
|
||||
app_variables: z.array(zAppVariableConfig).optional(),
|
||||
env: zAgentSoulEnvConfig.optional(),
|
||||
human: zAgentSoulHumanConfig.optional(),
|
||||
knowledge: zAgentSoulKnowledgeConfig.optional(),
|
||||
memory: zAgentSoulMemoryConfig.optional(),
|
||||
misc_legacy: zAgentSoulAppFeaturesConfig.optional(),
|
||||
model: zAgentSoulModelConfig.nullish(),
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
export const zAgentKnowledgeModelConfig = z.object({
|
||||
completion_params: z.record(z.string(), z.unknown()).optional(),
|
||||
mode: z.string().min(1).max(64),
|
||||
name: z.string().min(1).max(255),
|
||||
provider: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowAgentComposerResponse
|
||||
* AgentKnowledgeQueryMode
|
||||
*/
|
||||
export const zWorkflowAgentComposerResponse = z.object({
|
||||
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(),
|
||||
agent: zAgentComposerAgentResponse.nullish(),
|
||||
agent_soul: zAgentSoulConfig,
|
||||
app_id: z.string().nullish(),
|
||||
binding: zAgentComposerBindingResponse.nullish(),
|
||||
effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(),
|
||||
impact_summary: zAgentComposerImpactResponse.nullish(),
|
||||
node_id: z.string().nullish(),
|
||||
node_job: zWorkflowNodeJobConfig,
|
||||
save_options: z.array(zComposerSaveStrategy),
|
||||
soul_lock: zAgentComposerSoulLockResponse,
|
||||
validation: zComposerValidationFindingsResponse.nullish(),
|
||||
variant: z.literal('workflow'),
|
||||
workflow_id: z.string().nullish(),
|
||||
export const zAgentKnowledgeQueryMode = z.enum(['generated_query', 'user_query'])
|
||||
|
||||
/**
|
||||
* AgentKnowledgeQueryConfig
|
||||
*
|
||||
* Per-set query policy for Agent v2 knowledge retrieval.
|
||||
*
|
||||
* Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
* legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
* set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
* ``value`` while ``generated_query`` leaves that value empty.
|
||||
*/
|
||||
export const zAgentKnowledgeQueryConfig = z.object({
|
||||
mode: zAgentKnowledgeQueryMode,
|
||||
value: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerSavePayload
|
||||
* AgentKnowledgeRerankingModelConfig
|
||||
*/
|
||||
export const zComposerSavePayload = z.object({
|
||||
agent_soul: zAgentSoulConfig.nullish(),
|
||||
binding: zComposerBindingPayload.nullish(),
|
||||
client_revision_id: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().max(255).nullish(),
|
||||
icon_background: z.string().max(255).nullish(),
|
||||
icon_type: zAgentIconType.nullish(),
|
||||
idempotency_key: z.string().nullish(),
|
||||
new_agent_name: z.string().min(1).max(255).nullish(),
|
||||
node_job: zWorkflowNodeJobConfig.nullish(),
|
||||
role: z.string().max(255).nullish(),
|
||||
save_strategy: zComposerSaveStrategy,
|
||||
soul_lock: zComposerSoulLockPayload.optional(),
|
||||
variant: zComposerVariant,
|
||||
version_note: z.string().nullish(),
|
||||
export const zAgentKnowledgeRerankingModelConfig = z.object({
|
||||
model: z.string().min(1).max(255),
|
||||
provider: z.string().min(1).max(255),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeWeightedScoreConfig
|
||||
*/
|
||||
export const zAgentKnowledgeWeightedScoreConfig = z.object({
|
||||
keyword_setting: z.record(z.string(), z.unknown()).nullish(),
|
||||
vector_setting: z.record(z.string(), z.unknown()).nullish(),
|
||||
weight_type: z.string().max(64).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeRetrievalConfig
|
||||
*
|
||||
* Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
*
|
||||
* Retrieval settings now live on each knowledge set instead of one shared
|
||||
* flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
* ``single`` retrieval with a required model config.
|
||||
*/
|
||||
export const zAgentKnowledgeRetrievalConfig = z.object({
|
||||
mode: z.enum(['multiple', 'single']),
|
||||
model: zAgentKnowledgeModelConfig.nullish(),
|
||||
reranking_enable: z.boolean().optional().default(true),
|
||||
reranking_mode: z.string().optional().default('reranking_model'),
|
||||
reranking_model: zAgentKnowledgeRerankingModelConfig.nullish(),
|
||||
score_threshold: z.number().gte(0).lte(1).nullish(),
|
||||
top_k: z.int().gte(1).nullish(),
|
||||
weights: zAgentKnowledgeWeightedScoreConfig.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -3487,6 +3506,151 @@ export const zMessageInfiniteScrollPaginationResponse = z.object({
|
||||
limit: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeMetadataCondition
|
||||
*/
|
||||
export const zAgentKnowledgeMetadataCondition = z.object({
|
||||
comparison_operator: z.enum([
|
||||
'<',
|
||||
'=',
|
||||
'>',
|
||||
'after',
|
||||
'before',
|
||||
'contains',
|
||||
'empty',
|
||||
'end with',
|
||||
'in',
|
||||
'is',
|
||||
'is not',
|
||||
'not contains',
|
||||
'not empty',
|
||||
'not in',
|
||||
'start with',
|
||||
'≠',
|
||||
'≤',
|
||||
'≥',
|
||||
]),
|
||||
name: z.string().min(1).max(255),
|
||||
value: z.union([z.string(), z.array(z.string()), z.number()]).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeMetadataConditions
|
||||
*/
|
||||
export const zAgentKnowledgeMetadataConditions = z.object({
|
||||
conditions: z.array(zAgentKnowledgeMetadataCondition).optional(),
|
||||
logical_operator: z.enum(['and', 'or']).optional().default('and'),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeMetadataFilteringConfig
|
||||
*
|
||||
* Per-set metadata filtering policy.
|
||||
*
|
||||
* The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
* model belongs to metadata filtering specifically, while the external API and
|
||||
* generated schema keep the historical ``model_config`` field name via alias.
|
||||
*/
|
||||
export const zAgentKnowledgeMetadataFilteringConfig = z.object({
|
||||
conditions: zAgentKnowledgeMetadataConditions.nullish(),
|
||||
mode: z.enum(['automatic', 'disabled', 'manual']).optional().default('disabled'),
|
||||
model_config: zAgentKnowledgeModelConfig.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentKnowledgeSetConfig
|
||||
*
|
||||
* One explicit knowledge set in Agent v2.
|
||||
*
|
||||
* ``knowledge.sets`` replaces the old flat knowledge config. Each set owns
|
||||
* its datasets plus query, retrieval, and metadata policies. An individual
|
||||
* set must contain at least one dataset id even though the overall knowledge
|
||||
* section may be empty, which is how callers express "no knowledge layer".
|
||||
*/
|
||||
export const zAgentKnowledgeSetConfig = z.object({
|
||||
datasets: z.array(zAgentKnowledgeDatasetConfig),
|
||||
description: z.string().nullish(),
|
||||
id: z.string().min(1).max(255),
|
||||
metadata_filtering: zAgentKnowledgeMetadataFilteringConfig.optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
query: zAgentKnowledgeQueryConfig,
|
||||
retrieval: zAgentKnowledgeRetrievalConfig,
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulKnowledgeConfig
|
||||
*
|
||||
* Top-level Agent v2 knowledge config.
|
||||
*
|
||||
* Agent v2 models knowledge as explicit sets instead of one flat
|
||||
* ``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
|
||||
* list means no knowledge layer should be emitted at runtime, while set-name
|
||||
* uniqueness stays case-insensitive because runtime selection addresses sets
|
||||
* by name.
|
||||
*/
|
||||
export const zAgentSoulKnowledgeConfig = z.object({
|
||||
sets: z.array(zAgentKnowledgeSetConfig).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AgentSoulConfig
|
||||
*/
|
||||
export const zAgentSoulConfig = z.object({
|
||||
app_features: zAgentSoulAppFeaturesConfig.optional(),
|
||||
app_variables: z.array(zAppVariableConfig).optional(),
|
||||
env: zAgentSoulEnvConfig.optional(),
|
||||
human: zAgentSoulHumanConfig.optional(),
|
||||
knowledge: zAgentSoulKnowledgeConfig.optional(),
|
||||
memory: zAgentSoulMemoryConfig.optional(),
|
||||
misc_legacy: zAgentSoulAppFeaturesConfig.optional(),
|
||||
model: zAgentSoulModelConfig.nullish(),
|
||||
prompt: zAgentSoulPromptConfig.optional(),
|
||||
sandbox: zAgentSoulSandboxConfig.optional(),
|
||||
schema_version: z.int().optional().default(1),
|
||||
tools: zAgentSoulToolsConfig.optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowAgentComposerResponse
|
||||
*/
|
||||
export const zWorkflowAgentComposerResponse = z.object({
|
||||
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(),
|
||||
agent: zAgentComposerAgentResponse.nullish(),
|
||||
agent_soul: zAgentSoulConfig,
|
||||
app_id: z.string().nullish(),
|
||||
binding: zAgentComposerBindingResponse.nullish(),
|
||||
effective_declared_outputs: z.array(zDeclaredOutputConfig).optional(),
|
||||
impact_summary: zAgentComposerImpactResponse.nullish(),
|
||||
node_id: z.string().nullish(),
|
||||
node_job: zWorkflowNodeJobConfig,
|
||||
save_options: z.array(zComposerSaveStrategy),
|
||||
soul_lock: zAgentComposerSoulLockResponse,
|
||||
validation: zComposerValidationFindingsResponse.nullish(),
|
||||
variant: z.literal('workflow'),
|
||||
workflow_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ComposerSavePayload
|
||||
*/
|
||||
export const zComposerSavePayload = z.object({
|
||||
agent_soul: zAgentSoulConfig.nullish(),
|
||||
binding: zComposerBindingPayload.nullish(),
|
||||
client_revision_id: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().max(255).nullish(),
|
||||
icon_background: z.string().max(255).nullish(),
|
||||
icon_type: zAgentIconType.nullish(),
|
||||
idempotency_key: z.string().nullish(),
|
||||
new_agent_name: z.string().min(1).max(255).nullish(),
|
||||
node_job: zWorkflowNodeJobConfig.nullish(),
|
||||
role: z.string().max(255).nullish(),
|
||||
save_strategy: zComposerSaveStrategy,
|
||||
soul_lock: zComposerSoulLockPayload.optional(),
|
||||
variant: zComposerVariant,
|
||||
version_note: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* GeneratedAppResponse
|
||||
*/
|
||||
@@ -3535,22 +3699,32 @@ export const zAppPaginationWritable = z.object({
|
||||
})
|
||||
|
||||
/**
|
||||
* Site
|
||||
* AppDetailSiteResponse
|
||||
*/
|
||||
export const zSiteWritable = z.object({
|
||||
export const zAppDetailSiteResponseWritable = z.object({
|
||||
access_token: z.string().nullish(),
|
||||
app_base_url: z.string().nullish(),
|
||||
chat_color_theme: z.string().nullish(),
|
||||
chat_color_theme_inverted: z.boolean(),
|
||||
chat_color_theme_inverted: z.boolean().nullish(),
|
||||
code: z.string().nullish(),
|
||||
copyright: z.string().nullish(),
|
||||
created_at: z.int().nullish(),
|
||||
created_by: z.string().nullish(),
|
||||
custom_disclaimer: z.string().nullish(),
|
||||
default_language: z.string(),
|
||||
customize_domain: z.string().nullish(),
|
||||
customize_token_strategy: z.string().nullish(),
|
||||
default_language: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
icon_type: z.union([z.string(), zIconType]).nullish(),
|
||||
privacy_policy: z.string().nullish(),
|
||||
show_workflow_steps: z.boolean(),
|
||||
title: z.string(),
|
||||
use_icon_as_answer_icon: z.boolean(),
|
||||
prompt_public: z.boolean().nullish(),
|
||||
show_workflow_steps: z.boolean().nullish(),
|
||||
title: z.string().nullish(),
|
||||
updated_at: z.int().nullish(),
|
||||
updated_by: z.string().nullish(),
|
||||
use_icon_as_answer_icon: z.boolean().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -3577,7 +3751,7 @@ export const zAppDetailWithSiteWritable = z.object({
|
||||
model_config: zModelConfig.nullish(),
|
||||
name: z.string(),
|
||||
permission_keys: z.array(z.string()).optional(),
|
||||
site: zSiteWritable.nullish(),
|
||||
site: zAppDetailSiteResponseWritable.nullish(),
|
||||
tags: z.array(zTag).optional(),
|
||||
tracing: zJsonValue.nullish(),
|
||||
updated_at: z.int().nullish(),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16" fill="none">
|
||||
<path d="M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM6.75 12.75C8.82105 12.75 10.5 11.071 10.5 9C10.5 6.92895 8.82105 5.25 6.75 5.25C4.67893 5.25 3 6.92895 3 9C3 11.071 4.67893 12.75 6.75 12.75ZM6.75 11.25C5.50732 11.25 4.5 10.2427 4.5 9C4.5 7.75732 5.50732 6.75 6.75 6.75C7.99268 6.75 9 7.75732 9 9C9 10.2427 7.99268 11.25 6.75 11.25ZM6.75 9.75C7.16422 9.75 7.5 9.41422 7.5 9C7.5 8.58578 7.16422 8.25 6.75 8.25C6.33578 8.25 6 8.58578 6 9C6 9.41422 6.33578 9.75 6.75 9.75Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 664 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16" fill="none">
|
||||
<path d="M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM12 14.25V9C12 6.10051 9.6495 3.75 6.75 3.75C3.85051 3.75 1.5 6.10051 1.5 9V14.25H12ZM6.75 12.75C4.67893 12.75 3 11.071 3 9C3 6.92895 4.67893 5.25 6.75 5.25C8.82105 5.25 10.5 6.92895 10.5 9C10.5 11.071 8.82105 12.75 6.75 12.75ZM6.75 11.25C7.99268 11.25 9 10.2427 9 9C9 7.75732 7.99268 6.75 6.75 6.75C5.50732 6.75 4.5 7.75732 4.5 9C4.5 10.2427 5.50732 11.25 6.75 11.25ZM6.75 9.75C6.33578 9.75 6 9.41422 6 9C6 8.58578 6.33578 8.25 6.75 8.25C7.16422 8.25 7.5 8.58578 7.5 9C7.5 9.41422 7.16422 9.75 6.75 9.75Z" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 749 B |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"prefix": "custom-public",
|
||||
"lastModified": 1781515983,
|
||||
"lastModified": 1782215796,
|
||||
"icons": {
|
||||
"agent-building-blocks": {
|
||||
"body": "<path fill=\"#155AEF\" fill-rule=\"evenodd\" d=\"M8.303 1.546c.178-.045.364-.051.544-.017c.23.043.432.167.573.246l3.757 2.113c.12.067.29.156.433.289l.06.06c.12.131.21.288.267.457c.07.215.063.445.063.6V9.56c0 .146.007.36-.056.563q-.055.181-.162.338l-.075.1c-.137.163-.32.274-.442.353l-5.013 3.259c-.135.088-.33.224-.556.282a1.3 1.3 0 0 1-.543.017c-.23-.043-.433-.166-.573-.245l-3.757-2.114c-.136-.077-.34-.182-.493-.35a1.3 1.3 0 0 1-.267-.456C1.993 11.09 2 10.86 2 10.704V6.441c0-.146-.007-.36.055-.563l.043-.118a1.3 1.3 0 0 1 .195-.32l.053-.059c.128-.131.282-.225.389-.294L7.86 1.755c.122-.078.273-.165.443-.209m-4.97 9.158l.001.164l.033.02l.11.062l3.264 1.836v-1.137L3.333 9.732zm4.741.917v1.076l4.464-2.901l.098-.065l.029-.02v-.034l.001-.118v-.923zm-4.74-3.419L6.74 10.12V8.982L3.333 7.066zm4.74.752v1.076l4.592-2.985V5.969zm.51-6.08l-4.631 3.01l3.429 1.93l4.664-3.032l-3.28-1.846l-.15-.082z\" clip-rule=\"evenodd\"/>"
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"lastModified": 1781515983,
|
||||
"lastModified": 1782215796,
|
||||
"icons": {
|
||||
"agent-v2-access-point": {
|
||||
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 15,
|
||||
"height": 15
|
||||
},
|
||||
"agent-v2-configure": {
|
||||
"body": "<g fill=\"none\"><path d=\"M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM12 14.25V9C12 6.10051 9.6495 3.75 6.75 3.75C3.85051 3.75 1.5 6.10051 1.5 9V14.25H12ZM6.75 12.75C4.67893 12.75 3 11.071 3 9C3 6.92895 4.67893 5.25 6.75 5.25C8.82105 5.25 10.5 6.92895 10.5 9C10.5 11.071 8.82105 12.75 6.75 12.75ZM6.75 11.25C7.99268 11.25 9 10.2427 9 9C9 7.75732 7.99268 6.75 6.75 6.75C5.50732 6.75 4.5 7.75732 4.5 9C4.5 10.2427 5.50732 11.25 6.75 11.25ZM6.75 9.75C6.33578 9.75 6 9.41422 6 9C6 8.58578 6.33578 8.25 6.75 8.25C7.16422 8.25 7.5 8.58578 7.5 9C7.5 9.41422 7.16422 9.75 6.75 9.75Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 14
|
||||
},
|
||||
"agent-v2-configure-active": {
|
||||
"body": "<g fill=\"none\"><path d=\"M7.5 2.2912C10.875 2.66428 13.5 5.52559 13.5 9V15.75H0V9C0 5.52559 2.62504 2.66428 6 2.2912V0H7.5V2.2912ZM6.75 12.75C8.82105 12.75 10.5 11.071 10.5 9C10.5 6.92895 8.82105 5.25 6.75 5.25C4.67893 5.25 3 6.92895 3 9C3 11.071 4.67893 12.75 6.75 12.75ZM6.75 11.25C5.50732 11.25 4.5 10.2427 4.5 9C4.5 7.75732 5.50732 6.75 6.75 6.75C7.99268 6.75 9 7.75732 9 9C9 10.2427 7.99268 11.25 6.75 11.25ZM6.75 9.75C7.16422 9.75 7.5 9.41422 7.5 9C7.5 8.58578 7.16422 8.25 6.75 8.25C6.33578 8.25 6 8.58578 6 9C6 9.41422 6.33578 9.75 6.75 9.75Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 14
|
||||
},
|
||||
"agent-v2-end-user-auth": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12 7.33325C13.1046 7.33325 14 8.22865 14 9.33325C14 10.1403 13.5218 10.8356 12.8333 11.1516V11.9999L12.3333 12.4999L12.8333 12.9511V13.6666L12 14.3333L11.1667 13.6666V11.1516C10.4782 10.8356 10 10.1403 10 9.33325C10 8.22865 10.8954 7.33325 12 7.33325ZM12 8.66659C11.6318 8.66659 11.3333 8.96505 11.3333 9.33325C11.3333 9.70145 11.6318 9.99992 12 9.99992C12.3682 9.99992 12.6667 9.70145 12.6667 9.33325C12.6667 8.96505 12.3682 8.66659 12 8.66659Z\" fill=\"currentColor\"/><path d=\"M8 7.99992C8.2545 7.99992 8.50382 8.01506 8.7474 8.04484L8.58594 9.36841C8.39687 9.34527 8.20127 9.33325 8 9.33325C5.8465 9.33325 4.25915 10.7274 3.78646 12.6666H10V13.9999H2.26758L2.33594 13.2708C2.61081 10.3473 4.82817 7.99992 8 7.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8 1.33325C9.65687 1.33325 11 2.6764 11 4.33325C11 5.99011 9.65687 7.33325 8 7.33325C6.34315 7.33325 5 5.99011 5 4.33325C5 2.6764 6.34315 1.33325 8 1.33325ZM8 2.66659C7.07953 2.66659 6.33333 3.41278 6.33333 4.33325C6.33333 5.25373 7.07953 5.99992 8 5.99992C8.92047 5.99992 9.66667 5.25373 9.66667 4.33325C9.66667 3.41278 8.92047 2.66659 8 2.66659Z\" fill=\"currentColor\"/></g>"
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M12 7.33325C13.1046 7.33325 14 8.22865 14 9.33325C14 10.1403 13.5218 10.8356 12.8333 11.1516V11.9999L12.3333 12.4999L12.8333 12.9511V13.6666L12 14.3333L11.1667 13.6666V11.1516C10.4782 10.8356 10 10.1403 10 9.33325C10 8.22865 10.8954 7.33325 12 7.33325ZM12 8.66659C11.6318 8.66659 11.3333 8.96505 11.3333 9.33325C11.3333 9.70145 11.6318 9.99992 12 9.99992C12.3682 9.99992 12.6667 9.70145 12.6667 9.33325C12.6667 8.96505 12.3682 8.66659 12 8.66659Z\" fill=\"currentColor\"/><path d=\"M8 7.99992C8.2545 7.99992 8.50382 8.01506 8.7474 8.04484L8.58594 9.36841C8.39687 9.34527 8.20127 9.33325 8 9.33325C5.8465 9.33325 4.25915 10.7274 3.78646 12.6666H10V13.9999H2.26758L2.33594 13.2708C2.61081 10.3473 4.82817 7.99992 8 7.99992Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8 1.33325C9.65687 1.33325 11 2.6764 11 4.33325C11 5.99011 9.65687 7.33325 8 7.33325C6.34315 7.33325 5 5.99011 5 4.33325C5 2.6764 6.34315 1.33325 8 1.33325ZM8 2.66659C7.07953 2.66659 6.33333 3.41278 6.33333 4.33325C6.33333 5.25373 7.07953 5.99992 8 5.99992C8.92047 5.99992 9.66667 5.25373 9.66667 4.33325C9.66667 3.41278 8.92047 2.66659 8 2.66659Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 16
|
||||
},
|
||||
"agent-v2-plan": {
|
||||
"body": "<g fill=\"none\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M17 0C17.5523 0 18 0.447715 18 1V6C18 6.55228 17.5523 7 17 7H12C11.4477 7 11 6.55228 11 6V4.5H6.94629C5.92438 4.50039 5.56101 5.85276 6.44531 6.36523L12.5576 9.90332C15.2116 11.4402 14.1206 15.4996 11.0537 15.5H7V17C7 17.5523 6.55228 18 6 18H1C0.447715 18 0 17.5523 0 17V12C0 11.4477 0.447715 11 1 11H6C6.55228 11 7 11.4477 7 12V13.5H11.0537C12.0756 13.4996 12.4394 12.1472 11.5557 11.6348L5.44336 8.09668C2.789 6.55983 3.87917 2.50039 6.94629 2.5H11V1C11 0.447715 11.4477 0 12 0H17ZM2 16H5V13H2V16ZM13 5H16V2H13V5Z\" fill=\"currentColor\"/></g>",
|
||||
@@ -17,8 +26,8 @@
|
||||
},
|
||||
"agent-v2-prompt-insert": {
|
||||
"body": "<g fill=\"none\"><path d=\"M2.91669 1.16669C1.95019 1.16669 1.16669 1.95019 1.16669 2.91669V11.0834C1.16669 12.0499 1.95019 12.8334 2.91669 12.8334H11.0834C12.0499 12.8334 12.8334 12.0499 12.8334 11.0834V2.91669C12.8334 1.95019 12.0499 1.16669 11.0834 1.16669H2.91669ZM2.33335 2.91669C2.33335 2.59452 2.59452 2.33335 2.91669 2.33335H11.0834C11.4055 2.33335 11.6667 2.59452 11.6667 2.91669V11.0834C11.6667 11.4055 11.4055 11.6667 11.0834 11.6667H2.91669C2.59452 11.6667 2.33335 11.4055 2.33335 11.0834V2.91669ZM5.67188 10.5L9.67186 3.50002H8.32815L4.32817 10.5H5.67188Z\" fill=\"currentColor\"/></g>",
|
||||
"width": 14,
|
||||
"height": 14
|
||||
"height": 14,
|
||||
"width": 14
|
||||
},
|
||||
"agent-v2-robot-3": {
|
||||
"body": "<g fill=\"none\"><path d=\"M6.25 6.875C6.82523 6.875 7.29167 7.34128 7.29167 7.91667V9.16667C7.29167 9.74205 6.82523 10.2083 6.25 10.2083C5.67477 10.2083 5.20833 9.74205 5.20833 9.16667V7.91667C5.20833 7.34128 5.67477 6.875 6.25 6.875Z\" fill=\"currentColor\"/><path d=\"M10.4167 6.875C10.992 6.875 11.4583 7.34135 11.4583 7.91667V9.16667C11.4583 9.74199 10.992 10.2083 10.4167 10.2083C9.84135 10.2083 9.375 9.74199 9.375 9.16667V7.91667C9.375 7.34135 9.84135 6.875 10.4167 6.875Z\" fill=\"currentColor\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M8.33333 0C9.13875 0 9.79167 0.652918 9.79167 1.45833C9.79167 2.02329 9.46964 2.51173 8.99984 2.75391V3.33822C9.38912 3.34279 9.77995 3.35006 10.175 3.36263C11.6983 3.41112 12.7377 3.42425 13.6401 3.90951C14.375 4.30477 15.0255 4.97655 15.3971 5.72347C15.5468 6.02442 15.6427 6.33532 15.7056 6.66667H15.8333C16.2936 6.66667 16.6667 7.03976 16.6667 7.5V10C16.6667 10.4602 16.2936 10.8333 15.8333 10.8333H15.8285C15.8235 11.2254 15.813 11.5735 15.7869 11.8831C15.7386 12.4571 15.6361 12.9628 15.3971 13.4432C15.0254 14.1901 14.3749 14.8619 13.6401 15.2572C12.7377 15.7424 11.6982 15.7556 10.175 15.804C8.93336 15.8436 7.73328 15.8436 6.4917 15.804C4.96843 15.7556 3.92896 15.7424 3.02653 15.2572C2.29178 14.8619 1.64121 14.1902 1.26953 13.4432C1.03058 12.9628 0.928072 12.4571 0.87972 11.8831C0.853642 11.5735 0.843216 11.2254 0.838216 10.8333H0.833333C0.373096 10.8333 0 10.4602 0 10V7.5C0 7.03976 0.373096 6.66667 0.833333 6.66667H0.9611C1.02392 6.33532 1.11984 6.02442 1.26953 5.72347C1.64119 4.97649 2.29177 4.30475 3.02653 3.90951C3.92895 3.42425 4.96837 3.41112 6.4917 3.36263C6.88671 3.35006 7.27754 3.34279 7.66683 3.33822V2.75391C7.19703 2.51173 6.875 2.02329 6.875 1.45833C6.875 0.652918 7.52792 0 8.33333 0ZM10.1213 5.02848C8.91522 4.9901 7.75142 4.9901 6.54541 5.02848C4.85908 5.08217 4.29323 5.12091 3.81592 5.3776C3.38476 5.60954 2.98015 6.02734 2.76204 6.46566C2.65217 6.68652 2.57959 6.96168 2.54069 7.4235C2.50069 7.89854 2.5 8.50363 2.5 9.37825V9.78841C2.5 10.663 2.50069 11.2681 2.54069 11.7432C2.57959 12.205 2.65215 12.4801 2.76204 12.701C2.98015 13.1393 3.38475 13.5571 3.81592 13.7891C4.29321 14.0458 4.85904 14.0845 6.54541 14.1382C7.75141 14.1766 8.91523 14.1766 10.1213 14.1382C11.8075 14.0845 12.3734 14.0458 12.8507 13.7891C13.2819 13.5572 13.6865 13.1394 13.9046 12.701C14.0145 12.4801 14.0871 12.205 14.126 11.7432C14.166 11.2681 14.1667 10.663 14.1667 9.78841V9.37825C14.1667 8.50363 14.166 7.89854 14.126 7.4235C14.0871 6.96168 14.0145 6.68652 13.9046 6.46566C13.6865 6.02729 13.2819 5.60951 12.8507 5.3776C12.3734 5.12091 11.8075 5.08217 10.1213 5.02848Z\" fill=\"currentColor\"/></g>",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 326,
|
||||
"total": 328,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
@@ -14,11 +14,11 @@
|
||||
},
|
||||
"samples": [
|
||||
"agent-v2-access-point",
|
||||
"agent-v2-configure",
|
||||
"agent-v2-configure-active",
|
||||
"agent-v2-end-user-auth",
|
||||
"agent-v2-plan",
|
||||
"agent-v2-prompt-insert",
|
||||
"agent-v2-robot-3",
|
||||
"features-citations"
|
||||
"agent-v2-prompt-insert"
|
||||
],
|
||||
"palette": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canEmbedPath } from '@/proxy'
|
||||
|
||||
describe('proxy frame options', () => {
|
||||
it('should allow embedded share routes', () => {
|
||||
expect(canEmbedPath('/chatbot/token')).toBe(true)
|
||||
expect(canEmbedPath('/workflow/token')).toBe(true)
|
||||
expect(canEmbedPath('/completion/token')).toBe(true)
|
||||
expect(canEmbedPath('/webapp-signin')).toBe(true)
|
||||
expect(canEmbedPath('/agent/token')).toBe(true)
|
||||
})
|
||||
|
||||
it('should deny non-embedded console routes by default', () => {
|
||||
expect(canEmbedPath('/agents')).toBe(false)
|
||||
expect(canEmbedPath('/agent-settings')).toBe(false)
|
||||
expect(canEmbedPath('/agentic')).toBe(false)
|
||||
expect(canEmbedPath('/roster/agent/agent-1/access')).toBe(false)
|
||||
expect(canEmbedPath('/apps')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -47,10 +47,12 @@ const NavLink = ({
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
|
||||
const isCollapsed = mode !== 'expand'
|
||||
const borderClassName = 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px]'
|
||||
const linkClassName = cn(
|
||||
borderClassName,
|
||||
isActive
|
||||
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
|
||||
? 'border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
|
||||
: 'border-transparent system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
|
||||
isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center rounded-lg pr-1 pl-3',
|
||||
'rounded-lg',
|
||||
)
|
||||
@@ -68,7 +70,9 @@ const NavLink = ({
|
||||
type="button"
|
||||
disabled
|
||||
className={cn(
|
||||
borderClassName,
|
||||
'cursor-not-allowed rounded-lg system-sm-medium text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
|
||||
'border-transparent',
|
||||
isCollapsed ? 'flex size-8 items-center justify-center p-1.5' : 'flex h-8 items-center pr-1 pl-3',
|
||||
)}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
|
||||
@@ -285,6 +285,18 @@ describe('app-card-utils', () => {
|
||||
expect(snippet).not.toContain('isDev: true')
|
||||
})
|
||||
|
||||
it('should generate an agent embedded script route when requested', () => {
|
||||
const snippet = getEmbeddedScriptSnippet({
|
||||
url: 'https://example.com',
|
||||
token: 'agent-token',
|
||||
webAppRoute: 'agent',
|
||||
primaryColor: '#1C64F2',
|
||||
inputValues: {},
|
||||
})
|
||||
|
||||
expect(snippet).toContain('routeSegment: \'agent\'')
|
||||
})
|
||||
|
||||
it('should compress and encode base64 using CompressionStream when available', async () => {
|
||||
const result = await compressAndEncodeBase64('hello')
|
||||
expect(typeof result).toBe('string')
|
||||
|
||||
@@ -11,6 +11,7 @@ type OverviewCardType = 'api' | 'webapp'
|
||||
|
||||
export type OverviewOperationKey = 'launch' | 'embedded' | 'customize' | 'settings' | 'develop'
|
||||
export type WorkflowLaunchInputValue = string | boolean
|
||||
export type EmbeddedWebAppRoute = 'chatbot' | 'agent'
|
||||
export type WorkflowHiddenStartVariable = Pick<
|
||||
InputVar,
|
||||
'default' | 'hide' | 'label' | 'max_length' | 'options' | 'required' | 'type' | 'variable'
|
||||
@@ -156,12 +157,14 @@ ${entries.map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join('\
|
||||
export const getEmbeddedScriptSnippet = ({
|
||||
url,
|
||||
token,
|
||||
webAppRoute = 'chatbot',
|
||||
primaryColor,
|
||||
isTestEnv,
|
||||
inputValues,
|
||||
}: {
|
||||
url: string
|
||||
token: string
|
||||
webAppRoute?: EmbeddedWebAppRoute
|
||||
primaryColor: string
|
||||
isTestEnv?: boolean
|
||||
inputValues: Record<string, WorkflowLaunchInputValue>
|
||||
@@ -174,6 +177,9 @@ export const getEmbeddedScriptSnippet = ({
|
||||
: ''}${IS_CE_EDITION
|
||||
? `,
|
||||
baseUrl: '${url}${basePath}'`
|
||||
: ''}${webAppRoute !== 'chatbot'
|
||||
? `,
|
||||
routeSegment: '${webAppRoute}'`
|
||||
: ''},
|
||||
inputs: ${getScriptInputsContent(inputValues)},
|
||||
systemVariables: {
|
||||
|
||||
@@ -13,7 +13,8 @@ type IShareLinkProps = {
|
||||
onClose: () => void
|
||||
api_base_url: string
|
||||
appId: string
|
||||
mode: AppModeEnum
|
||||
mode?: AppModeEnum
|
||||
sourceCodeRepository?: 'webapp-conversation' | 'webapp-text-generator'
|
||||
}
|
||||
|
||||
const StepNum: FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
@@ -38,10 +39,12 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
appId,
|
||||
api_base_url,
|
||||
mode,
|
||||
sourceCodeRepository,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const isChatApp = mode === AppModeEnum.CHAT || mode === AppModeEnum.ADVANCED_CHAT
|
||||
const repository = sourceCodeRepository ?? (isChatApp ? 'webapp-conversation' : 'webapp-text-generator')
|
||||
const apiDocLink = docLink('/use-dify/publish/developing-with-apis')
|
||||
|
||||
return (
|
||||
@@ -67,7 +70,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
<div className="flex flex-col">
|
||||
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step1`, { ns: 'appOverview' })}</div>
|
||||
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step1Tip`, { ns: 'appOverview' })}</div>
|
||||
<Button nativeButton={false} render={<a href={`https://github.com/langgenius/${isChatApp ? 'webapp-conversation' : 'webapp-text-generator'}`} target="_blank" rel="noopener noreferrer" />}>
|
||||
<Button nativeButton={false} render={<a href={`https://github.com/langgenius/${repository}`} target="_blank" rel="noopener noreferrer" aria-label={t(`${prefixCustomize}.way1.step1Operation`, { ns: 'appOverview' })} />}>
|
||||
<GithubIcon className="mr-2 text-text-secondary" />
|
||||
{t(`${prefixCustomize}.way1.step1Operation`, { ns: 'appOverview' })}
|
||||
</Button>
|
||||
@@ -78,7 +81,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
<div className="flex flex-col">
|
||||
<div className="text-text-primary">{t(`${prefixCustomize}.way1.step2`, { ns: 'appOverview' })}</div>
|
||||
<div className="mt-1 mb-2 text-xs text-text-tertiary">{t(`${prefixCustomize}.way1.step2Tip`, { ns: 'appOverview' })}</div>
|
||||
<Button nativeButton={false} render={<a href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github" target="_blank" rel="noopener noreferrer" />}>
|
||||
<Button nativeButton={false} render={<a href="https://vercel.com/docs/concepts/deployments/git/vercel-for-github" target="_blank" rel="noopener noreferrer" aria-label={t(`${prefixCustomize}.way1.step2Operation`, { ns: 'appOverview' })} />}>
|
||||
<div className="mr-1.5 border-t-0 border-r-[7px] border-b-12 border-l-[7px] border-solid border-text-primary border-t-transparent border-r-transparent border-l-transparent"></div>
|
||||
<span>{t(`${prefixCustomize}.way1.step2Operation`, { ns: 'appOverview' })}</span>
|
||||
</Button>
|
||||
@@ -114,7 +117,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
<p className="my-2 system-sm-medium text-text-secondary">{t(`${prefixCustomize}.way2.name`, { ns: 'appOverview' })}</p>
|
||||
<Button
|
||||
nativeButton={false}
|
||||
render={<a href={apiDocLink} target="_blank" rel="noopener noreferrer" />}
|
||||
render={<a href={apiDocLink} target="_blank" rel="noopener noreferrer" aria-label={t(`${prefixCustomize}.way2.operation`, { ns: 'appOverview' })} />}
|
||||
className="mt-2"
|
||||
>
|
||||
<span className="text-sm text-text-secondary">{t(`${prefixCustomize}.way2.operation`, { ns: 'appOverview' })}</span>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { MutableRefObject } from 'react'
|
||||
import type { WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils'
|
||||
import type { EmbeddedWebAppRoute, WorkflowHiddenStartVariable, WorkflowLaunchInputValue } from '../app-card-utils'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
} from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Suspense, use, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -33,6 +29,7 @@ type Props = Readonly<{
|
||||
onClose: () => void
|
||||
accessToken?: string
|
||||
appBaseUrl?: string
|
||||
webAppRoute?: EmbeddedWebAppRoute
|
||||
hiddenInputs?: WorkflowHiddenStartVariable[]
|
||||
className?: string
|
||||
}>
|
||||
@@ -62,15 +59,17 @@ const getSerializedHiddenInputValue = (
|
||||
const buildEmbeddedIframeUrl = async ({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
webAppRoute,
|
||||
variables,
|
||||
values,
|
||||
}: {
|
||||
appBaseUrl: string
|
||||
accessToken: string
|
||||
webAppRoute: EmbeddedWebAppRoute
|
||||
variables: WorkflowHiddenStartVariable[]
|
||||
values: Record<string, WorkflowLaunchInputValue>
|
||||
}) => {
|
||||
const iframeUrl = new URL(`${appBaseUrl}${basePath}/chatbot/${accessToken}`, window.location.origin)
|
||||
const iframeUrl = new URL(`${appBaseUrl}${basePath}/${webAppRoute}/${accessToken}`, window.location.origin)
|
||||
|
||||
await Promise.all(variables.map(async (variable) => {
|
||||
iframeUrl.searchParams.set(variable.variable, await compressAndEncodeBase64(getSerializedHiddenInputValue(variable, values)))
|
||||
@@ -101,8 +100,9 @@ const EmbeddedContent = ({
|
||||
siteInfo,
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
webAppRoute = 'chatbot',
|
||||
hiddenInputs,
|
||||
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'hiddenInputs'>) => {
|
||||
}: Required<Pick<Props, 'accessToken' | 'appBaseUrl'>> & Pick<Props, 'siteInfo' | 'webAppRoute' | 'hiddenInputs'>) => {
|
||||
const { t } = useTranslation()
|
||||
const supportedHiddenInputs = useMemo<WorkflowHiddenStartVariable[]>(
|
||||
() => (hiddenInputs ?? []).filter(isWorkflowLaunchInputSupported),
|
||||
@@ -122,6 +122,7 @@ const EmbeddedContent = ({
|
||||
() => buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
webAppRoute,
|
||||
variables: supportedHiddenInputs,
|
||||
values: initialHiddenInputValues,
|
||||
}),
|
||||
@@ -143,6 +144,7 @@ const EmbeddedContent = ({
|
||||
setPreviewIframeUrlPromise(buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
webAppRoute,
|
||||
variables: supportedHiddenInputs,
|
||||
values: nextHiddenInputValues,
|
||||
}))
|
||||
@@ -150,15 +152,17 @@ const EmbeddedContent = ({
|
||||
const scriptsContent = useMemo(() => getEmbeddedScriptSnippet({
|
||||
url: appBaseUrl,
|
||||
token: accessToken,
|
||||
webAppRoute,
|
||||
primaryColor: themeBuilder.theme?.primaryColor ?? '#1C64F2',
|
||||
isTestEnv,
|
||||
inputValues: hiddenInputValues,
|
||||
}), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor])
|
||||
}), [accessToken, appBaseUrl, hiddenInputValues, isTestEnv, themeBuilder.theme?.primaryColor, webAppRoute])
|
||||
|
||||
const onClickCopy = async () => {
|
||||
const latestIframeUrl = await buildEmbeddedIframeUrl({
|
||||
appBaseUrl,
|
||||
accessToken,
|
||||
webAppRoute,
|
||||
variables: supportedHiddenInputs,
|
||||
values: hiddenInputValues,
|
||||
})
|
||||
@@ -211,8 +215,8 @@ const EmbeddedContent = ({
|
||||
</div>
|
||||
</div>
|
||||
{hiddenInputsCollapsed
|
||||
? <RiArrowRightSLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
: <RiArrowDownSLine className="size-4 shrink-0 text-text-tertiary" />}
|
||||
? <span aria-hidden className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary" />
|
||||
: <span aria-hidden className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary" />}
|
||||
</button>
|
||||
{!hiddenInputsCollapsed && (
|
||||
<div className="max-h-72 space-y-4 overflow-y-auto border-t-[0.5px] border-divider-subtle px-4 py-4">
|
||||
@@ -307,7 +311,7 @@ const EmbeddedContent = ({
|
||||
)
|
||||
}
|
||||
|
||||
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenInputs, className }: Props) => {
|
||||
const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, webAppRoute = 'chatbot', hiddenInputs, className }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@@ -327,10 +331,11 @@ const Embedded = ({ siteInfo, isShow, onClose, appBaseUrl, accessToken, hiddenIn
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
{isShow && (
|
||||
<EmbeddedContent
|
||||
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
key={`${appBaseUrl ?? ''}:${accessToken ?? ''}:${webAppRoute}:${JSON.stringify(hiddenInputs ?? [])}`}
|
||||
siteInfo={siteInfo}
|
||||
appBaseUrl={appBaseUrl ?? ''}
|
||||
accessToken={accessToken ?? ''}
|
||||
webAppRoute={webAppRoute}
|
||||
hiddenInputs={hiddenInputs}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -231,15 +231,15 @@ describe('SettingsModal', () => {
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should collapse the expanded settings section immediately when closing', () => {
|
||||
it('should keep one show-more trigger while toggling the advanced section', () => {
|
||||
renderSettingsModal()
|
||||
|
||||
expect(screen.getAllByText('appOverview.overview.appInfo.settings.more.entry')).toHaveLength(1)
|
||||
|
||||
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
|
||||
|
||||
expect(screen.getAllByText('appOverview.overview.appInfo.settings.more.entry')).toHaveLength(1)
|
||||
expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
expect(screen.getByText('appOverview.overview.appInfo.settings.more.entry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should reset local form state when the controlled dialog reopens', () => {
|
||||
@@ -276,6 +276,7 @@ describe('SettingsModal', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByText('appOverview.overview.appInfo.settings.more.entry')).toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the pricing modal from the copyright upgrade badge for sandbox plans', async () => {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppIconType, AppSSO, Language } from '@/types/app'
|
||||
import type { AppIconType, Language, SiteConfig } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { CollapsiblePanel, CollapsibleRoot, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { FieldControl, FieldDescription, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
@@ -29,13 +32,38 @@ import { AppModeEnum } from '@/types/app'
|
||||
|
||||
type ISettingsModalProps = {
|
||||
isChat: boolean
|
||||
appInfo: AppDetailResponse & Partial<AppSSO>
|
||||
appInfo: SettingsAppInfo
|
||||
isShow: boolean
|
||||
defaultValue?: string
|
||||
onClose: () => void
|
||||
onSave?: (params: ConfigParams) => Promise<void>
|
||||
}
|
||||
|
||||
type SettingsSiteInfo = Pick<
|
||||
SiteConfig,
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'default_language'
|
||||
| 'chat_color_theme'
|
||||
| 'chat_color_theme_inverted'
|
||||
| 'copyright'
|
||||
| 'privacy_policy'
|
||||
| 'custom_disclaimer'
|
||||
| 'icon_type'
|
||||
| 'icon'
|
||||
| 'icon_background'
|
||||
| 'icon_url'
|
||||
| 'show_workflow_steps'
|
||||
| 'use_icon_as_answer_icon'
|
||||
>
|
||||
|
||||
export type SettingsAppInfo = {
|
||||
id: string
|
||||
mode: AppModeEnum
|
||||
enable_sso?: boolean
|
||||
site: SettingsSiteInfo
|
||||
}
|
||||
|
||||
export type ConfigParams = {
|
||||
title: string
|
||||
description: string
|
||||
@@ -124,7 +152,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [isShowMore, setIsShowMore] = useState(false)
|
||||
const { default_language } = appInfo.site
|
||||
const nextInputInfo = createInputInfo(appInfo)
|
||||
const nextAppIcon = createAppIcon(appInfo)
|
||||
@@ -164,17 +191,11 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
setInputInfo(nextInputInfo)
|
||||
setLanguage(default_language)
|
||||
setAppIcon(nextAppIcon)
|
||||
setIsShowMore(false)
|
||||
setPreviousSettingsResetKey(settingsResetKey)
|
||||
}
|
||||
}
|
||||
|
||||
const onHide = () => {
|
||||
onClose()
|
||||
setIsShowMore(false)
|
||||
}
|
||||
|
||||
const onClickSave = async () => {
|
||||
const handleFormSubmit = async () => {
|
||||
if (!inputInfo.title) {
|
||||
toast.error(t('newApp.nameNotEmpty', { ns: 'app' }))
|
||||
return
|
||||
@@ -231,7 +252,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
}
|
||||
await onSave?.(params)
|
||||
setSaveLoading(false)
|
||||
onHide()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const onChange = (field: string) => {
|
||||
@@ -252,8 +273,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isShow} onOpenChange={open => !open && onHide()}>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[520px] flex-col overflow-hidden! p-0!">
|
||||
<Dialog open={isShow} onOpenChange={open => !open && onClose()} disablePointerDismissal>
|
||||
<DialogContent className="grid max-h-[calc(100dvh-2rem)] w-[520px] grid-rows-[auto_minmax(0,1fr)] overflow-hidden p-0">
|
||||
{/* header */}
|
||||
<div className="shrink-0 pt-5 pr-5 pb-3 pl-6">
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -264,224 +285,227 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
<span>{t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* form body */}
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain px-6 py-3">
|
||||
{/* name & icon */}
|
||||
<div className="flex gap-4">
|
||||
<div className="grow">
|
||||
<div className={cn('mb-1 py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
value={inputInfo.title}
|
||||
onChange={onChange('title')}
|
||||
placeholder={t('appNamePlaceholder', { ns: 'app' }) || ''}
|
||||
<Form className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]" onFormSubmit={handleFormSubmit}>
|
||||
{/* form body */}
|
||||
<ScrollArea
|
||||
className="relative min-h-0"
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-w-0 space-y-5 px-6 py-3',
|
||||
}}
|
||||
>
|
||||
{/* name & icon */}
|
||||
<div className="flex gap-4">
|
||||
<FieldRoot name="title" className="grow">
|
||||
<FieldLabel>{t(`${prefixSettings}.webName`, { ns: 'appOverview' })}</FieldLabel>
|
||||
<FieldControl
|
||||
value={inputInfo.title}
|
||||
onValueChange={value => setInputInfo(item => ({ ...item, title: value }))}
|
||||
placeholder={t('appNamePlaceholder', { ns: 'app' }) || ''}
|
||||
/>
|
||||
</FieldRoot>
|
||||
<AppIcon
|
||||
size="xxl"
|
||||
onClick={() => { setShowAppIconPicker(true) }}
|
||||
className="mt-2 cursor-pointer"
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
|
||||
background={appIcon.type === 'image' ? undefined : appIcon.background}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
/>
|
||||
</div>
|
||||
<AppIcon
|
||||
size="xxl"
|
||||
onClick={() => { setShowAppIconPicker(true) }}
|
||||
className="mt-2 cursor-pointer"
|
||||
iconType={appIcon.type}
|
||||
icon={appIcon.type === 'image' ? appIcon.fileId : appIcon.icon}
|
||||
background={appIcon.type === 'image' ? undefined : appIcon.background}
|
||||
imageUrl={appIcon.type === 'image' ? appIcon.url : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/* description */}
|
||||
<div className="relative">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
|
||||
<Textarea
|
||||
aria-label={t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}
|
||||
className="mt-1"
|
||||
value={inputInfo.desc}
|
||||
onValueChange={onDesChange}
|
||||
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
|
||||
</div>
|
||||
<Divider className="my-0 h-px" />
|
||||
{/* answer icon */}
|
||||
{isChat && (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t('answerIcon.title', { ns: 'app' })}</div>
|
||||
<Switch
|
||||
checked={inputInfo.use_icon_as_answer_icon}
|
||||
onCheckedChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
|
||||
/>
|
||||
</div>
|
||||
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t('answerIcon.description', { ns: 'app' })}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* language */}
|
||||
<div className="flex items-center">
|
||||
<div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
|
||||
<Select
|
||||
value={selectedLanguage?.value ?? null}
|
||||
onValueChange={handleLanguageChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t(`${prefixSettings}.language`, { ns: 'appOverview' })}
|
||||
size="large"
|
||||
className="w-[200px]"
|
||||
>
|
||||
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* theme color */}
|
||||
{isChat && (
|
||||
<div className="flex items-center">
|
||||
<div className="grow">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.chatColorTheme`, { ns: 'appOverview' })}</div>
|
||||
<div className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.chatColorThemeDesc`, { ns: 'appOverview' })}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Input
|
||||
className="mb-1 w-[200px]"
|
||||
value={inputInfo.chatColorTheme ?? ''}
|
||||
onChange={onChange('chatColorTheme')}
|
||||
placeholder="E.g #A020F0"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={cn('body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
|
||||
<Switch checked={inputInfo.chatColorThemeInverted} onCheckedChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
|
||||
{/* description */}
|
||||
<FieldRoot name="description">
|
||||
<FieldLabel>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</FieldLabel>
|
||||
<Textarea
|
||||
value={inputInfo.desc}
|
||||
onValueChange={onDesChange}
|
||||
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
<FieldDescription>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</FieldDescription>
|
||||
</FieldRoot>
|
||||
<Divider className="my-0 h-px" />
|
||||
{/* answer icon */}
|
||||
{isChat && (
|
||||
<FieldRoot name="use_icon_as_answer_icon" className="w-full">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel>{t('answerIcon.title', { ns: 'app' })}</FieldLabel>
|
||||
<Switch
|
||||
checked={inputInfo.use_icon_as_answer_icon}
|
||||
onCheckedChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
|
||||
/>
|
||||
</div>
|
||||
<FieldDescription>{t('answerIcon.description', { ns: 'app' })}</FieldDescription>
|
||||
</FieldRoot>
|
||||
)}
|
||||
{/* language */}
|
||||
<div className="flex items-center">
|
||||
<div className={cn('grow py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.language`, { ns: 'appOverview' })}</div>
|
||||
<Select
|
||||
value={selectedLanguage?.value ?? null}
|
||||
onValueChange={handleLanguageChange}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t(`${prefixSettings}.language`, { ns: 'appOverview' })}
|
||||
size="medium"
|
||||
className="w-[200px]"
|
||||
>
|
||||
{selectedLanguage?.name ?? t('placeholder.select', { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map(item => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<SelectItemText>{item.name}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* theme color */}
|
||||
{isChat && (
|
||||
<div className="flex items-center">
|
||||
<div className="grow">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.chatColorTheme`, { ns: 'appOverview' })}</div>
|
||||
<div className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.chatColorThemeDesc`, { ns: 'appOverview' })}</div>
|
||||
</div>
|
||||
<FieldRoot name="chat_color_theme" className="w-[200px] shrink-0">
|
||||
<FieldControl
|
||||
className="mb-1"
|
||||
value={inputInfo.chatColorTheme ?? ''}
|
||||
onValueChange={value => setInputInfo(item => ({ ...item, chatColorTheme: value }))}
|
||||
placeholder="E.g #A020F0"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 body-xs-regular text-text-tertiary">
|
||||
<span>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</span>
|
||||
<Switch checked={inputInfo.chatColorThemeInverted} onCheckedChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
|
||||
</div>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* workflow detail */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</div>
|
||||
<Switch
|
||||
disabled={!(appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT)}
|
||||
checked={inputInfo.show_workflow_steps}
|
||||
onCheckedChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
|
||||
/>
|
||||
</div>
|
||||
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</p>
|
||||
</div>
|
||||
{/* more settings switch */}
|
||||
<Divider className="my-0 h-px" />
|
||||
{!isShowMore && (
|
||||
<div className="flex cursor-pointer items-center" onClick={() => setIsShowMore(true)}>
|
||||
<div className="grow">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>
|
||||
{t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' })}
|
||||
{' '}
|
||||
&
|
||||
{' '}
|
||||
{t(`${prefixSettings}.more.privacyPolicyPlaceholder`, { ns: 'appOverview' })}
|
||||
</p>
|
||||
)}
|
||||
{/* workflow detail */}
|
||||
<FieldRoot name="show_workflow_steps" className="w-full">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<FieldLabel>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</FieldLabel>
|
||||
<Switch
|
||||
disabled={!(appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT)}
|
||||
checked={inputInfo.show_workflow_steps}
|
||||
onCheckedChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
|
||||
/>
|
||||
</div>
|
||||
<span aria-hidden="true" className="ml-1 i-ri-arrow-right-s-line size-4 shrink-0 text-text-secondary" />
|
||||
</div>
|
||||
)}
|
||||
{/* more settings */}
|
||||
{isShowMore && (
|
||||
<>
|
||||
{/* copyright */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center">
|
||||
<div className="flex grow items-center">
|
||||
<div className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
|
||||
{/* upgrade button */}
|
||||
{showUpgradeAction && (
|
||||
<div className="h-[18px] select-none">
|
||||
<PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}>
|
||||
<span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
|
||||
<div className="system-xs-medium">
|
||||
<span className="p-1">
|
||||
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
|
||||
</span>
|
||||
<FieldDescription>{t(`${prefixSettings}.workflow.showDesc`, { ns: 'appOverview' })}</FieldDescription>
|
||||
</FieldRoot>
|
||||
<CollapsibleRoot>
|
||||
<Divider className="my-0 h-px" />
|
||||
<CollapsibleTrigger className="-mx-2 mt-2 min-h-11 px-2 py-1.5 hover:not-data-disabled:bg-components-panel-on-panel-item-bg-hover focus-visible:ring-inset data-panel-open:text-text-secondary">
|
||||
<div className="min-w-0 grow">
|
||||
<div className="system-sm-semibold text-text-secondary">{t(`${prefixSettings}.more.entry`, { ns: 'appOverview' })}</div>
|
||||
<p className="truncate body-xs-regular text-text-tertiary">
|
||||
{t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' })}
|
||||
{' '}
|
||||
&
|
||||
{' '}
|
||||
{t(`${prefixSettings}.more.privacyPolicyPlaceholder`, { ns: 'appOverview' })}
|
||||
</p>
|
||||
</div>
|
||||
<span aria-hidden="true" className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-secondary transition-transform group-data-panel-open:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel>
|
||||
<div className="space-y-5 pt-5">
|
||||
{/* copyright */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center">
|
||||
<div className="flex grow items-center">
|
||||
<div className={cn('mr-1 py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.copyright`, { ns: 'appOverview' })}</div>
|
||||
{/* upgrade button */}
|
||||
{showUpgradeAction && (
|
||||
<div className="h-[18px] select-none">
|
||||
<PremiumBadgeButton size="s" color="blue" onClick={handlePlanClick}>
|
||||
<span aria-hidden="true" className="i-custom-public-common-sparkles-soft flex h-3.5 w-3.5 items-center py-px pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
|
||||
<div className="system-xs-medium">
|
||||
<span className="p-1">
|
||||
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadgeButton>
|
||||
</div>
|
||||
</PremiumBadgeButton>
|
||||
)}
|
||||
</div>
|
||||
{webappCopyrightEnabled
|
||||
? (
|
||||
<Switch
|
||||
checked={inputInfo.copyrightSwitchValue}
|
||||
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div>
|
||||
<Switch
|
||||
disabled
|
||||
checked={inputInfo.copyrightSwitchValue}
|
||||
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent className="w-[180px]">
|
||||
{t(`${prefixSettings}.more.copyrightTooltip`, { ns: 'appOverview' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
|
||||
{inputInfo.copyrightSwitchValue && (
|
||||
<Input
|
||||
className="mt-2 h-10"
|
||||
value={inputInfo.copyright}
|
||||
onChange={onChange('copyright')}
|
||||
placeholder={t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{webappCopyrightEnabled
|
||||
? (
|
||||
<Switch
|
||||
checked={inputInfo.copyrightSwitchValue}
|
||||
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div>
|
||||
<Switch
|
||||
disabled
|
||||
checked={inputInfo.copyrightSwitchValue}
|
||||
onCheckedChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent className="w-[180px]">
|
||||
{t(`${prefixSettings}.more.copyrightTooltip`, { ns: 'appOverview' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* privacy policy */}
|
||||
<div className="w-full">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.privacyPolicy`, { ns: 'appOverview' })}</div>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>
|
||||
<Trans
|
||||
i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
|
||||
ns="appOverview"
|
||||
components={{ privacyPolicyLink: <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer" className="text-text-accent" /> }}
|
||||
/>
|
||||
</p>
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={inputInfo.privacyPolicy}
|
||||
onChange={onChange('privacyPolicy')}
|
||||
placeholder={t(`${prefixSettings}.more.privacyPolicyPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
</div>
|
||||
{/* custom disclaimer */}
|
||||
<div className="w-full">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
|
||||
<Textarea
|
||||
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
|
||||
className="mt-1"
|
||||
value={inputInfo.customDisclaimer}
|
||||
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
|
||||
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="pb-0.5 body-xs-regular text-text-tertiary">{t(`${prefixSettings}.more.copyrightTip`, { ns: 'appOverview' })}</p>
|
||||
{inputInfo.copyrightSwitchValue && (
|
||||
<Input
|
||||
className="mt-2 h-10"
|
||||
value={inputInfo.copyright}
|
||||
onChange={onChange('copyright')}
|
||||
placeholder={t(`${prefixSettings}.more.copyRightPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* privacy policy */}
|
||||
<div className="w-full">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.privacyPolicy`, { ns: 'appOverview' })}</div>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>
|
||||
<Trans
|
||||
i18nKey={`${prefixSettings}.more.privacyPolicyTip`}
|
||||
ns="appOverview"
|
||||
components={{ privacyPolicyLink: <Link href="https://dify.ai/privacy" target="_blank" rel="noopener noreferrer" className="text-text-accent" /> }}
|
||||
/>
|
||||
</p>
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={inputInfo.privacyPolicy}
|
||||
onChange={onChange('privacyPolicy')}
|
||||
placeholder={t(`${prefixSettings}.more.privacyPolicyPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
</div>
|
||||
{/* custom disclaimer */}
|
||||
<div className="w-full">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
|
||||
<Textarea
|
||||
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
|
||||
className="mt-1"
|
||||
value={inputInfo.customDisclaimer}
|
||||
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
|
||||
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* footer */}
|
||||
<div className="flex shrink-0 justify-end p-6 pt-5">
|
||||
<Button className="mr-2" onClick={onHide}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button variant="primary" onClick={onClickSave} loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</CollapsibleRoot>
|
||||
</ScrollArea>
|
||||
{/* footer */}
|
||||
<div className="flex shrink-0 justify-end p-6 pt-5">
|
||||
<Button type="button" className="mr-2" onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button type="submit" variant="primary" loading={saveLoading}>{t('operation.save', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AppIconPicker
|
||||
|
||||
@@ -57,6 +57,9 @@ const ChatWrapper = () => {
|
||||
} = useChatWithHistoryContext()
|
||||
|
||||
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
|
||||
const timezone = appSourceType === AppSourceType.webApp
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: undefined
|
||||
|
||||
// Semantic variable for better code readability
|
||||
const isHistoryConversation = !!currentConversationId
|
||||
@@ -91,6 +94,8 @@ const ChatWrapper = () => {
|
||||
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
undefined,
|
||||
{ timezone },
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { useParams, usePathname } from '@/next/navigation'
|
||||
import { sseGet, ssePost } from '@/service/base'
|
||||
import { useChat } from '../hooks'
|
||||
|
||||
const useTimestampMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') })),
|
||||
)
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
sseGet: vi.fn(),
|
||||
ssePost: vi.fn(),
|
||||
@@ -31,7 +35,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-timestamp', () => ({
|
||||
default: () => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') }),
|
||||
default: useTimestampMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
@@ -91,6 +95,21 @@ describe('useChat', () => {
|
||||
expect(result.current.suggestedQuestions).toEqual([])
|
||||
})
|
||||
|
||||
it('should pass timestamp options to timestamp formatter', () => {
|
||||
renderHook(() => useChat(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ timezone: 'UTC' },
|
||||
))
|
||||
|
||||
expect(useTimestampMock).toHaveBeenCalledWith({ timezone: 'UTC' })
|
||||
})
|
||||
|
||||
it('should initialize with opening statement and suggested questions', () => {
|
||||
const config = {
|
||||
opening_statement: 'Hello {{name}}',
|
||||
|
||||
@@ -20,9 +20,14 @@ import { useCheckInputsForms } from '../check-input-forms-hooks'
|
||||
import { useTextAreaHeight } from './hooks'
|
||||
import Operation from './operation'
|
||||
|
||||
type AudioRecorderWithPermission = typeof Recorder & {
|
||||
getPermission: () => Promise<void>
|
||||
}
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
readonly?: boolean
|
||||
botName?: string
|
||||
placeholder?: string
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
featureBarReadonly?: boolean
|
||||
@@ -31,7 +36,7 @@ type ChatInputAreaProps = {
|
||||
visionConfig?: FileUpload
|
||||
speechToTextConfig?: EnableType
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputs?: Record<string, unknown>
|
||||
inputsForm?: InputForm[]
|
||||
theme?: Theme | null
|
||||
isResponding?: boolean
|
||||
@@ -44,7 +49,7 @@ type ChatInputAreaProps = {
|
||||
*/
|
||||
sendOnEnter?: boolean
|
||||
}
|
||||
const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
|
||||
const ChatInputArea = ({ readonly, botName, placeholder, showFeatureBar, showFileUpload, featureBarReadonly = readonly, featureBarDisabled, onFeatureBarClick, visionConfig, speechToTextConfig = { enabled: true }, onSend, inputs = {}, inputsForm = [], theme, isResponding, disabled, sendOnEnter = true }: ChatInputAreaProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { wrapperRef, textareaRef, textValueRef, holdSpaceRef, handleTextareaResize, isMultipleLine } = useTextAreaHeight()
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -130,7 +135,7 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
|
||||
}
|
||||
}
|
||||
const handleShowVoiceInput = useCallback(() => {
|
||||
(Recorder as any).getPermission().then(() => {
|
||||
;(Recorder as AudioRecorderWithPermission).getPermission().then(() => {
|
||||
setShowVoiceInput(true)
|
||||
}, () => {
|
||||
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
|
||||
@@ -147,7 +152,28 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
|
||||
<div ref={textValueRef} className="pointer-events-none invisible absolute size-auto p-1 body-lg-regular leading-6 whitespace-pre">
|
||||
{query}
|
||||
</div>
|
||||
<Textarea ref={ref => textareaRef.current = ref as any} className={cn('w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-text-primary outline-hidden')} placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')} autoFocus minRows={1} value={query} onChange={e => handleQueryChange(e.target.value)} onKeyDown={handleKeyDown} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} onPaste={handleClipboardPasteFile} onDragEnter={handleDragFileEnter} onDragLeave={handleDragFileLeave} onDragOver={handleDragFileOver} onDrop={handleDropFile} readOnly={readonly} />
|
||||
<Textarea
|
||||
ref={(ref) => {
|
||||
textareaRef.current = ref ?? undefined
|
||||
}}
|
||||
className={cn('w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-text-primary outline-hidden')}
|
||||
placeholder={placeholder ?? decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')}
|
||||
// Existing chat behavior focuses the composer as soon as it opens.
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
minRows={1}
|
||||
value={query}
|
||||
onChange={e => handleQueryChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onPaste={handleClipboardPasteFile}
|
||||
onDragEnter={handleDragFileEnter}
|
||||
onDragLeave={handleDragFileLeave}
|
||||
onDragOver={handleDragFileOver}
|
||||
onDrop={handleDropFile}
|
||||
readOnly={readonly}
|
||||
/>
|
||||
</div>
|
||||
{!isMultipleLine && operation}
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,10 @@ type SendCallback = {
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
type UseChatOptions = {
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export const useChat = (
|
||||
config?: ChatConfig,
|
||||
formSettings?: {
|
||||
@@ -66,9 +70,10 @@ export const useChat = (
|
||||
clearChatList?: boolean,
|
||||
clearChatListCallback?: (state: boolean) => void,
|
||||
initialConversationId?: string,
|
||||
options: UseChatOptions = {},
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { formatTime } = useTimestamp({ timezone: options.timezone })
|
||||
const conversationIdRef = useRef(initialConversationId ?? '')
|
||||
const initialConversationIdRef = useRef(initialConversationId ?? '')
|
||||
const hasStopRespondedRef = useRef(false)
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
|
||||
import type { InputForm } from './type'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { AppData } from '@/models/share'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -39,7 +41,7 @@ export type ChatProps = {
|
||||
onStopResponding?: () => void
|
||||
noChatInput?: boolean
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputs?: Record<string, unknown>
|
||||
inputsForm?: InputForm[]
|
||||
onRegenerate?: OnRegenerate
|
||||
chatContainerClassName?: string
|
||||
@@ -68,11 +70,13 @@ export type ChatProps = {
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
noSpacing?: boolean
|
||||
inputDisabled?: boolean
|
||||
inputPlaceholder?: string
|
||||
inputPlaceholderBotName?: string
|
||||
sidebarCollapseState?: boolean
|
||||
hideAvatar?: boolean
|
||||
sendOnEnter?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => any
|
||||
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
|
||||
}
|
||||
|
||||
const Chat: FC<ChatProps> = ({
|
||||
@@ -114,6 +118,8 @@ const Chat: FC<ChatProps> = ({
|
||||
onFeatureBarClick,
|
||||
noSpacing,
|
||||
inputDisabled,
|
||||
inputPlaceholder,
|
||||
inputPlaceholderBotName,
|
||||
sidebarCollapseState,
|
||||
hideAvatar,
|
||||
sendOnEnter,
|
||||
@@ -180,7 +186,7 @@ const Chat: FC<ChatProps> = ({
|
||||
appData={appData}
|
||||
key={item.id}
|
||||
item={item}
|
||||
question={chatList[index - 1]?.content!}
|
||||
question={chatList[index - 1]?.content ?? ''}
|
||||
index={index}
|
||||
config={config}
|
||||
answerIcon={answerIcon}
|
||||
@@ -240,7 +246,8 @@ const Chat: FC<ChatProps> = ({
|
||||
{
|
||||
!noChatInput && (
|
||||
<ChatInputArea
|
||||
botName={appData?.site?.title || 'Bot'}
|
||||
botName={inputPlaceholderBotName || appData?.site?.title || 'Bot'}
|
||||
placeholder={inputPlaceholder}
|
||||
disabled={inputDisabled}
|
||||
showFeatureBar={showFeatureBar}
|
||||
showFileUpload={showFileUpload}
|
||||
|
||||
@@ -81,6 +81,9 @@ const ChatWrapper = () => {
|
||||
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
|
||||
} as ChatConfig
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const timezone = appSourceType === AppSourceType.webApp
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: undefined
|
||||
const {
|
||||
chatList,
|
||||
handleSend,
|
||||
@@ -98,6 +101,8 @@ const ChatWrapper = () => {
|
||||
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
undefined,
|
||||
{ timezone },
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
|
||||
@@ -384,6 +384,7 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: /common.menus.roster/ })).toHaveAttribute('href', '/roster')
|
||||
expect(screen.getByRole('link', { name: /common.menus.roster common.menus.status/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider')
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/marketplace')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { MainNavItem } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import Link from '@/next/link'
|
||||
@@ -17,11 +18,13 @@ const NavIcon = ({
|
||||
type MainNavLinkProps = {
|
||||
item: MainNavItem
|
||||
pathname: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const MainNavLink = ({
|
||||
item,
|
||||
pathname,
|
||||
children,
|
||||
}: MainNavLinkProps) => {
|
||||
const activated = item.active(pathname)
|
||||
|
||||
@@ -38,7 +41,8 @@ const MainNavLink = ({
|
||||
>
|
||||
<NavIcon icon={item.icon} className="group-aria-[current=page]:hidden" />
|
||||
<NavIcon icon={item.activeIcon} className="hidden group-aria-[current=page]:block" />
|
||||
<span className="truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">{item.label}</span>
|
||||
<span className="min-w-0 truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">{item.label}</span>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import AppDetailTop from '@/app/components/app-sidebar/app-detail-top'
|
||||
import DatasetDetailSection from '@/app/components/app-sidebar/dataset-detail-section'
|
||||
import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import EnvNav from '@/app/components/header/env-nav'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@@ -299,7 +300,16 @@ const MainNav = ({
|
||||
<>
|
||||
<nav className="isolate flex flex-col gap-px p-2">
|
||||
{navItems.map(item => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname} />
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname}>
|
||||
{item.href === '/roster' && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dimm"
|
||||
text={t('menus.status', { ns: 'common' })}
|
||||
className="ml-auto shrink-0"
|
||||
/>
|
||||
)}
|
||||
</MainNavLink>
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
|
||||
@@ -270,6 +270,71 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
|
||||
expect(callbacks.onSettled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep pending inline Agent v2 nodes in draft without incomplete bindings', async () => {
|
||||
reactFlowState = {
|
||||
...reactFlowState,
|
||||
edges: [
|
||||
{ id: 'edge-1', source: 'n1', target: 'pending-agent', data: { sourceType: BlockEnum.Start, targetType: BlockEnum.Agent } },
|
||||
{ id: 'temp-edge', source: 'temp-node', target: 'pending-agent', data: {} },
|
||||
],
|
||||
}
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } },
|
||||
{
|
||||
id: 'pending-agent',
|
||||
position: { x: 1, y: 1 },
|
||||
data: {
|
||||
type: BlockEnum.Agent,
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
agent_node_kind: 'dify_agent',
|
||||
version: '2',
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
},
|
||||
_isTempNode: true,
|
||||
_openInlineAgentPanel: true,
|
||||
selected: true,
|
||||
},
|
||||
},
|
||||
{ id: 'temp-node', position: { x: 2, y: 2 }, data: { type: BlockEnum.Answer, _isTempNode: true } },
|
||||
])
|
||||
|
||||
const { result } = renderUseNodesSyncDraft()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false)
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
graph: expect.objectContaining({
|
||||
nodes: [
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Start } },
|
||||
{
|
||||
id: 'pending-agent',
|
||||
position: { x: 1, y: 1 },
|
||||
data: {
|
||||
type: BlockEnum.Agent,
|
||||
title: 'Agent',
|
||||
desc: '',
|
||||
agent_node_kind: 'dify_agent',
|
||||
version: '2',
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
},
|
||||
selected: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ id: 'edge-1', source: 'n1', target: 'pending-agent', data: { sourceType: BlockEnum.Start, targetType: BlockEnum.Agent } },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should post workflow draft with keepalive when the page closes', () => {
|
||||
reactFlowState = {
|
||||
...reactFlowState,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
|
||||
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
|
||||
import { useNodesReadOnly, useNodesReadOnlyByCanEdit } from '@/app/components/workflow/hooks/use-workflow'
|
||||
import { isAgentV2NodeData, needsInlineAgentBindingCreation } from '@/app/components/workflow/nodes/agent-v2/types'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { API_PREFIX } from '@/config'
|
||||
@@ -33,10 +34,26 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
|
||||
transform,
|
||||
} = store.getState()
|
||||
const allNodes = getNodes()
|
||||
const nodes = allNodes.filter(node => !node.data?._isTempNode && node.data?.type !== BlockEnum.StartPlaceholder)
|
||||
const nodes = allNodes.filter((node) => {
|
||||
if (node.data?.type === BlockEnum.StartPlaceholder)
|
||||
return false
|
||||
|
||||
if (!node.data?._isTempNode)
|
||||
return true
|
||||
|
||||
return isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data)
|
||||
})
|
||||
const skippedNodeIds = new Set(
|
||||
allNodes
|
||||
.filter(node => node.data?._isTempNode || node.data?.type === BlockEnum.StartPlaceholder)
|
||||
.filter((node) => {
|
||||
if (node.data?.type === BlockEnum.StartPlaceholder)
|
||||
return true
|
||||
|
||||
if (!node.data?._isTempNode)
|
||||
return false
|
||||
|
||||
return !(isAgentV2NodeData(node.data) && needsInlineAgentBindingCreation(node.data))
|
||||
})
|
||||
.map(node => node.id),
|
||||
)
|
||||
const [x, y, zoom] = transform
|
||||
@@ -159,9 +176,10 @@ const useNodesSyncDraftBase = (getNodesReadOnly: () => boolean) => {
|
||||
setDraftUpdatedAt(res.updated_at)
|
||||
callback?.onSuccess?.()
|
||||
}
|
||||
catch (error: any) {
|
||||
if (error && error.json && !error.bodyUsed) {
|
||||
error.json().then((err: any) => {
|
||||
catch (error: unknown) {
|
||||
const responseError = error as { bodyUsed?: boolean, json?: () => Promise<{ code?: string }> }
|
||||
if (responseError.json && !responseError.bodyUsed) {
|
||||
responseError.json().then((err) => {
|
||||
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
|
||||
handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
|
||||
@@ -294,6 +294,7 @@ describe('CandidateNodeMain', () => {
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
},
|
||||
selected: true,
|
||||
_isTempNode: true,
|
||||
}),
|
||||
}),
|
||||
@@ -307,6 +308,7 @@ describe('CandidateNodeMain', () => {
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'inline-snapshot-1',
|
||||
},
|
||||
selected: true,
|
||||
}),
|
||||
}),
|
||||
]))
|
||||
|
||||
@@ -53,12 +53,18 @@ const CandidateNodeMain: FC<Props> = ({
|
||||
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
|
||||
const shouldCreateInlineAgentBinding = isAgentV2NodeData(candidateNode.data) && needsInlineAgentBindingCreation(candidateNode.data)
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
if (shouldCreateInlineAgentBinding) {
|
||||
draft.forEach((node) => {
|
||||
node.data.selected = false
|
||||
})
|
||||
}
|
||||
draft.push({
|
||||
...candidateNode,
|
||||
data: {
|
||||
...candidateNode.data,
|
||||
_isCandidate: false,
|
||||
_isTempNode: shouldCreateInlineAgentBinding ? true : candidateNode.data._isTempNode,
|
||||
selected: shouldCreateInlineAgentBinding ? true : candidateNode.data.selected,
|
||||
},
|
||||
position: {
|
||||
x,
|
||||
@@ -89,6 +95,8 @@ const CandidateNodeMain: FC<Props> = ({
|
||||
}
|
||||
|
||||
if (shouldCreateInlineAgentBinding) {
|
||||
workflowStore.getState().setOpenInlineAgentPanelNodeId(candidateNode.id)
|
||||
handleSyncWorkflowDraft(true, true)
|
||||
createInlineAgentBinding(candidateNode.id, {
|
||||
onError: () => {
|
||||
const { nodes, setNodes } = collaborativeWorkflow.getState()
|
||||
@@ -105,7 +113,6 @@ const CandidateNodeMain: FC<Props> = ({
|
||||
delete node.data._isTempNode
|
||||
}
|
||||
}))
|
||||
workflowStore.getState().setOpenInlineAgentPanelNodeId(candidateNode.id)
|
||||
handleSyncWorkflowDraft(true, true)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -193,6 +193,8 @@ export const useNodesInteractions = () => {
|
||||
const createInlineAgentBindingForNode = useCallback((nodeId: string, options?: {
|
||||
onError?: () => void
|
||||
}) => {
|
||||
workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId)
|
||||
handleSyncWorkflowDraft(true, true)
|
||||
createInlineAgentBinding(nodeId, {
|
||||
onError: () => {
|
||||
options?.onError?.()
|
||||
@@ -208,7 +210,6 @@ export const useNodesInteractions = () => {
|
||||
delete node.data._isTempNode
|
||||
}
|
||||
}))
|
||||
workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId)
|
||||
handleSyncWorkflowDraft(true, true)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -156,6 +156,16 @@ describe('useCreateInlineAgentBinding', () => {
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'inline-snapshot-1',
|
||||
}))
|
||||
expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual(expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
schema_version: 1,
|
||||
}),
|
||||
binding: expect.objectContaining({
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'inline-snapshot-1',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('creates inline agent with a model-less initial soul before the default model loads', async () => {
|
||||
@@ -212,6 +222,61 @@ describe('useCreateInlineAgentBinding', () => {
|
||||
current_snapshot_id: 'inline-snapshot-1',
|
||||
}))
|
||||
})
|
||||
|
||||
it('finishes inline creation after the caller component unmounts', async () => {
|
||||
let resolveComposerState!: (value: Awaited<ReturnType<typeof mockComposerMutationFn>>) => void
|
||||
mockComposerMutationFn.mockImplementationOnce(async _variables =>
|
||||
new Promise((resolve) => {
|
||||
resolveComposerState = resolve as typeof resolveComposerState
|
||||
}),
|
||||
)
|
||||
const onSuccess = vi.fn()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result, unmount } = renderWorkflowHook(() => useCreateInlineAgentBinding(), {
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
void result.current.createInlineAgentBinding('node-1', { onSuccess })
|
||||
})
|
||||
await waitFor(() => expect(mockComposerMutationFn).toHaveBeenCalled())
|
||||
unmount()
|
||||
resolveComposerState({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
},
|
||||
binding: {
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'inline-snapshot-1',
|
||||
},
|
||||
variables: {},
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith({
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'inline-snapshot-1',
|
||||
}))
|
||||
expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toEqual(expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
schema_version: 1,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
|
||||
@@ -123,9 +123,9 @@ describe('agent/node', () => {
|
||||
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.name')).toHaveClass('system-xs-regular', 'text-text-secondary')
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.type')).toHaveClass('system-2xs-regular', 'text-text-tertiary')
|
||||
const robotIcon = container.querySelector('.i-custom-vender-agent-v2-robot-3')
|
||||
expect(robotIcon).toHaveClass('size-5')
|
||||
expect(robotIcon?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
|
||||
const configureIcon = container.querySelector('.i-custom-vender-agent-v2-configure')
|
||||
expect(configureIcon).toHaveClass('h-3.5', 'w-3')
|
||||
expect(configureIcon?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
|
||||
})
|
||||
|
||||
it('renders the fixed inline setup name when workflow composer state is loaded', () => {
|
||||
|
||||
@@ -14,6 +14,8 @@ const {
|
||||
mockInsertNodes,
|
||||
mockOrchestrateDrawerPanelProps,
|
||||
mockPromptEditorProps,
|
||||
mockCopyFromRosterMutate,
|
||||
mockCopyFromRosterState,
|
||||
mockCreateInlineAgentBinding,
|
||||
mockSetInputs,
|
||||
mockStoreState,
|
||||
@@ -27,16 +29,21 @@ const {
|
||||
mockHandleNodeDataUpdateWithSyncDraft: vi.fn((_payload, options) => options?.callback?.onSuccess?.()),
|
||||
mockInsertNodes: vi.fn(),
|
||||
mockOrchestrateDrawerPanelProps: [] as Array<{
|
||||
agentId: string
|
||||
agentId?: string
|
||||
inlineComposerState?: unknown
|
||||
isInline: boolean
|
||||
nodeId: string
|
||||
open: boolean
|
||||
}>,
|
||||
mockPromptEditorProps: [] as PromptEditorProps[],
|
||||
mockCopyFromRosterMutate: vi.fn(),
|
||||
mockCopyFromRosterState: {
|
||||
isPending: false,
|
||||
},
|
||||
mockCreateInlineAgentBinding: vi.fn(),
|
||||
mockSetInputs: vi.fn(),
|
||||
mockStoreState: {
|
||||
appId: 'app-1',
|
||||
openInlineAgentPanelNodeId: undefined as string | undefined,
|
||||
setOpenInlineAgentPanelNodeId: vi.fn(),
|
||||
},
|
||||
@@ -80,6 +87,18 @@ vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
}],
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useMutation: () => ({
|
||||
isPending: mockCopyFromRosterState.isPending,
|
||||
mutate: mockCopyFromRosterMutate,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('lexical', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('lexical')>()
|
||||
|
||||
@@ -150,7 +169,7 @@ vi.mock('../hooks', () => ({
|
||||
|
||||
vi.mock('../components/agent-orchestrate-drawer-panel', () => ({
|
||||
AgentOrchestrateDrawerPanel: (props: {
|
||||
agentId: string
|
||||
agentId?: string
|
||||
appId?: string
|
||||
inlineComposerState?: unknown
|
||||
isInline: boolean
|
||||
@@ -165,6 +184,41 @@ vi.mock('../components/agent-orchestrate-drawer-panel', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../components/save-inline-agent-to-roster-dialog', () => ({
|
||||
SaveInlineAgentToRosterDialog: ({
|
||||
open,
|
||||
onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
onSaved: (binding: {
|
||||
agent_id?: string | null
|
||||
binding_type: 'inline_agent' | 'roster_agent'
|
||||
current_snapshot_id?: string | null
|
||||
id: string
|
||||
node_id: string
|
||||
workflow_id: string
|
||||
}) => void
|
||||
}) => open
|
||||
? (
|
||||
<div role="dialog" aria-label="save-inline-agent-to-roster">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSaved({
|
||||
id: 'binding-1',
|
||||
binding_type: 'roster_agent',
|
||||
agent_id: 'saved-roster-agent',
|
||||
current_snapshot_id: 'saved-snapshot',
|
||||
workflow_id: 'workflow-1',
|
||||
node_id: 'agent-node',
|
||||
})}
|
||||
>
|
||||
Save inline agent to roster
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('../../_base/hooks/use-available-var-list', () => ({
|
||||
default: () => ({
|
||||
availableVars: [{
|
||||
@@ -215,7 +269,32 @@ describe('agent/panel', () => {
|
||||
vi.clearAllMocks()
|
||||
mockPromptEditorProps.length = 0
|
||||
mockOrchestrateDrawerPanelProps.length = 0
|
||||
mockStoreState.appId = 'app-1'
|
||||
mockStoreState.openInlineAgentPanelNodeId = undefined
|
||||
mockCopyFromRosterState.isPending = false
|
||||
mockCopyFromRosterMutate.mockImplementation((_variables, options?: {
|
||||
onSuccess?: (composerState: {
|
||||
binding: {
|
||||
agent_id: string
|
||||
binding_type: 'inline_agent'
|
||||
current_snapshot_id: string
|
||||
id: string
|
||||
node_id: string
|
||||
workflow_id: string
|
||||
}
|
||||
}) => void
|
||||
}) => {
|
||||
options?.onSuccess?.({
|
||||
binding: {
|
||||
id: 'binding-1',
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-copy-agent',
|
||||
current_snapshot_id: 'inline-copy-snapshot',
|
||||
workflow_id: 'workflow-1',
|
||||
node_id: 'agent-node',
|
||||
},
|
||||
})
|
||||
})
|
||||
mockCreateInlineAgentBinding.mockImplementation(() => {})
|
||||
mockUseNodeCrud.mockImplementation((_id: string, data: AgentV2NodeType) => ({
|
||||
inputs: data,
|
||||
@@ -304,6 +383,52 @@ describe('agent/panel', () => {
|
||||
expect(screen.queryByRole('dialog', { name: 'Nadia' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies a roster agent from the drawer into an inline agent for this node', () => {
|
||||
render(
|
||||
<AgentV2Panel
|
||||
id="agent-node"
|
||||
data={createData()}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' }))
|
||||
|
||||
expect(mockCopyFromRosterMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
params: {
|
||||
app_id: 'app-1',
|
||||
node_id: 'agent-node',
|
||||
},
|
||||
body: {
|
||||
source_agent_id: 'agent-1',
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'agent-node',
|
||||
data: expect.objectContaining({
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-copy-agent',
|
||||
current_snapshot_id: 'inline-copy-snapshot',
|
||||
},
|
||||
_openInlineAgentPanel: true,
|
||||
}),
|
||||
},
|
||||
expect.objectContaining({
|
||||
sync: true,
|
||||
notRefreshWhenSyncError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a required roster state when no roster agent is selected', () => {
|
||||
render(
|
||||
<AgentV2Panel
|
||||
@@ -321,6 +446,7 @@ describe('agent/panel', () => {
|
||||
})
|
||||
|
||||
it('renders a pending inline agent state while the binding is being created', () => {
|
||||
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
|
||||
const { container } = render(
|
||||
<AgentV2Panel
|
||||
id="agent-node"
|
||||
@@ -337,6 +463,16 @@ describe('agent/panel', () => {
|
||||
expect(screen.queryByText(/^workflow\.errorMsg\.fieldRequired/)).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change' })).toBeDisabled()
|
||||
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.nodes.agent.roster.editInConsole')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('workflow.nodes.agent.roster.makeCopy')).not.toBeInTheDocument()
|
||||
expect(mockOrchestrateDrawerPanelProps.at(-1)).toMatchObject({
|
||||
agentId: undefined,
|
||||
isInline: true,
|
||||
nodeId: 'agent-node',
|
||||
open: true,
|
||||
})
|
||||
expect(screen.getByText('workflow.nodes.agent.task.label')).toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.outputVars.text')).toBeInTheDocument()
|
||||
expect(container.querySelector('[inert]')).toBeInTheDocument()
|
||||
@@ -366,11 +502,11 @@ describe('agent/panel', () => {
|
||||
expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.name')).toBeInTheDocument()
|
||||
expect(within(trigger).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
|
||||
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
|
||||
const panelRobotIcon = panel.querySelector('.i-custom-vender-agent-v2-robot-3')
|
||||
expect(container.querySelector('.i-custom-vender-agent-v2-robot-3')).toHaveClass('size-5')
|
||||
expect(container.querySelector('.i-custom-vender-agent-v2-robot-3')?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
|
||||
expect(panelRobotIcon).toHaveClass('size-5')
|
||||
expect(panelRobotIcon?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
|
||||
const panelConfigureIcon = panel.querySelector('.i-custom-vender-agent-v2-configure')
|
||||
expect(container.querySelector('.i-custom-vender-agent-v2-configure')).toHaveClass('h-3.5', 'w-3')
|
||||
expect(container.querySelector('.i-custom-vender-agent-v2-configure')?.parentElement).toHaveClass('size-8', 'rounded-full', 'bg-background-default-burn')
|
||||
expect(panelConfigureIcon).toHaveClass('h-3.5', 'w-3')
|
||||
expect(panelConfigureIcon?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
|
||||
expect(screen.queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('workflow.nodes.agent.roster.inlineSetup.description')).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
|
||||
@@ -421,7 +557,7 @@ describe('agent/panel', () => {
|
||||
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
|
||||
expect(panel).toBeInTheDocument()
|
||||
expect(panel.querySelector('header')).not.toHaveClass('h-[108px]')
|
||||
expect(panel.querySelector('.i-custom-vender-agent-v2-robot-3')?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
|
||||
expect(panel.querySelector('.i-custom-vender-agent-v2-configure')?.parentElement).toHaveClass('size-9', 'rounded-full', 'bg-background-default-burn')
|
||||
expect(within(panel).queryByText('Workflow Agent 1')).not.toBeInTheDocument()
|
||||
expect(within(panel).getByText('workflow.nodes.agent.roster.inlineSetup.type')).toBeInTheDocument()
|
||||
expect(within(panel).queryByText('workflow.nodes.agent.roster.inlineSetup.title')).not.toBeInTheDocument()
|
||||
@@ -431,6 +567,45 @@ describe('agent/panel', () => {
|
||||
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens save-to-roster action from the inline drawer menu and rebinds to the saved roster agent', () => {
|
||||
mockStoreState.openInlineAgentPanelNodeId = 'agent-node'
|
||||
render(
|
||||
<AgentV2Panel
|
||||
id="agent-node"
|
||||
data={createData({
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-1',
|
||||
current_snapshot_id: 'snapshot-1',
|
||||
},
|
||||
})}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
|
||||
fireEvent.click(within(panel).getByRole('button', { name: 'workflow.nodes.agent.roster.more' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'agentV2.roster.saveToRoster' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save inline agent to roster' }))
|
||||
|
||||
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith(undefined)
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'agent-node',
|
||||
data: expect.objectContaining({
|
||||
agent_binding: {
|
||||
binding_type: 'roster_agent',
|
||||
agent_id: 'saved-roster-agent',
|
||||
},
|
||||
}),
|
||||
},
|
||||
expect.objectContaining({
|
||||
sync: true,
|
||||
notRefreshWhenSyncError: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not show start from scratch for an existing inline agent binding', () => {
|
||||
render(
|
||||
<AgentV2Panel
|
||||
@@ -471,7 +646,9 @@ describe('agent/panel', () => {
|
||||
|
||||
expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1')
|
||||
expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })).toBeInTheDocument()
|
||||
const panel = screen.getByRole('dialog', { name: 'workflow.nodes.agent.roster.inlineSetup.name' })
|
||||
expect(panel).toBeInTheDocument()
|
||||
expect(within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument()
|
||||
expect(mockOrchestrateDrawerPanelProps.at(-1)).toMatchObject({
|
||||
agentId: 'inline-agent-1',
|
||||
@@ -570,10 +747,30 @@ describe('agent/panel', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.change' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start from Scratch' }))
|
||||
|
||||
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
|
||||
expect(mockCreateInlineAgentBinding).toHaveBeenCalledWith('agent-node', expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
expect(mockStoreState.setOpenInlineAgentPanelNodeId).toHaveBeenCalledWith('agent-node')
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'agent-node',
|
||||
data: expect.objectContaining({
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
},
|
||||
agent_task: 'Keep this task',
|
||||
agent_declared_outputs: [{
|
||||
name: 'summary',
|
||||
type: 'string',
|
||||
}],
|
||||
_openInlineAgentPanel: true,
|
||||
}),
|
||||
},
|
||||
expect.objectContaining({
|
||||
sync: true,
|
||||
notRefreshWhenSyncError: true,
|
||||
}),
|
||||
)
|
||||
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
|
||||
{
|
||||
id: 'agent-node',
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import type { AgentComposerAgentResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog'
|
||||
|
||||
const mutationMock = vi.hoisted(() => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
}))
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: () => ({
|
||||
isPending: mutationMock.isPending,
|
||||
mutate: mutationMock.mutate,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
initialEmoji,
|
||||
onSelect,
|
||||
open,
|
||||
}: {
|
||||
initialEmoji?: { icon: string, background: string }
|
||||
onSelect: (payload: { type: 'emoji', icon: string, background: string }) => void
|
||||
open: boolean
|
||||
}) => open
|
||||
? (
|
||||
<div>
|
||||
<span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })}
|
||||
>
|
||||
Select brain icon
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
byAppId: {
|
||||
workflows: {
|
||||
draft: {
|
||||
nodes: {
|
||||
byNodeId: {
|
||||
agentComposer: {
|
||||
saveToRoster: {
|
||||
post: {
|
||||
mutationOptions: vi.fn(() => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const inlineAgent: AgentComposerAgentResponse = {
|
||||
active_config_snapshot_id: 'snapshot-1',
|
||||
description: 'Drafts tender clarifications.',
|
||||
icon: '🤖',
|
||||
icon_background: '#F5F3FF',
|
||||
icon_type: 'emoji',
|
||||
id: 'inline-agent-1',
|
||||
name: 'Inline Tender Agent',
|
||||
role: 'Tender Analyst',
|
||||
scope: 'workflow_only',
|
||||
status: 'active',
|
||||
}
|
||||
|
||||
const renderDialog = () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onSaved = vi.fn()
|
||||
|
||||
render(
|
||||
<SaveInlineAgentToRosterDialog
|
||||
appId="app-1"
|
||||
formKey={1}
|
||||
initialAgent={inlineAgent}
|
||||
nodeId="node-1"
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
)
|
||||
|
||||
return { onOpenChange, onSaved }
|
||||
}
|
||||
|
||||
describe('SaveInlineAgentToRosterDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mutationMock.isPending = false
|
||||
})
|
||||
|
||||
it('initializes form fields from the inline agent metadata', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
|
||||
expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' })).toHaveValue('Inline Tender Agent')
|
||||
expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })).toHaveValue('Tender Analyst')
|
||||
expect(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder')).toHaveValue('Drafts tender clarifications.')
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mutationMock.mutate).toHaveBeenCalledWith({
|
||||
params: {
|
||||
app_id: 'app-1',
|
||||
node_id: 'node-1',
|
||||
},
|
||||
body: {
|
||||
variant: 'workflow',
|
||||
save_strategy: 'save_to_roster',
|
||||
new_agent_name: 'Inline Tender Agent',
|
||||
description: 'Drafts tender clarifications.',
|
||||
role: 'Tender Analyst',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1]
|
||||
expect(mutationOptions).not.toHaveProperty('onError')
|
||||
})
|
||||
|
||||
it('initializes the icon picker from the inline agent and submits changed icon fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
|
||||
await user.click(within(dialog).getByRole('button', { name: 'agentV2.roster.saveToRosterForm.changeIcon' }))
|
||||
|
||||
expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' }))
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mutationMock.mutate).toHaveBeenCalledWith({
|
||||
params: {
|
||||
app_id: 'app-1',
|
||||
node_id: 'node-1',
|
||||
},
|
||||
body: {
|
||||
variant: 'workflow',
|
||||
save_strategy: 'save_to_roster',
|
||||
new_agent_name: 'Inline Tender Agent',
|
||||
description: 'Drafts tender clarifications.',
|
||||
role: 'Tender Analyst',
|
||||
icon_type: 'emoji',
|
||||
icon: '🧠',
|
||||
icon_background: '#E0F2FE',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
})
|
||||
})
|
||||
+4
-4
@@ -15,7 +15,7 @@ import { consoleQuery } from '@/service/client'
|
||||
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
|
||||
|
||||
type AgentOrchestrateDrawerPanelProps = {
|
||||
agentId: string
|
||||
agentId?: string
|
||||
appId?: string
|
||||
inlineComposerState?: WorkflowAgentComposerResponse
|
||||
isInline: boolean
|
||||
@@ -40,7 +40,7 @@ function AgentOrchestrateDrawerPanelContent({
|
||||
open,
|
||||
}: AgentOrchestrateDrawerPanelProps) {
|
||||
const rosterComposerQuery = useQuery(consoleQuery.agent.byAgentId.composer.get.queryOptions({
|
||||
input: open && !isInline
|
||||
input: open && !isInline && agentId
|
||||
? {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
@@ -62,12 +62,12 @@ function AgentOrchestrateDrawerPanelContent({
|
||||
})
|
||||
|
||||
useHydrateAgentSoulConfigDraft({
|
||||
agentId: isInline ? `${nodeId}:${agentId}` : agentId,
|
||||
agentId: isInline ? `${nodeId}:${agentId ?? 'pending'}` : agentId ?? nodeId,
|
||||
activeVersionId: activeConfigSnapshot?.id,
|
||||
config: agentSoulConfig as AgentSoulConfig | undefined,
|
||||
})
|
||||
|
||||
if (!agentSoulConfig) {
|
||||
if (!agentId || !agentSoulConfig) {
|
||||
return (
|
||||
<div className="flex h-full min-h-80 items-center justify-center bg-components-panel-bg">
|
||||
<Loading type="app" />
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentOutputVariables } from '../index'
|
||||
|
||||
const editorLabel = 'workflow.nodes.agent.outputVars.editorLabel'
|
||||
const nameLabel = 'workflow.nodes.agent.outputVars.nameLabel'
|
||||
const confirmLabel = 'workflow.nodes.agent.outputVars.confirm'
|
||||
|
||||
async function expandOutputVars(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.outputVars' }))
|
||||
}
|
||||
|
||||
function getAddButton(name: string) {
|
||||
return screen.getByRole('button', { name: `common.operation.add ${name}` })
|
||||
}
|
||||
|
||||
function getEditButton(name: string) {
|
||||
return screen.getByRole('button', {
|
||||
name: `workflow.nodes.agent.outputVars.edit:{"name":"${name}"}`,
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmEditorName(user: ReturnType<typeof userEvent.setup>, name: string) {
|
||||
const editor = screen.getByRole('form', { name: editorLabel })
|
||||
const nameInput = within(editor).getByLabelText(nameLabel)
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, name)
|
||||
await user.click(within(editor).getByRole('button', { name: confirmLabel }))
|
||||
}
|
||||
|
||||
describe('AgentOutputVariables', () => {
|
||||
it('should add an object child without opening the parent editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const outputs: DeclaredOutputConfig[] = [{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
description: 'User profile',
|
||||
}]
|
||||
|
||||
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
await expandOutputVars(user)
|
||||
await user.click(getAddButton('profile'))
|
||||
|
||||
expect(screen.getAllByRole('form', { name: editorLabel })).toHaveLength(1)
|
||||
expect(screen.getByText('profile')).toBeInTheDocument()
|
||||
|
||||
await confirmEditorName(user, 'email')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
description: 'User profile',
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('should append children to array object items', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const outputs: DeclaredOutputConfig[] = [{
|
||||
name: 'addresses',
|
||||
type: 'array',
|
||||
required: false,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
},
|
||||
}]
|
||||
|
||||
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
await expandOutputVars(user)
|
||||
await user.click(getAddButton('addresses'))
|
||||
await confirmEditorName(user, 'city')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{
|
||||
name: 'addresses',
|
||||
type: 'array',
|
||||
required: false,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
children: [{
|
||||
name: 'city',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('should add nested children under the selected object child', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const outputs: DeclaredOutputConfig[] = [{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'contact',
|
||||
type: 'object',
|
||||
required: true,
|
||||
}],
|
||||
}]
|
||||
|
||||
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
await expandOutputVars(user)
|
||||
await user.click(getAddButton('contact'))
|
||||
await confirmEditorName(user, 'email')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'contact',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('should edit only the selected nested child', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const outputs: DeclaredOutputConfig[] = [{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'contact',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Primary email',
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
|
||||
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
await expandOutputVars(user)
|
||||
await user.click(getEditButton('email'))
|
||||
|
||||
expect(screen.getAllByRole('form', { name: editorLabel })).toHaveLength(1)
|
||||
|
||||
await confirmEditorName(user, 'work_email')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'contact',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'work_email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Primary email',
|
||||
}],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
})
|
||||
+46
@@ -6,6 +6,7 @@ import {
|
||||
} from '../utils'
|
||||
|
||||
const createDraft = (overrides: Partial<OutputDraft> = {}): OutputDraft => ({
|
||||
children: [],
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
name: 'summary',
|
||||
@@ -54,4 +55,49 @@ describe('agent output variables utils', () => {
|
||||
type: 'array[file]',
|
||||
}))).toBe('nodes.agent.outputVars.defaultValueFileUnsupported')
|
||||
})
|
||||
|
||||
it('should preserve object children when building an output', () => {
|
||||
expect(createOutputFromDraft(createDraft({
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'User email',
|
||||
}],
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
}))).toMatchObject({
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'User email',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve array object item children when building an output', () => {
|
||||
expect(createOutputFromDraft(createDraft({
|
||||
children: [{
|
||||
name: 'city',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
name: 'addresses',
|
||||
type: 'array[object]',
|
||||
}))).toMatchObject({
|
||||
name: 'addresses',
|
||||
type: 'array',
|
||||
array_item: {
|
||||
type: 'object',
|
||||
children: [{
|
||||
name: 'city',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+41
-35
@@ -1,5 +1,5 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { EditingState, OutputDraft } from './utils'
|
||||
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { CollapsiblePanel, CollapsibleRoot, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
@@ -38,21 +38,25 @@ function ConfirmHotkeyHint() {
|
||||
|
||||
export function OutputEditCard({
|
||||
existingOutputs,
|
||||
editingIndex,
|
||||
allowDefaultValue = true,
|
||||
state,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
existingOutputs: DeclaredOutputConfig[]
|
||||
existingOutputs: EditableOutputConfig[]
|
||||
editingIndex?: number
|
||||
allowDefaultValue?: boolean
|
||||
state: EditingState
|
||||
onCancel: () => void
|
||||
onConfirm: (output: DeclaredOutputConfig, index?: number) => void
|
||||
onConfirm: (output: DeclaredOutputConfig, state: EditingState) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const nameErrorId = useId()
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const [draft, setDraft] = useState(state.draft)
|
||||
const trimmedName = draft.name.trim()
|
||||
const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== state.index)
|
||||
const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== editingIndex)
|
||||
const nameInvalid = !!trimmedName && !OUTPUT_NAME_PATTERN.test(trimmedName)
|
||||
const hasNameError = duplicateName || nameInvalid
|
||||
const defaultValueErrorKey = getDefaultValueErrorKey(draft)
|
||||
@@ -63,7 +67,7 @@ export function OutputEditCard({
|
||||
function handleConfirm() {
|
||||
if (confirmDisabled)
|
||||
return
|
||||
onConfirm(createOutputFromDraft(draft), state.index)
|
||||
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
|
||||
}
|
||||
useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false })
|
||||
useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false })
|
||||
@@ -134,36 +138,38 @@ export function OutputEditCard({
|
||||
/>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
<CollapsibleRoot>
|
||||
<CollapsibleTrigger className="h-8 min-h-8 justify-start gap-x-0.5 rounded-none border-y border-divider-subtle pr-2 pl-2.5 system-xs-regular text-text-tertiary hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-tertiary focus-visible:bg-state-base-hover focus-visible:ring-inset data-panel-open:text-text-tertiary">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
{t('nodes.agent.outputVars.showAdvancedOptions', { ns: 'workflow' })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className="border-t border-divider-subtle">
|
||||
<div className="px-3 py-2">
|
||||
<FieldRoot name="defaultValue" className="gap-1">
|
||||
<FieldLabel className="py-0 system-xs-medium text-text-secondary">
|
||||
{t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={draft.defaultValue}
|
||||
placeholder={t('nodes.agent.outputVars.defaultValuePlaceholder', { ns: 'workflow' })}
|
||||
className="mt-1 min-h-6"
|
||||
onValueChange={defaultValue => updateDraft({ defaultValue })}
|
||||
/>
|
||||
{defaultValueErrorKey && (
|
||||
<FieldError match className="py-0 system-xs-regular text-text-destructive">
|
||||
{t(defaultValueErrorKey, { ns: 'workflow' })}
|
||||
</FieldError>
|
||||
)}
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</CollapsibleRoot>
|
||||
{allowDefaultValue && (
|
||||
<CollapsibleRoot>
|
||||
<CollapsibleTrigger className="h-8 min-h-8 justify-start gap-x-0.5 rounded-none border-y border-divider-subtle pr-2 pl-2.5 system-xs-regular text-text-tertiary hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-tertiary focus-visible:bg-state-base-hover focus-visible:ring-inset data-panel-open:text-text-tertiary">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
{t('nodes.agent.outputVars.showAdvancedOptions', { ns: 'workflow' })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className="border-t border-divider-subtle">
|
||||
<div className="px-3 py-2">
|
||||
<FieldRoot name="defaultValue" className="gap-1">
|
||||
<FieldLabel className="py-0 system-xs-medium text-text-secondary">
|
||||
{t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={draft.defaultValue}
|
||||
placeholder={t('nodes.agent.outputVars.defaultValuePlaceholder', { ns: 'workflow' })}
|
||||
className="mt-1 min-h-6"
|
||||
onValueChange={defaultValue => updateDraft({ defaultValue })}
|
||||
/>
|
||||
{defaultValueErrorKey && (
|
||||
<FieldError match className="py-0 system-xs-regular text-text-destructive">
|
||||
{t(defaultValueErrorKey, { ns: 'workflow' })}
|
||||
</FieldError>
|
||||
)}
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</CollapsibleRoot>
|
||||
)}
|
||||
<div className="flex h-12 items-center justify-end gap-x-2 px-3">
|
||||
<Button type="button" size="small" variant="secondary" onClick={onCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
|
||||
+172
-27
@@ -1,5 +1,6 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentOutputVariablesProps, EditingState } from './utils'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentOutputVariablesProps, DeclaredOutputChildConfig, EditableOutputConfig, EditingState } from './utils'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -7,21 +8,30 @@ import Divider from '@/app/components/base/divider'
|
||||
import OutputVars from '../../../_base/components/output-vars'
|
||||
import { OutputEditCard } from './edit-card'
|
||||
import {
|
||||
canOutputHaveChildren,
|
||||
createDraft,
|
||||
deleteOutputChildAtPath,
|
||||
getOutputChildren,
|
||||
getOutputChildrenAtPath,
|
||||
getOutputDescription,
|
||||
getOutputDisplayType,
|
||||
getOutputTypeOptionValue,
|
||||
insertOutputChildAtPath,
|
||||
isDefaultOutput,
|
||||
toDeclaredOutputChild,
|
||||
updateOutputChildAtPath,
|
||||
} from './utils'
|
||||
|
||||
function OutputRow({
|
||||
output,
|
||||
editable,
|
||||
onAddChild,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
output: DeclaredOutputConfig
|
||||
output: EditableOutputConfig
|
||||
editable: boolean
|
||||
onAddChild?: () => void
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
}) {
|
||||
@@ -43,6 +53,16 @@ function OutputRow({
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="pointer-events-none flex shrink-0 items-center gap-x-0.5 opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{onAddChild && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('operation.add', { ns: 'common' })} ${output.name}`}
|
||||
className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={onAddChild}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-add-circle-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nodes.agent.outputVars.edit', { ns: 'workflow', name: output.name })}
|
||||
@@ -70,6 +90,22 @@ function OutputRow({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChildOutputFrame({ children, depth }: { children: ReactNode, depth: number }) {
|
||||
return (
|
||||
<div className="flex items-stretch">
|
||||
{Array.from({ length: depth }, (_, index) => (
|
||||
<div key={index} aria-hidden="true" className="flex w-5 shrink-0 justify-center">
|
||||
<div className="w-px bg-divider-subtle" />
|
||||
</div>
|
||||
))}
|
||||
<div className="min-w-0 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentOutputVariables({
|
||||
outputs,
|
||||
onChange,
|
||||
@@ -80,49 +116,151 @@ export function AgentOutputVariables({
|
||||
setEditingState({ draft: createDraft() })
|
||||
}
|
||||
function handleEditOutput(index: number) {
|
||||
setEditingState({ index, draft: createDraft(outputs[index]) })
|
||||
setEditingState({ outputIndex: index, draft: createDraft(outputs[index]) })
|
||||
}
|
||||
function handleNewChild(outputIndex: number, parentPath: number[]) {
|
||||
setEditingState({ outputIndex, parentPath, draft: createDraft() })
|
||||
}
|
||||
function handleEditChild(outputIndex: number, childPath: number[], child: DeclaredOutputChildConfig) {
|
||||
setEditingState({ outputIndex, childPath, draft: createDraft(child) })
|
||||
}
|
||||
function handleDeleteOutput(index: number) {
|
||||
onChange(outputs.filter((_, outputIndex) => outputIndex !== index))
|
||||
}
|
||||
function handleConfirm(output: DeclaredOutputConfig, index?: number) {
|
||||
if (typeof index === 'number') {
|
||||
onChange(outputs.map((item, outputIndex) => outputIndex === index ? output : item))
|
||||
function handleDeleteChild(outputIndex: number, childPath: number[]) {
|
||||
const parent = outputs[outputIndex]
|
||||
if (!parent)
|
||||
return
|
||||
|
||||
onChange(outputs.map((item, itemIndex) => (
|
||||
itemIndex === outputIndex ? deleteOutputChildAtPath(item, childPath) : item
|
||||
)))
|
||||
}
|
||||
function handleConfirm(output: DeclaredOutputConfig, state: EditingState) {
|
||||
if (typeof state.outputIndex === 'number' && state.parentPath) {
|
||||
const parent = outputs[state.outputIndex]
|
||||
if (!parent)
|
||||
return
|
||||
|
||||
const childOutput = toDeclaredOutputChild(output)
|
||||
onChange(outputs.map((item, outputIndex) => (
|
||||
outputIndex === state.outputIndex ? insertOutputChildAtPath(item, state.parentPath!, childOutput) : item
|
||||
)))
|
||||
}
|
||||
else if (typeof state.outputIndex === 'number' && state.childPath) {
|
||||
const childOutput = toDeclaredOutputChild(output)
|
||||
onChange(outputs.map((item, outputIndex) => (
|
||||
outputIndex === state.outputIndex ? updateOutputChildAtPath(item, state.childPath!, childOutput) : item
|
||||
)))
|
||||
}
|
||||
else if (typeof state.outputIndex === 'number') {
|
||||
onChange(outputs.map((item, outputIndex) => outputIndex === state.outputIndex ? output : item))
|
||||
}
|
||||
else {
|
||||
onChange([...outputs, output])
|
||||
}
|
||||
setEditingState(null)
|
||||
}
|
||||
return (
|
||||
<OutputVars>
|
||||
<div className="pb-2">
|
||||
<div className="flex flex-col">
|
||||
{outputs.map((output, index) => (
|
||||
editingState?.index === index
|
||||
? (
|
||||
function renderChildren(output: DeclaredOutputConfig, outputIndex: number, depth: number, children: DeclaredOutputChildConfig[], editable: boolean, parentPath: number[] = []) {
|
||||
return (
|
||||
<>
|
||||
{children.map((child, childIndex) => {
|
||||
const childPath = [...parentPath, childIndex]
|
||||
const nestedChildren = getOutputChildren(child)
|
||||
const isEditingChild = editingState?.outputIndex === outputIndex && pathsEqual(editingState.childPath, childPath)
|
||||
return (
|
||||
<div key={`${childPath.join('.')}-${child.name}-${getOutputTypeOptionValue(child)}`} className="flex flex-col">
|
||||
{isEditingChild
|
||||
? (
|
||||
<ChildOutputFrame depth={depth}>
|
||||
<OutputEditCard
|
||||
allowDefaultValue={false}
|
||||
editingIndex={childIndex}
|
||||
existingOutputs={getOutputChildrenAtPath(output, parentPath)}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)
|
||||
: (
|
||||
<ChildOutputFrame depth={depth}>
|
||||
<OutputRow
|
||||
output={child}
|
||||
editable={editable}
|
||||
onAddChild={editable && canOutputHaveChildren(child) ? () => handleNewChild(outputIndex, childPath) : undefined}
|
||||
onDelete={() => handleDeleteChild(outputIndex, childPath)}
|
||||
onEdit={() => handleEditChild(outputIndex, childPath, child)}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
{renderChildren(output, outputIndex, depth + 1, nestedChildren, editable, childPath)}
|
||||
{editingState?.outputIndex === outputIndex && pathsEqual(editingState.parentPath, childPath) && (
|
||||
<ChildOutputFrame depth={depth + 1}>
|
||||
<OutputEditCard
|
||||
key={`${output.name}-editing`}
|
||||
existingOutputs={outputs}
|
||||
allowDefaultValue={false}
|
||||
existingOutputs={nestedChildren}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<OutputRow
|
||||
key={`${output.name}-${getOutputTypeOptionValue(output)}`}
|
||||
output={output}
|
||||
editable={!isDefaultOutput(output)}
|
||||
onDelete={() => handleDeleteOutput(index)}
|
||||
onEdit={() => handleEditOutput(index)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<OutputVars>
|
||||
<div className="pb-2">
|
||||
<div className="flex flex-col">
|
||||
{outputs.map((output, index) => {
|
||||
const editable = !isDefaultOutput(output)
|
||||
const children = getOutputChildren(output)
|
||||
const isEditingOutput = editingState?.outputIndex === index && !editingState.childPath && !editingState.parentPath
|
||||
return (
|
||||
<div key={`${output.name}-${getOutputTypeOptionValue(output)}`} className="flex flex-col">
|
||||
{isEditingOutput
|
||||
? (
|
||||
<OutputEditCard
|
||||
key={`${output.name}-editing`}
|
||||
editingIndex={index}
|
||||
existingOutputs={outputs}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<OutputRow
|
||||
output={output}
|
||||
editable={editable}
|
||||
onAddChild={editable && canOutputHaveChildren(output) ? () => handleNewChild(index, []) : undefined}
|
||||
onDelete={() => handleDeleteOutput(index)}
|
||||
onEdit={() => handleEditOutput(index)}
|
||||
/>
|
||||
)}
|
||||
{renderChildren(output, index, 1, children, editable)}
|
||||
{editingState?.outputIndex === index && editingState.parentPath && !editingState.parentPath.length && (
|
||||
<ChildOutputFrame depth={1}>
|
||||
<OutputEditCard
|
||||
allowDefaultValue={false}
|
||||
existingOutputs={children}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="py-1">
|
||||
<Divider type="horizontal" className="h-px bg-divider-subtle" />
|
||||
</div>
|
||||
{editingState && editingState.index == null
|
||||
{editingState && editingState.outputIndex == null
|
||||
? (
|
||||
<OutputEditCard
|
||||
existingOutputs={outputs}
|
||||
@@ -149,3 +287,10 @@ export function AgentOutputVariables({
|
||||
</OutputVars>
|
||||
)
|
||||
}
|
||||
|
||||
function pathsEqual(left?: number[], right?: number[]) {
|
||||
if (!left || !right || left.length !== right.length)
|
||||
return false
|
||||
|
||||
return left.every((item, index) => item === right[index])
|
||||
}
|
||||
|
||||
+209
-8
@@ -2,6 +2,10 @@ import type { DeclaredOutputConfig, DeclaredOutputType } from '@dify/contracts/a
|
||||
import type { TFunction } from 'i18next'
|
||||
import { defaultAgentV2DeclaredOutputs } from '../../output-variables'
|
||||
|
||||
export type DeclaredOutputChildConfig = NonNullable<DeclaredOutputConfig['children']>[number]
|
||||
|
||||
export type EditableOutputConfig = DeclaredOutputConfig | DeclaredOutputChildConfig
|
||||
|
||||
export type OutputTypeOptionValue
|
||||
= DeclaredOutputType
|
||||
| 'array[boolean]'
|
||||
@@ -23,11 +27,14 @@ export type OutputDraft = {
|
||||
name: string
|
||||
required: boolean
|
||||
type: OutputTypeOptionValue
|
||||
children: DeclaredOutputChildConfig[]
|
||||
}
|
||||
|
||||
export type EditingState = {
|
||||
draft: OutputDraft
|
||||
index?: number
|
||||
outputIndex?: number
|
||||
childPath?: number[]
|
||||
parentPath?: number[]
|
||||
}
|
||||
|
||||
export type AgentOutputVariablesProps = {
|
||||
@@ -51,7 +58,7 @@ export const OUTPUT_TYPE_OPTIONS: OutputTypeOption[] = [
|
||||
{ value: 'array[file]', label: 'array[file]', type: 'array', arrayItemType: 'file' },
|
||||
]
|
||||
|
||||
export function getOutputTypeOptionValue(output: DeclaredOutputConfig): OutputTypeOptionValue {
|
||||
export function getOutputTypeOptionValue(output: EditableOutputConfig): OutputTypeOptionValue {
|
||||
if (output.type !== 'array')
|
||||
return output.type
|
||||
|
||||
@@ -62,7 +69,7 @@ export function getOutputTypeOption(value: OutputTypeOptionValue) {
|
||||
return OUTPUT_TYPE_OPTIONS.find(option => option.value === value) || OUTPUT_TYPE_OPTIONS[0]!
|
||||
}
|
||||
|
||||
export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
export function createDraft(output?: EditableOutputConfig): OutputDraft {
|
||||
if (!output) {
|
||||
return {
|
||||
defaultValue: '',
|
||||
@@ -70,6 +77,7 @@ export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
name: '',
|
||||
required: false,
|
||||
type: 'string',
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +87,14 @@ export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
name: output.name,
|
||||
required: output.required ?? true,
|
||||
type: getOutputTypeOptionValue(output),
|
||||
children: getOutputChildren(output),
|
||||
}
|
||||
}
|
||||
|
||||
export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig {
|
||||
export function createOutputFromDraft(
|
||||
draft: OutputDraft,
|
||||
{ includeDefaultValue = true }: { includeDefaultValue?: boolean } = {},
|
||||
): DeclaredOutputConfig {
|
||||
const option = getOutputTypeOption(draft.type)
|
||||
const output: DeclaredOutputConfig = {
|
||||
name: draft.name.trim(),
|
||||
@@ -99,6 +111,16 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.children.length && draft.type === 'object')
|
||||
output.children = draft.children
|
||||
|
||||
if (draft.children.length && draft.type === 'array[object]') {
|
||||
output.array_item = {
|
||||
type: 'object',
|
||||
children: draft.children,
|
||||
}
|
||||
}
|
||||
|
||||
if (option.type === 'file') {
|
||||
output.file = {
|
||||
extensions: [],
|
||||
@@ -106,7 +128,7 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.defaultValue.trim()) {
|
||||
if (includeDefaultValue && draft.defaultValue.trim()) {
|
||||
output.failure_strategy = {
|
||||
on_failure: 'default_value',
|
||||
default_value: coerceDefaultValue(draft.defaultValue, option),
|
||||
@@ -116,6 +138,113 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
return output
|
||||
}
|
||||
|
||||
export function toDeclaredOutputChild(output: DeclaredOutputConfig): DeclaredOutputChildConfig {
|
||||
return {
|
||||
name: output.name,
|
||||
type: output.type,
|
||||
required: output.required,
|
||||
...(output.description ? { description: output.description } : {}),
|
||||
...(output.file ? { file: output.file } : {}),
|
||||
...(output.children ? { children: output.children } : {}),
|
||||
...(output.array_item ? { array_item: output.array_item } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function getOutputChildren(output: EditableOutputConfig): DeclaredOutputChildConfig[] {
|
||||
if (getOutputTypeOptionValue(output) === 'array[object]')
|
||||
return readOutputChildren(output.array_item?.children)
|
||||
|
||||
if (output.type === 'object')
|
||||
return readOutputChildren(output.children)
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function canOutputHaveChildren(output: EditableOutputConfig) {
|
||||
const type = getOutputTypeOptionValue(output)
|
||||
return type === 'object' || type === 'array[object]'
|
||||
}
|
||||
|
||||
export function updateOutputChildren(
|
||||
output: DeclaredOutputConfig,
|
||||
children: DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputConfig {
|
||||
if (getOutputTypeOptionValue(output) === 'array[object]') {
|
||||
return {
|
||||
...output,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
...output.array_item,
|
||||
children: children.length ? children : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (output.type === 'object') {
|
||||
return {
|
||||
...output,
|
||||
children: children.length ? children : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function getOutputChildrenAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
path: number[],
|
||||
): DeclaredOutputChildConfig[] {
|
||||
const target = getOutputChildAtPath(output, path)
|
||||
return target ? getOutputChildren(target) : getOutputChildren(output)
|
||||
}
|
||||
|
||||
export function getOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
path: number[],
|
||||
): DeclaredOutputChildConfig | undefined {
|
||||
let current: DeclaredOutputChildConfig | undefined
|
||||
let children = getOutputChildren(output)
|
||||
for (const index of path) {
|
||||
current = children[index]
|
||||
if (!current)
|
||||
return undefined
|
||||
children = getOutputChildren(current)
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
export function insertOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
parentPath: number[],
|
||||
child: DeclaredOutputChildConfig,
|
||||
) {
|
||||
return updateOutputChildrenAtPath(output, parentPath, children => [...children, child])
|
||||
}
|
||||
|
||||
export function updateOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
childPath: number[],
|
||||
child: DeclaredOutputChildConfig,
|
||||
) {
|
||||
const childIndex = childPath.at(-1)
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.map((item, index) => index === childIndex ? child : item))
|
||||
}
|
||||
|
||||
export function deleteOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
childPath: number[],
|
||||
) {
|
||||
const childIndex = childPath.at(-1)
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.filter((_, index) => index !== childIndex))
|
||||
}
|
||||
|
||||
export function getDefaultValueErrorKey(draft: OutputDraft) {
|
||||
const trimmed = draft.defaultValue.trim()
|
||||
if (!trimmed)
|
||||
@@ -157,7 +286,7 @@ export function isDefaultOutput(output: DeclaredOutputConfig) {
|
||||
)
|
||||
}
|
||||
|
||||
export function getOutputDescription(output: DeclaredOutputConfig, t: TFunction) {
|
||||
export function getOutputDescription(output: EditableOutputConfig, t: TFunction) {
|
||||
if (output.name === 'text')
|
||||
return t('nodes.agent.outputVars.text', { ns: 'workflow' })
|
||||
if (output.name === 'files')
|
||||
@@ -167,11 +296,83 @@ export function getOutputDescription(output: DeclaredOutputConfig, t: TFunction)
|
||||
return output.description || ''
|
||||
}
|
||||
|
||||
export function getOutputDisplayType(output: DeclaredOutputConfig) {
|
||||
export function getOutputDisplayType(output: EditableOutputConfig) {
|
||||
return getOutputTypeOption(getOutputTypeOptionValue(output)).label
|
||||
}
|
||||
|
||||
function getOutputDefaultValue(output: DeclaredOutputConfig) {
|
||||
function readOutputChildren(children: EditableOutputConfig['children']) {
|
||||
return (children ?? []) as DeclaredOutputChildConfig[]
|
||||
}
|
||||
|
||||
function updateOutputChildrenAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
parentPath: number[],
|
||||
updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputConfig {
|
||||
if (!parentPath.length)
|
||||
return updateOutputChildren(output, updater(getOutputChildren(output)))
|
||||
|
||||
const [childIndex, ...restPath] = parentPath
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
const children = getOutputChildren(output)
|
||||
const nextChildren = children.map((child, index) => (
|
||||
index === childIndex ? updateChildChildrenAtPath(child, restPath, updater) : child
|
||||
))
|
||||
|
||||
return updateOutputChildren(output, nextChildren)
|
||||
}
|
||||
|
||||
function updateChildChildrenAtPath(
|
||||
child: DeclaredOutputChildConfig,
|
||||
parentPath: number[],
|
||||
updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputChildConfig {
|
||||
if (!parentPath.length)
|
||||
return updateChildChildren(child, updater(getOutputChildren(child)))
|
||||
|
||||
const [childIndex, ...restPath] = parentPath
|
||||
if (childIndex == null)
|
||||
return child
|
||||
|
||||
const children = getOutputChildren(child)
|
||||
const nextChildren = children.map((nestedChild, index) => (
|
||||
index === childIndex ? updateChildChildrenAtPath(nestedChild, restPath, updater) : nestedChild
|
||||
))
|
||||
|
||||
return updateChildChildren(child, nextChildren)
|
||||
}
|
||||
|
||||
function updateChildChildren(
|
||||
child: DeclaredOutputChildConfig,
|
||||
children: DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputChildConfig {
|
||||
if (getOutputTypeOptionValue(child) === 'array[object]') {
|
||||
return {
|
||||
...child,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
...child.array_item,
|
||||
children: children.length ? children : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (child.type === 'object') {
|
||||
return {
|
||||
...child,
|
||||
children: children.length ? children : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
function getOutputDefaultValue(output: EditableOutputConfig) {
|
||||
if (!('failure_strategy' in output))
|
||||
return ''
|
||||
|
||||
const defaultValue = output.failure_strategy?.default_value
|
||||
if (defaultValue == null)
|
||||
return ''
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import {
|
||||
Popover,
|
||||
@@ -76,7 +82,7 @@ function InlineSetupAvatar({
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('flex size-8 shrink-0 items-center justify-center rounded-full bg-background-default-burn', className)}>
|
||||
<span aria-hidden className="i-custom-vender-agent-v2-robot-3 size-5 text-text-tertiary" />
|
||||
<span aria-hidden className="i-custom-vender-agent-v2-configure h-3.5 w-3 text-text-tertiary" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -91,23 +97,30 @@ function AgentRosterDrawer({
|
||||
showAccessIcon = true,
|
||||
showConsoleLink = true,
|
||||
showDetailActions = true,
|
||||
isCopyPending = false,
|
||||
onMakeCopy,
|
||||
onSaveInlineToRoster,
|
||||
onClose,
|
||||
}: {
|
||||
agent: AgentRosterDisplayData
|
||||
children?: ReactNode
|
||||
isInlineSetup?: boolean
|
||||
isCopyPending?: boolean
|
||||
mode?: AgentRosterDrawerMode
|
||||
open: boolean
|
||||
portalContainerRef: RefObject<HTMLDivElement | null>
|
||||
showAccessIcon?: boolean
|
||||
showConsoleLink?: boolean
|
||||
showDetailActions?: boolean
|
||||
onMakeCopy?: () => void
|
||||
onSaveInlineToRoster?: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const isSetup = mode === 'setup'
|
||||
const title = isInlineSetup ? t(`${i18nPrefix}.roster.inlineSetup.name`, { ns: 'workflow' }) : agent.name
|
||||
const description = isSetup ? t(`${i18nPrefix}.roster.inlineSetup.description`, { ns: 'workflow' }) : agent.role
|
||||
const showInlineActions = isInlineSetup && !!onSaveInlineToRoster
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -160,16 +173,27 @@ function AgentRosterDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</button>
|
||||
<div className="flex h-3.5 items-start px-1">
|
||||
<div className="h-full w-px bg-divider-regular" />
|
||||
</div>
|
||||
{showInlineActions && (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44 w-max">
|
||||
<DropdownMenuItem className="gap-2 whitespace-nowrap" onClick={onSaveInlineToRoster}>
|
||||
<span aria-hidden className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t('roster.saveToRoster', { ns: 'agentV2' })}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex h-3.5 items-start px-1">
|
||||
<div className="h-full w-px bg-divider-regular" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DrawerCloseButton
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="size-6 rounded-md"
|
||||
@@ -193,6 +217,8 @@ function AgentRosterDrawer({
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
className="min-w-0 flex-1 gap-1.5 px-3"
|
||||
loading={isCopyPending}
|
||||
onClick={onMakeCopy}
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-copy-2-line size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
@@ -222,6 +248,7 @@ export function AgentRosterField({
|
||||
agentId,
|
||||
canOpenPanel = true,
|
||||
isPanelOpen,
|
||||
isPanelCopyPending = false,
|
||||
isPending = false,
|
||||
isLoading = false,
|
||||
isInlineSetup = false,
|
||||
@@ -230,13 +257,16 @@ export function AgentRosterField({
|
||||
showPanelDetailActions = true,
|
||||
portalContainerRef,
|
||||
onChange,
|
||||
onMakeCopy,
|
||||
onPanelOpenChange,
|
||||
onSaveInlineToRoster,
|
||||
onStartFromScratch,
|
||||
}: {
|
||||
agent?: AgentRosterDisplayData
|
||||
agentId?: string
|
||||
canOpenPanel?: boolean
|
||||
isPanelOpen?: boolean
|
||||
isPanelCopyPending?: boolean
|
||||
isLoading?: boolean
|
||||
isInlineSetup?: boolean
|
||||
isPending?: boolean
|
||||
@@ -245,7 +275,9 @@ export function AgentRosterField({
|
||||
showPanelDetailActions?: boolean
|
||||
portalContainerRef: RefObject<HTMLDivElement | null>
|
||||
onChange: (agent: AgentRosterNodeData) => void
|
||||
onMakeCopy?: () => void
|
||||
onPanelOpenChange?: (open: boolean) => void
|
||||
onSaveInlineToRoster?: () => void
|
||||
onStartFromScratch?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -358,6 +390,9 @@ export function AgentRosterField({
|
||||
portalContainerRef={portalContainerRef}
|
||||
showAccessIcon={!isInlineSetup}
|
||||
showDetailActions={showPanelDetailActions}
|
||||
isCopyPending={isPanelCopyPending}
|
||||
onMakeCopy={onMakeCopy}
|
||||
onSaveInlineToRoster={onSaveInlineToRoster}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
>
|
||||
{panelBody}
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentComposerAgentResponse, AgentComposerBindingResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentFormValues, AgentIconSelection } from '@/features/agent-v2/roster/components/agent-form'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import { createAgentIconSelection, defaultAgentIcon } from '@/features/agent-v2/roster/components/agent-form'
|
||||
import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type SaveInlineAgentToRosterDialogProps = {
|
||||
appId?: string
|
||||
formKey: number
|
||||
initialAgent?: AgentComposerAgentResponse | null
|
||||
nodeId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: (binding: AgentComposerBindingResponse) => void
|
||||
}
|
||||
|
||||
export function SaveInlineAgentToRosterDialog({
|
||||
appId,
|
||||
formKey,
|
||||
initialAgent,
|
||||
nodeId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: SaveInlineAgentToRosterDialogProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [name, setName] = useState(initialAgent?.name ?? '')
|
||||
const [description, setDescription] = useState(initialAgent?.description ?? '')
|
||||
const [role, setRole] = useState(initialAgent?.role ?? '')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [isIconChanged, setIsIconChanged] = useState(false)
|
||||
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => initialAgent
|
||||
? createAgentIconSelection(initialAgent)
|
||||
: defaultAgentIcon)
|
||||
const saveToRosterMutation = useMutation(
|
||||
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(),
|
||||
)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setName(initialAgent?.name ?? '')
|
||||
setDescription(initialAgent?.description ?? '')
|
||||
setRole(initialAgent?.role ?? '')
|
||||
setIsIconChanged(false)
|
||||
setAgentIcon(initialAgent
|
||||
? createAgentIconSelection(initialAgent)
|
||||
: defaultAgentIcon)
|
||||
}
|
||||
else {
|
||||
setIconPickerOpen(false)
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = (formValues: AgentFormValues) => {
|
||||
if (saveToRosterMutation.isPending)
|
||||
return
|
||||
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const trimmedName = formValues.name?.trim() ?? ''
|
||||
const trimmedRole = formValues.role?.trim() ?? ''
|
||||
|
||||
saveToRosterMutation.mutate({
|
||||
params: {
|
||||
app_id: appId,
|
||||
node_id: nodeId,
|
||||
},
|
||||
body: {
|
||||
variant: 'workflow',
|
||||
save_strategy: 'save_to_roster',
|
||||
new_agent_name: trimmedName,
|
||||
description: formValues.description?.trim() ?? '',
|
||||
role: trimmedRole,
|
||||
...(isIconChanged
|
||||
? {
|
||||
icon_type: agentIcon.type,
|
||||
icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon,
|
||||
icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}, {
|
||||
onSuccess: (composerState) => {
|
||||
const binding = composerState.binding
|
||||
if (binding?.binding_type !== 'roster_agent' || !binding.agent_id)
|
||||
return
|
||||
|
||||
toast.success(t('roster.saveToRosterSuccess'))
|
||||
onSaved(binding)
|
||||
handleOpenChange(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!">
|
||||
<DialogCloseButton />
|
||||
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('roster.saveToRosterDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('roster.saveToRosterDialog.description')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Form<AgentFormValues>
|
||||
key={formKey}
|
||||
className="min-h-0 flex-1"
|
||||
onFormSubmit={handleSubmit}
|
||||
>
|
||||
<AgentFormFields
|
||||
description={description}
|
||||
icon={agentIcon}
|
||||
iconAriaLabel={t('roster.saveToRosterForm.changeIcon')}
|
||||
name={name}
|
||||
role={role}
|
||||
onDescriptionChange={setDescription}
|
||||
onIconClick={() => setIconPickerOpen(true)}
|
||||
onNameChange={setName}
|
||||
onRoleChange={setRole}
|
||||
/>
|
||||
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
|
||||
<Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={saveToRosterMutation.isPending}>
|
||||
{tCommon('operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="min-w-18"
|
||||
loading={saveToRosterMutation.isPending}
|
||||
>
|
||||
{tCommon('operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AppIconPicker
|
||||
open={iconPickerOpen}
|
||||
initialEmoji={agentIcon.type === 'emoji'
|
||||
? { icon: agentIcon.icon, background: agentIcon.background }
|
||||
: undefined}
|
||||
onOpenChange={setIconPickerOpen}
|
||||
onSelect={(icon) => {
|
||||
setAgentIcon(icon)
|
||||
setIsIconChanged(true)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentInlineBinding } from '../../block-selector/types'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
@@ -51,22 +51,23 @@ export function useCreateInlineAgentBinding() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const configsMap = useHooksStore(state => state.configsMap)
|
||||
const { data: defaultModel } = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
const queryClient = useQueryClient()
|
||||
const {
|
||||
isPending,
|
||||
mutate,
|
||||
mutateAsync,
|
||||
} = useMutation(
|
||||
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(),
|
||||
)
|
||||
|
||||
const createInlineAgentBinding = useCallback((nodeId: string, options?: CreateInlineAgentBindingOptions) => {
|
||||
const createInlineAgentBinding = useCallback(async (nodeId: string, options?: CreateInlineAgentBindingOptions) => {
|
||||
if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) {
|
||||
toast.error(t('roster.nodeSelector.createInlineFailed'))
|
||||
options?.onError?.()
|
||||
return
|
||||
}
|
||||
|
||||
mutate(
|
||||
{
|
||||
try {
|
||||
const composerState = await mutateAsync({
|
||||
params: {
|
||||
app_id: configsMap.flowId,
|
||||
node_id: nodeId,
|
||||
@@ -82,33 +83,40 @@ export function useCreateInlineAgentBinding() {
|
||||
},
|
||||
agent_soul: getDefaultAgentSoul(defaultModel),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (composerState) => {
|
||||
const binding = composerState.binding
|
||||
})
|
||||
const binding = composerState.binding
|
||||
|
||||
if (
|
||||
binding?.binding_type !== 'inline_agent'
|
||||
|| !binding.agent_id
|
||||
|| !binding.current_snapshot_id
|
||||
) {
|
||||
toast.error(t('roster.nodeSelector.createInlineFailed'))
|
||||
options?.onError?.()
|
||||
return
|
||||
}
|
||||
if (
|
||||
binding?.binding_type !== 'inline_agent'
|
||||
|| !binding.agent_id
|
||||
|| !binding.current_snapshot_id
|
||||
) {
|
||||
toast.error(t('roster.nodeSelector.createInlineFailed'))
|
||||
options?.onError?.()
|
||||
return
|
||||
}
|
||||
|
||||
options?.onSuccess?.({
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: binding.agent_id,
|
||||
current_snapshot_id: binding.current_snapshot_id,
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
options?.onError?.()
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [configsMap?.flowId, configsMap?.flowType, defaultModel, mutate, t])
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
app_id: configsMap.flowId,
|
||||
node_id: nodeId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
composerState,
|
||||
)
|
||||
options?.onSuccess?.({
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: binding.agent_id,
|
||||
current_snapshot_id: binding.current_snapshot_id,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
options?.onError?.()
|
||||
}
|
||||
}, [configsMap?.flowId, configsMap?.flowType, defaultModel, mutateAsync, queryClient, t])
|
||||
|
||||
return {
|
||||
createInlineAgentBinding,
|
||||
|
||||
@@ -24,7 +24,7 @@ function AgentNodeAvatar({
|
||||
if (isInlineAgent) {
|
||||
return (
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-background-default-burn text-text-tertiary">
|
||||
<span aria-hidden className="i-custom-vender-agent-v2-robot-3 size-5" />
|
||||
<span aria-hidden className="i-custom-vender-agent-v2-configure h-3.5 w-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AgentComposerBindingResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentRosterNodeData } from '../../block-selector/types'
|
||||
import type { NodePanelProps } from '../../types'
|
||||
import type { AgentV2NodeType } from './types'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -10,12 +12,14 @@ import {
|
||||
} from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils'
|
||||
import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import useNodeCrud from '../_base/hooks/use-node-crud'
|
||||
import { AgentAdvancedSettings } from './components/agent-advanced-settings'
|
||||
import { AgentOrchestrateDrawerPanel } from './components/agent-orchestrate-drawer-panel'
|
||||
import { AgentOutputVariables } from './components/agent-output-variables'
|
||||
import { AgentRosterField } from './components/agent-roster-field'
|
||||
import { AgentTaskField } from './components/agent-task-field'
|
||||
import { SaveInlineAgentToRosterDialog } from './components/save-inline-agent-to-roster-dialog'
|
||||
import { useAgentRosterDetail, useCreateInlineAgentBinding, useWorkflowInlineAgentDetail } from './hooks'
|
||||
import { getAgentV2DeclaredOutputs } from './output-variables'
|
||||
import { hasValidInlineAgentBinding } from './types'
|
||||
@@ -30,6 +34,8 @@ export function AgentV2Panel({
|
||||
const promptOutputNamesRef = useRef(extractAgentOutputNames(inputs.agent_task || ''))
|
||||
const [isRosterAgentPanelOpen, setIsRosterAgentPanelOpen] = useState(false)
|
||||
const [isInlineAgentPanelOpenedFromTrigger, setIsInlineAgentPanelOpenedFromTrigger] = useState(false)
|
||||
const [isSaveToRosterDialogOpen, setIsSaveToRosterDialogOpen] = useState(false)
|
||||
const [saveToRosterSessionKey, setSaveToRosterSessionKey] = useState(0)
|
||||
const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
|
||||
const openInlineAgentPanelNodeId = useStore(state => state.openInlineAgentPanelNodeId)
|
||||
const setOpenInlineAgentPanelNodeId = useStore(state => state.setOpenInlineAgentPanelNodeId)
|
||||
@@ -40,18 +46,25 @@ export function AgentV2Panel({
|
||||
const inlineAgentId = inputs.agent_binding?.binding_type === 'inline_agent' ? inputs.agent_binding.agent_id : undefined
|
||||
const isInlineAgentReady = hasValidInlineAgentBinding(inputs)
|
||||
const isInlineAgentPending = inputs.agent_binding?.binding_type === 'inline_agent' && !isInlineAgentReady
|
||||
const isInlineAgentPanelOpen = isInlineAgentReady && openInlineAgentPanelNodeId === id
|
||||
const isInlineAgentPanelOpen = (isInlineAgentReady || isInlineAgentPending) && openInlineAgentPanelNodeId === id
|
||||
const rosterAgentQuery = useAgentRosterDetail(rosterAgentId)
|
||||
const inlineAgentQuery = useWorkflowInlineAgentDetail(id, inlineAgentId)
|
||||
const { createInlineAgentBinding, isCreatingInlineAgent } = useCreateInlineAgentBinding()
|
||||
const inlineAgent = inlineAgentQuery.data?.agent
|
||||
const isAgentPanelOpen = isInlineAgentReady ? isInlineAgentPanelOpen : isRosterAgentPanelOpen
|
||||
const isInlineAgentLoading = isInlineAgentReady && !inlineAgent
|
||||
const {
|
||||
isPending: isCopyingFromRoster,
|
||||
mutate: copyFromRoster,
|
||||
} = useMutation(
|
||||
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(),
|
||||
)
|
||||
const isAgentPanelOpen = isInlineAgentReady || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen
|
||||
const isInlineAgentLoading = isInlineAgentPending || (isInlineAgentReady && !inlineAgent)
|
||||
const isAgentBindingPending = isInlineAgentPending || isCreatingInlineAgent
|
||||
const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent'
|
||||
const displayedAgent = rosterAgentQuery.data ?? (inlineAgentId && isInlineAgentReady
|
||||
const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent
|
||||
const displayedAgent = rosterAgentQuery.data ?? (isInlineAgentPending || isInlineAgentReady
|
||||
? {
|
||||
id: inlineAgentId,
|
||||
id: inlineAgentId ?? id,
|
||||
name: inlineAgent?.name || t('nodes.agent.roster.inlineSetup.name', { ns: 'workflow' }),
|
||||
description: inlineAgent?.description,
|
||||
role: t('nodes.agent.roster.inlineSetup.type', { ns: 'workflow' }),
|
||||
@@ -113,13 +126,117 @@ export function AgentV2Panel({
|
||||
)
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id, inputs, setOpenInlineAgentPanelNodeId])
|
||||
|
||||
const handleStartFromScratch = useCallback(() => {
|
||||
createInlineAgentBinding(id, {
|
||||
onSuccess: (binding) => {
|
||||
const handleMakeRosterCopy = useCallback(() => {
|
||||
if (!appId || !rosterAgentId || isCopyingFromRoster)
|
||||
return
|
||||
|
||||
copyFromRoster({
|
||||
params: {
|
||||
app_id: appId,
|
||||
node_id: id,
|
||||
},
|
||||
body: {
|
||||
source_agent_id: rosterAgentId,
|
||||
},
|
||||
}, {
|
||||
onSuccess: (composerState) => {
|
||||
const binding = composerState.binding
|
||||
if (
|
||||
binding?.binding_type !== 'inline_agent'
|
||||
|| !binding.agent_id
|
||||
|| !binding.current_snapshot_id
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsRosterAgentPanelOpen(false)
|
||||
setIsInlineAgentPanelOpenedFromTrigger(false)
|
||||
setIsInlineAgentPanelOpenedFromTrigger(true)
|
||||
setOpenInlineAgentPanelNodeId(id)
|
||||
|
||||
const newInputs = produce(inputsRef.current, (draft) => {
|
||||
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
|
||||
draft.agent_binding = {
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: binding.agent_id,
|
||||
current_snapshot_id: binding.current_snapshot_id,
|
||||
}
|
||||
draft._openInlineAgentPanel = true
|
||||
})
|
||||
inputsRef.current = newInputs
|
||||
handleNodeDataUpdateWithSyncDraft(
|
||||
{
|
||||
id,
|
||||
data: newInputs,
|
||||
},
|
||||
{
|
||||
sync: true,
|
||||
notRefreshWhenSyncError: true,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
}, [appId, copyFromRoster, handleNodeDataUpdateWithSyncDraft, id, isCopyingFromRoster, rosterAgentId, setOpenInlineAgentPanelNodeId])
|
||||
|
||||
const handleSaveInlineToRosterOpen = useCallback(() => {
|
||||
setSaveToRosterSessionKey(key => key + 1)
|
||||
setIsSaveToRosterDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleInlineSavedToRoster = useCallback((binding: AgentComposerBindingResponse) => {
|
||||
if (binding.binding_type !== 'roster_agent' || !binding.agent_id)
|
||||
return
|
||||
|
||||
setOpenInlineAgentPanelNodeId(undefined)
|
||||
setIsInlineAgentPanelOpenedFromTrigger(false)
|
||||
setIsRosterAgentPanelOpen(true)
|
||||
|
||||
const newInputs = produce(inputsRef.current, (draft) => {
|
||||
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
|
||||
delete draft._openInlineAgentPanel
|
||||
draft.agent_binding = {
|
||||
binding_type: 'roster_agent',
|
||||
agent_id: binding.agent_id!,
|
||||
}
|
||||
})
|
||||
inputsRef.current = newInputs
|
||||
handleNodeDataUpdateWithSyncDraft(
|
||||
{
|
||||
id,
|
||||
data: newInputs,
|
||||
},
|
||||
{
|
||||
sync: true,
|
||||
notRefreshWhenSyncError: true,
|
||||
},
|
||||
)
|
||||
}, [handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId])
|
||||
|
||||
const handleStartFromScratch = useCallback(() => {
|
||||
setIsRosterAgentPanelOpen(false)
|
||||
setIsInlineAgentPanelOpenedFromTrigger(false)
|
||||
setOpenInlineAgentPanelNodeId(id)
|
||||
|
||||
const pendingInputs = produce(inputsRef.current, (draft) => {
|
||||
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
|
||||
draft.agent_binding = {
|
||||
binding_type: 'inline_agent',
|
||||
}
|
||||
draft._openInlineAgentPanel = true
|
||||
})
|
||||
inputsRef.current = pendingInputs
|
||||
handleNodeDataUpdateWithSyncDraft(
|
||||
{
|
||||
id,
|
||||
data: pendingInputs,
|
||||
},
|
||||
{
|
||||
sync: true,
|
||||
notRefreshWhenSyncError: true,
|
||||
},
|
||||
)
|
||||
|
||||
createInlineAgentBinding(id, {
|
||||
onSuccess: (binding) => {
|
||||
const newInputs = produce(inputsRef.current, (draft) => {
|
||||
delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster
|
||||
draft.agent_binding = binding
|
||||
@@ -196,31 +313,44 @@ export function AgentV2Panel({
|
||||
<div className="border-b border-divider-subtle">
|
||||
<AgentRosterField
|
||||
agent={displayedAgent}
|
||||
agentId={rosterAgentId ?? inlineAgentId ?? undefined}
|
||||
canOpenPanel={!isInlineAgentPending}
|
||||
isInlineSetup={isInlineAgentReady}
|
||||
agentId={rosterAgentId ?? inlineAgentId ?? (isInlineAgentPending ? id : undefined)}
|
||||
canOpenPanel
|
||||
isInlineSetup={isInlineAgentReady || isInlineAgentPending}
|
||||
isLoading={isInlineAgentLoading}
|
||||
isPanelCopyPending={isCopyingFromRoster}
|
||||
isPanelOpen={isAgentPanelOpen}
|
||||
isPending={isAgentBindingPending}
|
||||
panelBody={isAgentPanelOpen && displayedAgent
|
||||
? (
|
||||
<AgentOrchestrateDrawerPanel
|
||||
agentId={displayedAgent.id}
|
||||
agentId={inlineAgentId ?? rosterAgentId}
|
||||
appId={appId}
|
||||
inlineComposerState={inlineAgentQuery.data}
|
||||
isInline={isInlineAgentReady}
|
||||
isInline={isInlineAgentReady || isInlineAgentPending}
|
||||
nodeId={id}
|
||||
open={isAgentPanelOpen}
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
panelMode={isInlineAgentReady && !isInlineAgentPanelOpenedFromTrigger ? 'setup' : 'detail'}
|
||||
panelMode={isInlineAgentPending || (isInlineAgentReady && !isInlineAgentPanelOpenedFromTrigger) ? 'setup' : 'detail'}
|
||||
portalContainerRef={drawerPortalContainerRef}
|
||||
showPanelDetailActions={!isInlineAgentReady}
|
||||
showPanelDetailActions={!isInlineAgentReady && !isInlineAgentPending}
|
||||
onChange={handleRosterChange}
|
||||
onMakeCopy={rosterAgentId ? handleMakeRosterCopy : undefined}
|
||||
onPanelOpenChange={handleAgentPanelOpenChange}
|
||||
onSaveInlineToRoster={canSaveInlineToRoster ? handleSaveInlineToRosterOpen : undefined}
|
||||
onStartFromScratch={canStartFromScratch ? handleStartFromScratch : undefined}
|
||||
/>
|
||||
<SaveInlineAgentToRosterDialog
|
||||
key={saveToRosterSessionKey}
|
||||
appId={appId}
|
||||
formKey={saveToRosterSessionKey}
|
||||
initialAgent={inlineAgent}
|
||||
nodeId={id}
|
||||
open={isSaveToRosterDialogOpen}
|
||||
onOpenChange={setIsSaveToRosterDialogOpen}
|
||||
onSaved={handleInlineSavedToRoster}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-disabled={isInlineAgentPending}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user