Compare commits

...
Author SHA1 Message Date
Yansong Zhang 9cf7e849f1 feat(agent): version agent soul drive file refs 2026-06-24 17:09:11 +08:00
yyh a60a26d419 Merge branch 'deploy/agent' of https://github.com/langgenius/dify into deploy/agent 2026-06-24 16:21:11 +08:00
yyh b24b49178a Merge branch 'deploy/agent' of https://github.com/langgenius/dify into deploy/agent 2026-06-24 16:20:16 +08:00
yyh 5bb8658b0d fix: use timestamp hook 2026-06-24 16:19:55 +08:00
yyh a656e514aa fix: no console api 2026-06-24 16:18:00 +08:00
Yansong Zhang c0d1e85698 feat(agent): version drive refs in agent soul 2026-06-24 16:15:54 +08:00
Joel fe274fc28c feat: build and preview chat 2026-06-24 15:53:41 +08:00
yyh 63e91b40ae Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 15:48:37 +08:00
yyh dbb7e36ab9 Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 15:32:22 +08:00
yyh 4ddc9ceb5c feat: embed agent webapp 2026-06-24 15:26:27 +08:00
yyh 01c138e7a9 fix(api): site dto 2026-06-24 14:47:30 +08:00
yyh 71df1572b7 fix: use new icon in agent v2 node 2026-06-24 14:08:32 +08:00
yyh 5d0ec54e8c Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 13:51:02 +08:00
Joel ac80d95aed chore: split hooks frorm agent 2026-06-24 13:43:08 +08:00
yyh b1feacca6b Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-24 11:28:14 +08:00
yyh 7ef6c13d0d feat(agent-v2): wire workflow agent copy and roster save 2026-06-23 21:31:49 +08:00
yyh 673d84073b Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-23 20:46:06 +08:00
yyh 3d24e21709 feat: add roster beta badge 2026-06-23 20:11:41 +08:00
yyh f11b09abf1 feat(webapp): customize 2026-06-23 20:01:50 +08:00
yyh 238b9202d6 fix: stabilize sidebar nav active layout 2026-06-23 20:00:54 +08:00
yyh 573f9de17a fix(agent-v2): update configure nav icon 2026-06-23 19:57:36 +08:00
yyh d6e902e5a7 fix(agent-v2): rename configure nav label 2026-06-23 19:48:31 +08:00
yyh bf5caeb9f1 fix: new api key modal 2026-06-23 19:36:43 +08:00
yyh 1e17c0f314 feat: access point settings 2026-06-23 18:57:55 +08:00
yyh 73c9017e63 fix(web): update agent access settings icon 2026-06-23 18:34:01 +08:00
yyh 6dd394edee fix(agent-v2): open workflow references in new tab 2026-06-23 18:29:25 +08:00
yyh 53b59eea8b feat: support nested agent output children 2026-06-23 18:14:34 +08:00
Yanli 盐粒 c9a3fa9a45 Merge remote-tracking branch 'origin/feat/agent-v2' into feat/agent-v2 2026-06-23 17:57:54 +08:00
Joel bf01ad417c fix: publish state 2026-06-23 17:54:32 +08:00
Joel 6e1b7d806d chore: cli input placeholder 2026-06-23 17:54:30 +08:00
Yanli 盐粒 7bb4b67885 fix: relax knowledge retrieval usage schema 2026-06-23 17:35:10 +08:00
Yanli 盐粒 8203d42233 Merge remote-tracking branch 'origin/main' into feat/agent-v2 2026-06-23 16:54:02 +08:00
Yanli 盐粒 3efc1f93b9 feat(web): support agent v2 knowledge sets 2026-06-23 16:43:37 +08:00
Yanli 盐粒 c06d924094 feat: wire agent v2 knowledge sets backend 2026-06-23 16:43:10 +08:00
Joel c3cb134e73 chore: missing files 2026-06-23 16:26:12 +08:00
Joel dd3ba72c2c chore: refresh agent debug conversatation id 2026-06-23 16:26:12 +08:00
Joel 44a4fb8888 fix: missing dep 2026-06-23 16:26:11 +08:00
Joel 6c05219af8 fix: knowledge modal not anim 2026-06-23 16:26:09 +08:00
Joel ce5033c554 fix: inline agent panel not always show 2026-06-23 16:26:09 +08:00
Joel 31ee7bb467 fix: public and Knowledge no mask 2026-06-23 16:26:09 +08:00
149 changed files with 9242 additions and 1751 deletions
+2 -2
View File
@@ -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,
+61 -17
View File
@@ -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}
+3 -3
View File
@@ -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,
@@ -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}
@@ -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,
+21 -1
View File
@@ -16,8 +16,10 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentCliToolConfig,
AgentFileRefConfig,
AgentHumanContactConfig,
AgentKnowledgeDatasetConfig,
AgentSkillRefConfig,
AgentSoulConfig,
DeclaredOutputConfig,
DeclaredOutputType,
@@ -298,6 +300,10 @@ 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
@@ -400,11 +406,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):
+189 -8
View File
@@ -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)
+95 -8
View File
@@ -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
+32 -12
View File
@@ -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():
+133 -58
View File
@@ -37,7 +37,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 +125,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)
@@ -294,6 +300,7 @@ 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")
@@ -327,6 +334,11 @@ class AgentComposerService:
except IntegrityError as exc:
db.session.rollback()
raise AgentNameConflictError() from exc
payload.agent_soul = cls._preserve_active_soul_files(
tenant_id=tenant_id,
agent_id=agent.id,
agent_soul=payload.agent_soul,
)
if payload.save_strategy == ComposerSaveStrategy.SAVE_AS_NEW_VERSION or not agent.active_config_snapshot_id:
version = cls._create_config_version(
@@ -372,29 +384,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 +452,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 +482,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 +559,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 +586,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(
@@ -629,30 +679,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 +789,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 +894,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 +933,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 +970,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 +991,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 +1035,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 +1179,38 @@ 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
@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 []:
@@ -1479,6 +1550,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,
+5 -5
View File
@@ -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(
+63
View File
@@ -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]
+4 -4
View File
@@ -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 _:
@@ -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
+528
View File
@@ -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
+53 -11
View File
@@ -12,15 +12,17 @@ from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationE
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentDriveFile,
AgentDriveFileKind,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, WorkflowNodeJobConfig
from models.model import ToolFile, UploadFile
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,
@@ -169,6 +171,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 +181,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,
+98 -3
View File
@@ -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
@@ -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(
@@ -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()
@@ -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():
@@ -376,13 +376,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 +1095,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 +1184,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(
@@ -3312,20 +3347,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 +3544,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 +3580,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 +3595,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 +3607,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']",
],
-5
View File
@@ -883,11 +883,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
}
+20
View File
@@ -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}
/>
)}
@@ -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>
+6 -1
View File
@@ -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)
+11 -4
View File
@@ -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>
)
}
+11 -1
View File
@@ -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 />}
@@ -89,6 +89,7 @@ const CandidateNodeMain: FC<Props> = ({
}
if (shouldCreateInlineAgentBinding) {
workflowStore.getState().setOpenInlineAgentPanelNodeId(candidateNode.id)
createInlineAgentBinding(candidateNode.id, {
onError: () => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
@@ -105,7 +106,6 @@ const CandidateNodeMain: FC<Props> = ({
delete node.data._isTempNode
}
}))
workflowStore.getState().setOpenInlineAgentPanelNodeId(candidateNode.id)
handleSyncWorkflowDraft(true, true)
},
})
@@ -193,6 +193,7 @@ export const useNodesInteractions = () => {
const createInlineAgentBindingForNode = useCallback((nodeId: string, options?: {
onError?: () => void
}) => {
workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId)
createInlineAgentBinding(nodeId, {
onError: () => {
options?.onError?.()
@@ -208,7 +209,6 @@ export const useNodesInteractions = () => {
delete node.data._isTempNode
}
}))
workflowStore.getState().setOpenInlineAgentPanelNodeId(nodeId)
handleSyncWorkflowDraft(true, true)
},
})
@@ -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',
@@ -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),
}))
})
})
@@ -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" />
@@ -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',
}],
}],
}])
})
})
@@ -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,
}],
},
})
})
})
@@ -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' })}
@@ -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])
}
@@ -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}
@@ -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)
}}
/>
</>
)
}
@@ -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}
@@ -125,6 +125,7 @@ const AddBlock = ({
workflowStore.setState({
candidateNode: undefined,
})
workflowStore.getState().setOpenInlineAgentPanelNodeId(newNode.id)
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: newNode.id })
createInlineAgentBinding(newNode.id, {
onSuccess: (binding) => {
@@ -138,7 +139,6 @@ const AddBlock = ({
delete node.data._isTempNode
}
}))
workflowStore.getState().setOpenInlineAgentPanelNodeId(newNode.id)
handleSyncWorkflowDraft(true, true)
},
})
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { LogicalOperator, MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import { RETRIEVE_TYPE } from '@/types/app'
import { validateKnowledgeRetrievals } from '../knowledge-validation'
describe('validateKnowledgeRetrievals', () => {
it('should accept a valid generated-query knowledge retrieval', () => {
const result = validateKnowledgeRetrievals([
{
id: 'retrieval-1',
name: 'Docs Search',
datasetRefs: [{ id: 'dataset-1', name: 'Docs' }],
queryMode: 'agent',
retrievalMode: RETRIEVE_TYPE.multiWay,
},
])
expect(result).toMatchObject({
isValid: true,
byId: {},
firstIssue: undefined,
})
})
it('should reject blank and duplicate names', () => {
const result = validateKnowledgeRetrievals([
{
id: 'retrieval-1',
name: ' ',
datasetRefs: [{ id: 'dataset-1', name: 'Docs' }],
},
{
id: 'retrieval-2',
name: 'Docs Search',
datasetRefs: [{ id: 'dataset-2', name: 'FAQ' }],
},
{
id: 'retrieval-3',
name: ' docs search ',
datasetRefs: [{ id: 'dataset-3', name: 'Guide' }],
},
])
expect(result.isValid).toBe(false)
expect(result.byId['retrieval-1']).toMatchObject({ name: 'name_required' })
expect(result.byId['retrieval-2']).toMatchObject({ name: 'name_duplicate' })
expect(result.byId['retrieval-3']).toMatchObject({ name: 'name_duplicate' })
})
it('should reject missing datasets, blank custom queries, and missing single-retrieval model', () => {
const result = validateKnowledgeRetrievals([
{
id: 'retrieval-1',
name: 'Product Docs',
queryMode: 'custom',
customQuery: ' ',
retrievalMode: RETRIEVE_TYPE.oneWay,
singleRetrievalConfig: {
model: {
provider: '',
name: '',
mode: 'chat',
completion_params: {},
},
},
},
])
expect(result.isValid).toBe(false)
expect(result.byId['retrieval-1']).toMatchObject({
datasets: 'datasets_required',
query: 'custom_query_required',
retrieval: 'single_model_required',
})
})
it('should reject invalid metadata filtering requirements', () => {
const result = validateKnowledgeRetrievals([
{
id: 'retrieval-1',
name: 'Auto Metadata',
datasetRefs: [{ id: 'dataset-1', name: 'Docs' }],
metadataFilterMode: MetadataFilteringModeEnum.automatic,
},
{
id: 'retrieval-2',
name: 'Manual Metadata',
datasetRefs: [{ id: 'dataset-2', name: 'FAQ' }],
metadataFilterMode: MetadataFilteringModeEnum.manual,
metadataFilteringConditions: {
logical_operator: LogicalOperator.and,
conditions: [],
},
},
])
expect(result.isValid).toBe(false)
expect(result.byId['retrieval-1']).toMatchObject({ metadata: 'metadata_model_required' })
expect(result.byId['retrieval-2']).toMatchObject({ metadata: 'metadata_conditions_required' })
})
})
@@ -4,7 +4,7 @@ import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conve
import { defaultAgentSoulConfigFormState } from '../form-state'
describe('agent composer store conversions', () => {
it('should hydrate editable form state from an AgentSoulConfig and preserve it in publish payload', () => {
it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => {
const baseConfig: AgentSoulConfig = {
app_features: {
opening_statement: 'Hello',
@@ -27,20 +27,30 @@ describe('agent composer store conversions', () => {
],
},
knowledge: {
datasets: [
sets: [
{
id: 'dataset-1',
id: 'support',
name: 'Product Docs',
description: 'Docs corpus',
datasets: [
{
id: 'dataset-1',
name: 'Product Docs',
description: 'Docs corpus',
},
],
query: {
mode: 'user_query',
value: 'release notes',
},
retrieval: {
mode: 'multiple',
top_k: 8,
score_threshold: 0.72,
reranking_enable: false,
},
},
],
query_config: {
query: 'release notes',
score_threshold: 0.72,
score_threshold_enabled: true,
top_k: 8,
},
query_mode: 'user_query',
},
model: {
model: 'gpt-4.1',
@@ -100,7 +110,7 @@ describe('agent composer store conversions', () => {
},
knowledgeRetrievals: [
expect.objectContaining({
id: 'dataset-1',
id: 'support',
name: 'Product Docs',
queryMode: 'custom',
customQuery: 'release notes',
@@ -166,20 +176,30 @@ describe('agent composer store conversions', () => {
}),
])
expect(publishConfig.knowledge).toMatchObject({
datasets: [
sets: [
{
id: 'dataset-1',
id: 'support',
name: 'Product Docs',
description: 'Docs corpus',
datasets: [
{
id: 'dataset-1',
name: 'Product Docs',
description: 'Docs corpus',
},
],
query: {
mode: 'user_query',
value: 'release notes',
},
retrieval: {
mode: 'multiple',
top_k: 8,
score_threshold: 0.72,
reranking_enable: false,
},
},
],
query_config: {
query: 'release notes',
score_threshold: 0.72,
score_threshold_enabled: true,
top_k: 8,
},
query_mode: 'user_query',
})
expect(publishConfig.env).toMatchObject({
variables: [
@@ -285,21 +305,232 @@ describe('agent composer store conversions', () => {
})
})
it('should not hydrate a knowledge retrieval row when the config has no datasets', () => {
it('should not hydrate a knowledge retrieval row when the config has no sets', () => {
const formState = agentSoulConfigToFormState({
knowledge: {
datasets: [],
query_config: {
top_k: 4,
},
query_mode: 'generated_query',
sets: [],
},
})
expect(formState.knowledgeRetrievals).toEqual([])
})
it('should omit incomplete environment variables from the publish payload', () => {
it('should keep explicitly cleared selected datasets instead of falling back to dataset refs', () => {
const publishConfig = formStateToAgentSoulConfig({
formState: {
...defaultAgentSoulConfigFormState,
knowledgeRetrievals: [
{
id: 'retrieval-1',
name: 'Docs Search',
selectedDatasets: [],
datasetRefs: [
{
id: 'dataset-stale',
name: 'Stale Docs',
description: 'Should stay cleared',
},
],
},
],
},
})
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'retrieval-1',
datasets: [],
},
],
})
})
it('should round-trip single retrieval model config', () => {
const baseConfig: AgentSoulConfig = {
knowledge: {
sets: [
{
id: 'retrieval-1',
name: 'Docs Search',
datasets: [{ id: 'dataset-1', name: 'Docs' }],
query: { mode: 'generated_query' },
retrieval: {
mode: 'single',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1',
mode: 'chat',
completion_params: { temperature: 0.1 },
},
},
},
],
},
}
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
expect(formState.knowledgeRetrievals).toEqual([
expect.objectContaining({
id: 'retrieval-1',
retrievalMode: 'single',
singleRetrievalConfig: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1',
mode: 'chat',
completion_params: { temperature: 0.1 },
},
},
}),
])
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'retrieval-1',
retrieval: {
mode: 'single',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1',
mode: 'chat',
completion_params: { temperature: 0.1 },
},
},
},
],
})
})
it('should round-trip automatic metadata filtering model config', () => {
const baseConfig: AgentSoulConfig = {
knowledge: {
sets: [
{
id: 'retrieval-1',
name: 'Docs Search',
datasets: [{ id: 'dataset-1', name: 'Docs' }],
query: { mode: 'generated_query' },
retrieval: { mode: 'multiple', top_k: 4 },
metadata_filtering: {
mode: 'automatic',
model_config: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1-mini',
mode: 'chat',
completion_params: { temperature: 0.2 },
},
},
},
],
},
}
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
expect(formState.knowledgeRetrievals).toEqual([
expect.objectContaining({
id: 'retrieval-1',
metadataFilterMode: 'automatic',
metadataModelConfig: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1-mini',
mode: 'chat',
completion_params: { temperature: 0.2 },
},
}),
])
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'retrieval-1',
metadata_filtering: {
mode: 'automatic',
model_config: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1-mini',
mode: 'chat',
completion_params: { temperature: 0.2 },
},
},
},
],
})
})
it('should round-trip manual metadata filtering conditions', () => {
const baseConfig: AgentSoulConfig = {
knowledge: {
sets: [
{
id: 'retrieval-1',
name: 'Docs Search',
datasets: [{ id: 'dataset-1', name: 'Docs' }],
query: { mode: 'generated_query' },
retrieval: { mode: 'multiple', top_k: 4 },
metadata_filtering: {
mode: 'manual',
conditions: {
logical_operator: 'and',
conditions: [
{
name: 'language',
comparison_operator: 'is',
value: 'en',
},
],
},
},
},
],
},
}
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
expect(formState.knowledgeRetrievals).toEqual([
expect.objectContaining({
id: 'retrieval-1',
metadataFilterMode: 'manual',
metadataFilteringConditions: {
logical_operator: 'and',
conditions: [
{
name: 'language',
comparison_operator: 'is',
value: 'en',
},
],
},
}),
])
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'retrieval-1',
metadata_filtering: {
mode: 'manual',
conditions: {
logical_operator: 'and',
conditions: [
{
name: 'language',
comparison_operator: 'is',
value: 'en',
},
],
},
},
},
],
})
})
it('should omit incomplete environment variables from the config snapshot', () => {
const publishConfig = formStateToAgentSoulConfig({
formState: {
...defaultAgentSoulConfigFormState,
@@ -1,4 +1,10 @@
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import type {
AgentKnowledgeMetadataConditions,
AgentKnowledgeModelConfig,
AgentKnowledgeRetrievalConfig,
AgentKnowledgeSetConfig,
AgentSoulConfig,
} from '@dify/contracts/api/console/agent/types.gen'
import type {
AgentCliTool,
AgentKnowledgeRetrievalItem,
@@ -8,80 +14,132 @@ import type {
EnvVariable,
} from './form-state'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
MetadataFilteringConditions,
MultipleRetrievalConfig,
SingleRetrievalConfig,
} from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import type { ModelConfig } from '@/app/components/workflow/types'
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import { DATASET_DEFAULT } from '@/config'
import { RETRIEVE_TYPE } from '@/types/app'
import { checkKey } from '@/utils/var'
import { defaultAgentSoulConfigFormState } from './form-state'
import { getKnowledgeRetrievalSetName } from './knowledge-validation'
type AgentSoulDifyToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number]
type AgentSoulCliToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['cli_tools']>[number]
type AgentSoulToolRuntimeParameterValue = NonNullable<AgentSoulDifyToolConfig['runtime_parameters']>[string]
type AgentSoulEnvVariableConfig = NonNullable<NonNullable<AgentSoulConfig['env']>['variables']>[number]
const getKnowledgeRetrievalName = (item: AgentKnowledgeRetrievalItem) => item.name ?? item.nameKey ?? item.id
const toKnowledgeDatasets = (knowledgeRetrievals: AgentKnowledgeRetrievalItem[]) => knowledgeRetrievals.flatMap((item) => {
if (item.selectedDatasets?.length) {
const toKnowledgeDatasetRefs = (item: AgentKnowledgeRetrievalItem) => {
if (item.selectedDatasets !== undefined) {
return item.selectedDatasets.map(dataset => ({
description: dataset.description,
id: dataset.id,
name: dataset.name,
}))
}
if (item.datasetRefs?.length)
return item.datasetRefs
return [{
id: item.id,
name: getKnowledgeRetrievalName(item),
}]
return item.datasetRefs ?? []
}
const toRetrievalConfig = (item: AgentKnowledgeRetrievalItem): AgentKnowledgeRetrievalConfig => {
if (item.retrievalMode === RETRIEVE_TYPE.oneWay) {
return {
mode: 'single',
model: item.singleRetrievalConfig?.model,
}
}
const config = item.multipleRetrievalConfig
return {
mode: 'multiple',
top_k: config?.top_k ?? DATASET_DEFAULT.top_k,
score_threshold: config?.score_threshold ?? undefined,
reranking_mode: config?.reranking_mode,
reranking_enable: config?.reranking_enable ?? false,
reranking_model: config?.reranking_model,
weights: config?.weights,
}
}
const toModelFormState = (model?: AgentKnowledgeModelConfig | null): ModelConfig | undefined => {
if (!model)
return undefined
return {
provider: model.provider,
name: model.name,
mode: model.mode,
completion_params: model.completion_params ?? {},
}
}
const toMultipleRetrievalFormState = (config?: AgentKnowledgeRetrievalConfig): MultipleRetrievalConfig => ({
top_k: config?.top_k ?? DATASET_DEFAULT.top_k,
score_threshold: config?.score_threshold ?? null,
reranking_model: config?.reranking_model ?? undefined,
reranking_mode: config?.reranking_mode as MultipleRetrievalConfig['reranking_mode'],
weights: config?.weights as MultipleRetrievalConfig['weights'],
reranking_enable: config?.reranking_enable ?? false,
})
const toSingleRetrievalFormState = (config?: AgentKnowledgeRetrievalConfig): SingleRetrievalConfig | undefined => (
config?.model
? {
model: toModelFormState(config.model)!,
}
: undefined
)
const toMetadataFilteringConfig = (item: AgentKnowledgeRetrievalItem): AgentKnowledgeSetConfig['metadata_filtering'] => {
const mode = item.metadataFilterMode ?? MetadataFilteringModeEnum.disabled
return {
mode,
model_config: mode === MetadataFilteringModeEnum.automatic ? item.metadataModelConfig : undefined,
conditions: mode === MetadataFilteringModeEnum.manual
? item.metadataFilteringConditions as AgentKnowledgeMetadataConditions | undefined
: undefined,
}
}
const toKnowledgeSets = (knowledgeRetrievals: AgentKnowledgeRetrievalItem[]): AgentKnowledgeSetConfig[] => knowledgeRetrievals.map(item => ({
id: item.id,
name: getKnowledgeRetrievalSetName(item),
description: item.description,
datasets: toKnowledgeDatasetRefs(item),
query: {
mode: item.queryMode === 'custom' ? ('user_query' as const) : ('generated_query' as const),
value: item.queryMode === 'custom' ? (item.customQuery?.trim() || undefined) : undefined,
},
retrieval: toRetrievalConfig(item),
metadata_filtering: toMetadataFilteringConfig(item),
}))
const toKnowledgeRetrievalFormState = (config?: AgentSoulConfig): AgentKnowledgeRetrievalItem[] => {
const knowledge = config?.knowledge
const datasets = knowledge?.datasets ?? []
if (datasets.length === 0)
return []
return [{
id: datasets[0]?.id ?? 'knowledge-retrieval',
name: datasets[0]?.name ?? 'Knowledge Retrieval',
queryMode: knowledge?.query_mode === 'user_query' ? 'custom' : 'agent',
customQuery: knowledge?.query_config?.query ?? undefined,
datasetRefs: datasets,
multipleRetrievalConfig: {
top_k: knowledge?.query_config?.top_k ?? 4,
score_threshold: knowledge?.query_config?.score_threshold ?? null,
reranking_enable: false,
},
}]
return (config?.knowledge?.sets ?? []).map(knowledgeSet => ({
id: knowledgeSet.id,
name: knowledgeSet.name,
description: knowledgeSet.description ?? undefined,
queryMode: knowledgeSet.query.mode === 'user_query' ? 'custom' : 'agent',
customQuery: knowledgeSet.query.value ?? undefined,
datasetRefs: knowledgeSet.datasets,
retrievalMode: knowledgeSet.retrieval.mode === 'single' ? RETRIEVE_TYPE.oneWay : RETRIEVE_TYPE.multiWay,
multipleRetrievalConfig: toMultipleRetrievalFormState(knowledgeSet.retrieval),
singleRetrievalConfig: toSingleRetrievalFormState(knowledgeSet.retrieval),
metadataFilterMode: (knowledgeSet.metadata_filtering?.mode ?? MetadataFilteringModeEnum.disabled) as MetadataFilteringModeEnum,
metadataFilteringConditions: knowledgeSet.metadata_filtering?.conditions as MetadataFilteringConditions | undefined,
metadataModelConfig: toModelFormState(knowledgeSet.metadata_filtering?.model_config),
}))
}
const toKnowledgeConfig = (
baseKnowledge: AgentSoulConfig['knowledge'],
knowledgeRetrievals: AgentKnowledgeRetrievalItem[],
): AgentSoulConfig['knowledge'] => {
const primaryRetrieval = knowledgeRetrievals.find(retrieval =>
retrieval.queryMode === 'custom'
|| retrieval.customQuery
|| retrieval.multipleRetrievalConfig
|| retrieval.selectedDatasets?.length,
) ?? knowledgeRetrievals[0]
const multipleRetrievalConfig = primaryRetrieval?.multipleRetrievalConfig
const scoreThreshold = multipleRetrievalConfig?.score_threshold
return {
...baseKnowledge,
datasets: toKnowledgeDatasets(knowledgeRetrievals),
query_mode: primaryRetrieval?.queryMode === 'custom' ? 'user_query' : 'generated_query',
query_config: {
...baseKnowledge?.query_config,
query: primaryRetrieval?.queryMode === 'custom' ? primaryRetrieval.customQuery : null,
score_threshold: scoreThreshold,
score_threshold_enabled: scoreThreshold !== undefined && scoreThreshold !== null,
top_k: multipleRetrievalConfig?.top_k ?? baseKnowledge?.query_config?.top_k,
},
}
}
): AgentSoulConfig['knowledge'] => ({
sets: toKnowledgeSets(knowledgeRetrievals),
})
const isToolRuntimeParameterValue = (value: unknown): value is AgentSoulToolRuntimeParameterValue => {
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
@@ -396,7 +454,7 @@ export const formStateToAgentSoulConfig = ({
cli_tools: toCliToolConfigs(formState.tools),
},
app_features: formState.appFeatures ?? baseConfig?.app_features,
knowledge: toKnowledgeConfig(baseConfig?.knowledge, formState.knowledgeRetrievals),
knowledge: toKnowledgeConfig(formState.knowledgeRetrievals),
env: toEnvConfig(formState.envVariables),
}
}
@@ -6,6 +6,7 @@ import type {
MetadataFilteringConditions,
MetadataFilteringModeEnum,
MultipleRetrievalConfig,
SingleRetrievalConfig,
} from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import type { ModelConfig } from '@/app/components/workflow/types'
import type { DataSet } from '@/models/datasets'
@@ -42,6 +43,7 @@ export type AgentFileNode = {
export type AgentKnowledgeRetrievalItem = {
id: string
name?: string
description?: string
nameKey?: I18nKeysWithPrefix<'agentV2', 'agentDetail.configure.knowledgeRetrieval.'>
queryMode?: 'agent' | 'custom'
customQuery?: string
@@ -49,6 +51,7 @@ export type AgentKnowledgeRetrievalItem = {
selectedDatasets?: DataSet[]
retrievalMode?: RETRIEVE_TYPE
multipleRetrievalConfig?: MultipleRetrievalConfig
singleRetrievalConfig?: SingleRetrievalConfig
metadataFilterMode?: MetadataFilteringModeEnum
metadataFilteringConditions?: MetadataFilteringConditions
metadataModelConfig?: ModelConfig
@@ -0,0 +1,175 @@
import type { AgentKnowledgeRetrievalItem } from './form-state'
import { useTranslation } from 'react-i18next'
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import { RETRIEVE_TYPE } from '@/types/app'
export type KnowledgeValidationIssueCode
= | 'name_required'
| 'name_duplicate'
| 'datasets_required'
| 'custom_query_required'
| 'single_model_required'
| 'metadata_model_required'
| 'metadata_conditions_required'
export type KnowledgeValidationField = 'name' | 'datasets' | 'query' | 'retrieval' | 'metadata'
export type KnowledgeValidationIssue = {
itemId: string
code: KnowledgeValidationIssueCode
field: KnowledgeValidationField
}
export type KnowledgeValidationResult = {
byId: Record<string, Partial<Record<KnowledgeValidationField, KnowledgeValidationIssueCode>>>
firstIssue?: KnowledgeValidationIssue
isValid: boolean
}
const getKnowledgeRetrievalConcreteName = (item: AgentKnowledgeRetrievalItem) => item.name?.trim() ?? ''
export const getKnowledgeRetrievalSetName = (item: AgentKnowledgeRetrievalItem) =>
getKnowledgeRetrievalConcreteName(item) || item.id
export const useKnowledgeValidationMessage = () => {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const { t: tAppDebug } = useTranslation('appDebug')
const { t: tWorkflow } = useTranslation('workflow')
return (issueCode?: KnowledgeValidationIssueCode) => {
switch (issueCode) {
case 'name_required':
return tCommon('errorMsg.fieldRequired', {
field: t('agentDetail.configure.knowledgeRetrieval.dialog.nameLabel'),
})
case 'name_duplicate':
return tAppDebug('varKeyError.keyAlreadyExists', {
key: t('agentDetail.configure.knowledgeRetrieval.dialog.nameLabel'),
})
case 'datasets_required':
return tCommon('errorMsg.fieldRequired', {
field: t('agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label'),
})
case 'custom_query_required':
return tCommon('errorMsg.fieldRequired', {
field: t('agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel'),
})
case 'single_model_required':
case 'metadata_model_required':
return tCommon('errorMsg.fieldRequired', {
field: tCommon('modelProvider.systemReasoningModel.key'),
})
case 'metadata_conditions_required':
return tCommon('errorMsg.fieldRequired', {
field: tWorkflow('nodes.knowledgeRetrieval.metadata.panel.conditions'),
})
default:
return undefined
}
}
}
const getKnowledgeDatasetCount = (item: AgentKnowledgeRetrievalItem) =>
item.selectedDatasets?.length ?? item.datasetRefs?.length ?? 0
const getNormalizedKnowledgeName = (item: AgentKnowledgeRetrievalItem) => getKnowledgeRetrievalConcreteName(item).toLowerCase()
export const validateKnowledgeRetrievals = (
retrievals: AgentKnowledgeRetrievalItem[],
): KnowledgeValidationResult => {
const byId: KnowledgeValidationResult['byId'] = {}
const issues: KnowledgeValidationIssue[] = []
const nameCounts = new Map<string, number>()
retrievals.forEach((item) => {
const normalizedName = getNormalizedKnowledgeName(item)
if (!normalizedName)
return
nameCounts.set(normalizedName, (nameCounts.get(normalizedName) ?? 0) + 1)
})
const pushIssue = (issue: KnowledgeValidationIssue) => {
byId[issue.itemId] ??= {}
const itemIssues = byId[issue.itemId]
if (itemIssues)
itemIssues[issue.field] ??= issue.code
issues.push(issue)
}
retrievals.forEach((item) => {
const setName = getKnowledgeRetrievalConcreteName(item)
const normalizedName = setName.toLowerCase()
if (!setName) {
pushIssue({
itemId: item.id,
code: 'name_required',
field: 'name',
})
}
else if ((nameCounts.get(normalizedName) ?? 0) > 1) {
pushIssue({
itemId: item.id,
code: 'name_duplicate',
field: 'name',
})
}
if (!getKnowledgeDatasetCount(item)) {
pushIssue({
itemId: item.id,
code: 'datasets_required',
field: 'datasets',
})
}
if (item.queryMode === 'custom' && !item.customQuery?.trim()) {
pushIssue({
itemId: item.id,
code: 'custom_query_required',
field: 'query',
})
}
if (
item.retrievalMode === RETRIEVE_TYPE.oneWay
&& (!item.singleRetrievalConfig?.model?.provider || !item.singleRetrievalConfig.model.name)
) {
pushIssue({
itemId: item.id,
code: 'single_model_required',
field: 'retrieval',
})
}
if (
item.metadataFilterMode === MetadataFilteringModeEnum.automatic
&& (!item.metadataModelConfig?.provider || !item.metadataModelConfig.name)
) {
pushIssue({
itemId: item.id,
code: 'metadata_model_required',
field: 'metadata',
})
}
if (
item.metadataFilterMode === MetadataFilteringModeEnum.manual
&& !item.metadataFilteringConditions?.conditions.length
) {
pushIssue({
itemId: item.id,
code: 'metadata_conditions_required',
field: 'metadata',
})
}
})
return {
byId,
firstIssue: issues[0],
isValid: issues.length === 0,
}
}
+2 -39
View File
@@ -1,10 +1,9 @@
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import type { AgentSoulConfigFormState } from './form-state'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
import isEqual from 'fast-deep-equal'
import { atom, useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useMemo, useRef } from 'react'
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from './conversions'
import { useEffect, useRef } from 'react'
import { agentSoulConfigToFormState } from './conversions'
import { defaultAgentSoulConfigFormState } from './form-state'
export const agentComposerOriginalConfigAtom = atom<AgentSoulConfig | undefined>(undefined)
@@ -94,39 +93,3 @@ export function useHydrateAgentSoulConfigDraft({
export function useHasAgentComposerUnpublishedChanges() {
return useAtomValue(hasAgentComposerUnpublishedChangesAtom)
}
export function useAgentComposerConfigSnapshot({
baseConfig,
currentModel,
}: {
baseConfig?: AgentSoulConfig
currentModel?: DefaultModel
}) {
const draft = useAtomValue(agentComposerDraftAtom)
return useMemo(() => formStateToAgentSoulConfig({
baseConfig,
formState: draft,
currentModel,
}), [baseConfig, currentModel, draft])
}
export function useConfigPublishPayload({
agentId,
baseConfig,
currentModel,
}: {
agentId: string
baseConfig?: AgentSoulConfig
currentModel?: DefaultModel
}) {
const configSnapshot = useAgentComposerConfigSnapshot({
baseConfig,
currentModel,
})
return useMemo(() => ({
agent_id: agentId,
config_snapshot: configSnapshot,
}), [agentId, configSnapshot])
}
@@ -0,0 +1,68 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessSurfaceCard } from '../access-surface-card'
const mockCopy = vi.fn()
let mockCopied = false
vi.mock('foxact/use-clipboard', () => ({
useClipboard: () => ({
copied: mockCopied,
copy: mockCopy,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
describe('AccessSurfaceCard', () => {
beforeEach(() => {
mockCopied = false
vi.clearAllMocks()
})
describe('Copy feedback', () => {
it('should copy the endpoint and render copied state from the clipboard hook', async () => {
const user = userEvent.setup()
const { rerender } = render(
<AccessSurfaceCard
title="Web app"
icon="i-ri-window-line"
iconClassName="bg-state-accent-solid"
endpointLabel="Access URL"
endpoint="https://chat.example.test/agent/token"
enabled
onEnabledChange={vi.fn()}
copyLabel="Copy access URL"
>
<button type="button">Action</button>
</AccessSurfaceCard>,
)
await user.click(screen.getByRole('button', { name: 'Copy access URL' }))
expect(mockCopy).toHaveBeenCalledWith('https://chat.example.test/agent/token')
mockCopied = true
rerender(
<AccessSurfaceCard
title="Web app"
icon="i-ri-window-line"
iconClassName="bg-state-accent-solid"
endpointLabel="Access URL"
endpoint="https://chat.example.test/agent/token"
enabled
onEnabledChange={vi.fn()}
copyLabel="Copy access URL"
>
<button type="button">Action</button>
</AccessSurfaceCard>,
)
expect(screen.getByRole('button', { name: 'common.operation.copied' })).toBeInTheDocument()
})
})
})
@@ -0,0 +1,364 @@
import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen'
import type React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ServiceApiAccessCard } from '../service-api-access-card'
import { WebAppAccessCard } from '../web-app-access-card'
const mocks = vi.hoisted(() => ({
apiAccessQueryFn: vi.fn(),
apiKeysQueryFn: vi.fn(),
siteEnableMutation: vi.fn(),
siteAccessTokenResetMutation: vi.fn(),
apiEnableMutation: vi.fn(),
createApiKeyMutation: vi.fn(),
deleteApiKeyMutation: vi.fn(),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test${path}`,
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
formatTime: (value: number) => `formatted-${value}`,
}),
}))
vi.mock('@/app/components/base/chat/embedded-chatbot/theme/theme-context', () => ({
useThemeContext: () => ({
buildTheme: vi.fn(),
theme: {
primaryColor: '#1C64F2',
},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
langGeniusVersionInfo: {
current_env: 'PRODUCTION',
},
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
siteEnable: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.siteEnableMutation,
...options,
}),
},
},
site: {
accessTokenReset: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.siteAccessTokenResetMutation,
...options,
}),
},
},
},
},
},
agent: {
byAgentId: {
get: {
queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-detail', input.params.agent_id],
},
apiAccess: {
get: {
queryKey: ({ input }: { input: { params: { agent_id: string } } }) => ['agent-api-access', input.params.agent_id],
queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({
queryKey: ['agent-api-access', input.params.agent_id],
queryFn: () => mocks.apiAccessQueryFn(input),
}),
},
},
apiEnable: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.apiEnableMutation,
...options,
}),
},
},
apiKeys: {
get: {
queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({
queryKey: ['agent-api-keys', input.params.agent_id],
queryFn: () => mocks.apiKeysQueryFn(input),
}),
},
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.createApiKeyMutation,
...options,
}),
},
byApiKeyId: {
delete: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.deleteApiKeyMutation,
...options,
}),
},
},
},
},
},
},
}))
function createAgent(overrides: Partial<AgentAppDetailWithSite> = {}): AgentAppDetailWithSite {
return {
enable_api: true,
enable_site: true,
icon_url: null,
id: 'agent-1',
mode: 'agent',
name: 'Support Agent',
app_id: 'app-1',
api_base_url: 'https://api.example.test/v1',
access_mode: 'sso_verified',
site: {
access_token: 'site-token',
app_base_url: 'https://chat.example.test',
chat_color_theme_inverted: false,
default_language: 'en-US',
icon_url: null,
show_workflow_steps: false,
title: 'Support Agent',
use_icon_as_answer_icon: false,
} as NonNullable<AgentAppDetailWithSite['site']> & {
access_token: string
app_base_url: string
},
...overrides,
}
}
function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
)
return queryClient
}
describe('Agent access surface cards', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Web app access', () => {
it('should render the backend web app URL and toggle site status through the backing app id', async () => {
const user = userEvent.setup()
mocks.siteEnableMutation.mockResolvedValueOnce({ enable_site: false })
renderWithQueryClient(
<WebAppAccessCard agent={createAgent()} agentId="agent-1" isLoading={false} />,
)
expect(screen.getByText('https://chat.example.test/agent/site-token')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'agentV2.agentDetail.access.webApp.actions.launch' })).toHaveAttribute('href', 'https://chat.example.test/agent/site-token')
expect(screen.getByText('agentV2.agentDetail.access.webApp.ssoEnabled')).toBeInTheDocument()
await user.click(screen.getByRole('switch', { name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.webApp.title"}' }))
await waitFor(() => {
expect(mocks.siteEnableMutation.mock.calls[0]?.[0]).toEqual({
params: {
app_id: 'app-1',
},
body: {
enable_site: false,
},
})
})
})
it('should open the customize dialog with the backing app id and API base URL', async () => {
const user = userEvent.setup()
renderWithQueryClient(
<WebAppAccessCard agent={createAgent()} agentId="agent-1" isLoading={false} />,
)
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.customize' }))
const dialog = await screen.findByRole('dialog', { name: 'appOverview.overview.appInfo.customize.title' })
expect(dialog).toHaveTextContent(/NEXT_PUBLIC_APP_ID=\s*'app-1'/)
expect(dialog).toHaveTextContent(/NEXT_PUBLIC_API_URL=\s*'https:\/\/api\.example\.test\/v1'/)
expect(within(dialog).getByRole('button', { name: /appOverview\.overview\.appInfo\.customize\.way1\.step1Operation/ })).toHaveAttribute('href', 'https://github.com/langgenius/webapp-conversation')
})
it('should open the embedded dialog with the Agent web app route', async () => {
const user = userEvent.setup()
renderWithQueryClient(
<WebAppAccessCard agent={createAgent()} agentId="agent-1" isLoading={false} />,
)
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.embedded' }))
const dialog = await screen.findByRole('dialog', { name: 'appOverview.overview.appInfo.embedded.title' })
await waitFor(() => {
expect(dialog).toHaveTextContent('https://chat.example.test/agent/site-token')
})
await user.click(within(dialog).getByRole('button', { name: 'appOverview.overview.appInfo.embedded.scripts' }))
await waitFor(() => {
expect(dialog).toHaveTextContent('routeSegment: \'agent\'')
})
})
it('should keep embedded disabled until the backing app id and web app token are available', () => {
renderWithQueryClient(
<WebAppAccessCard
agent={createAgent({
app_id: null,
site: {
...createAgent().site!,
access_token: null,
code: null,
},
})}
agentId="agent-1"
isLoading={false}
/>,
)
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.embedded' })).toBeDisabled()
})
it('should keep customize disabled until the generated contract provides the required fields', () => {
renderWithQueryClient(
<WebAppAccessCard agent={createAgent({ api_base_url: null })} agentId="agent-1" isLoading={false} />,
)
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.customize' })).toBeDisabled()
})
})
describe('Service API access', () => {
it('should render service API data and toggle Agent API status through the generated Agent endpoint', async () => {
const user = userEvent.setup()
mocks.apiAccessQueryFn.mockResolvedValueOnce({
api_key_count: 2,
enabled: true,
service_api_base_url: 'https://api.example.test/v1',
})
mocks.apiEnableMutation.mockResolvedValueOnce({
api_key_count: 2,
enabled: false,
service_api_base_url: 'https://api.example.test/v1',
})
renderWithQueryClient(<ServiceApiAccessCard agentId="agent-1" />)
expect(await screen.findByText('https://api.example.test/v1')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
await user.click(screen.getByRole('switch', { name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.serviceApi.title"}' }))
await waitFor(() => {
expect(mocks.apiEnableMutation.mock.calls[0]?.[0]).toEqual({
params: {
agent_id: 'agent-1',
},
body: {
enable_api: false,
},
})
})
})
it('should manage API keys with the Agent API key endpoints', async () => {
const user = userEvent.setup()
mocks.apiAccessQueryFn.mockResolvedValue({
api_key_count: 1,
enabled: true,
service_api_base_url: 'https://api.example.test/v1',
})
mocks.apiKeysQueryFn.mockResolvedValue({
data: [
{
created_at: 1781660000,
id: 'key-1',
last_used_at: null,
token: 'app-existing-secret-key-token',
type: 'app',
},
],
})
mocks.createApiKeyMutation.mockResolvedValueOnce({
created_at: 1781660100,
id: 'key-2',
last_used_at: null,
token: 'app-new-secret-key-token',
type: 'app',
})
mocks.deleteApiKeyMutation.mockResolvedValueOnce(undefined)
renderWithQueryClient(<ServiceApiAccessCard agentId="agent-1" />)
await user.click(await screen.findByRole('button', { name: /agentV2\.agentDetail\.access\.serviceApi\.actions\.apiKey/ }))
const dialog = await screen.findByRole('dialog', { name: 'appApi.apiKeyModal.apiSecretKey' })
expect(await within(dialog).findByText('app...ing-secret-key-token')).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'appApi.apiKeyModal.createNewSecretKey' }))
await waitFor(() => {
expect(mocks.createApiKeyMutation.mock.calls[0]?.[0]).toEqual({
params: {
agent_id: 'agent-1',
},
})
})
expect(await screen.findByText('appApi.apiKeyModal.generateTips')).toBeInTheDocument()
expect(screen.getByText('app-new-secret-key-token')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'appApi.actionMsg.ok' }))
await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => {
expect(mocks.deleteApiKeyMutation.mock.calls[0]?.[0]).toEqual({
params: {
agent_id: 'agent-1',
api_key_id: 'key-1',
},
})
})
})
})
})
@@ -97,8 +97,10 @@ describe('WorkflowReferencesTable', () => {
expect(screen.getByText('v3')).toBeInTheDocument()
expect(screen.getByText('agentV2.agentDetail.access.workflow.nodeCount:{"count":2}')).toBeInTheDocument()
expect(screen.getByText('formatted-1781660000')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'agentV2.agentDetail.access.workflow.openInStudioFor:{"name":"Support Workflow"}' }))
.toHaveAttribute('href', '/app/workflow-app-id/workflow')
const studioLink = screen.getByRole('link', { name: 'agentV2.agentDetail.access.workflow.openInStudioFor:{"name":"Support Workflow"}' })
expect(studioLink).toHaveAttribute('href', '/app/workflow-app-id/workflow')
expect(studioLink).toHaveAttribute('target', '_blank')
expect(studioLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render an empty state when the agent has no workflow references', async () => {
@@ -0,0 +1,124 @@
'use client'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { useClipboard } from 'foxact/use-clipboard'
import { useTranslation } from 'react-i18next'
export type AccessSurfaceCardProps = {
title: string
icon: string
iconClassName: string
endpointLabel: string
endpoint: string
enabled: boolean
onEnabledChange: (enabled: boolean) => void
copyLabel: string
children: ReactNode
badge?: ReactNode
endpointActions?: ReactNode
disabled?: boolean
busy?: boolean
}
export const accessSurfaceActionClassName = 'inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-[13px] leading-4 font-medium text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
export function AccessSurfaceCard({
title,
icon,
iconClassName,
endpointLabel,
endpoint,
enabled,
onEnabledChange,
copyLabel,
children,
badge,
endpointActions,
disabled = false,
busy = false,
}: AccessSurfaceCardProps) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const { copied, copy } = useClipboard({
timeout: 2000,
onCopyError: () => {
toast.error(t('agentDetail.access.copyFailed'))
},
})
const canCopyEndpoint = Boolean(endpoint)
const switchDisabled = disabled || busy
const handleCopyEndpoint = () => {
if (!canCopyEndpoint)
return
void copy(endpoint)
}
return (
<article className="rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs">
<div className="px-4 pt-4 pb-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn('flex size-6 shrink-0 items-center justify-center rounded-lg', iconClassName)}>
<span aria-hidden className={cn(icon, 'size-4')} />
</span>
<h3 className="truncate system-md-semibold text-text-secondary">
{title}
</h3>
{badge}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className={cn(
'inline-flex items-center gap-1 system-xs-semibold-uppercase',
enabled ? 'text-util-colors-green-green-700' : 'text-text-tertiary',
)}
>
<StatusDot status={enabled ? 'success' : 'disabled'} size="small" />
{t(enabled ? 'agentDetail.access.status.inService' : 'agentDetail.access.status.outOfService')}
</span>
<Switch
size="md"
checked={enabled}
disabled={switchDisabled}
aria-label={t('agentDetail.access.toggleSurface', { name: title })}
onCheckedChange={onEnabledChange}
/>
</div>
</div>
<div className="mt-3">
<div className="system-xs-medium text-text-tertiary">
{endpointLabel}
</div>
<div className="mt-1 flex h-8 min-w-0 items-center rounded-lg bg-components-input-bg-normal px-2">
<span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary" translate="no">
{endpoint || t('agentDetail.access.workflow.notAvailable')}
</span>
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={copied ? tCommon('operation.copied') : copyLabel}
disabled={!canCopyEndpoint}
onClick={handleCopyEndpoint}
>
<span aria-hidden className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-4')} />
</Button>
{endpointActions}
</div>
</div>
</div>
<div className="flex min-h-16 flex-wrap items-center gap-2 border-t border-divider-subtle px-4 py-4">
{children}
</div>
</article>
)
}
@@ -0,0 +1,283 @@
'use client'
import type { ApiKeyItem } from '@dify/contracts/api/console/agent/types.gen'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CopyFeedback from '@/app/components/base/copy-feedback'
import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/client'
export function AgentApiKeyModal({
agentId,
open,
onOpenChange,
}: {
agentId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation('appApi')
const { t: tCommon } = useTranslation('common')
const { formatTime } = useTimestamp()
const queryClient = useQueryClient()
const [newKey, setNewKey] = useState<ApiKeyItem | null>(null)
const [apiKeyToDelete, setApiKeyToDelete] = useState<ApiKeyItem | null>(null)
const apiKeysQueryOptions = consoleQuery.agent.byAgentId.apiKeys.get.queryOptions({
input: {
params: {
agent_id: agentId,
},
},
})
const apiKeysQuery = useQuery({
...apiKeysQueryOptions,
enabled: open,
})
const createApiKeyMutation = useMutation(consoleQuery.agent.byAgentId.apiKeys.post.mutationOptions({
onSuccess: (createdKey) => {
setNewKey(createdKey)
queryClient.invalidateQueries({ queryKey: apiKeysQueryOptions.queryKey })
queryClient.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.apiAccess.get.queryKey({
input: {
params: {
agent_id: agentId,
},
},
}),
})
toast.success(tCommon('actionMsg.modifiedSuccessfully'))
},
onError: () => {
toast.error(tCommon('actionMsg.modifiedUnsuccessfully'))
},
}))
const deleteApiKeyMutation = useMutation(consoleQuery.agent.byAgentId.apiKeys.byApiKeyId.delete.mutationOptions({
onSuccess: () => {
setApiKeyToDelete(null)
queryClient.invalidateQueries({ queryKey: apiKeysQueryOptions.queryKey })
queryClient.invalidateQueries({
queryKey: consoleQuery.agent.byAgentId.apiAccess.get.queryKey({
input: {
params: {
agent_id: agentId,
},
},
}),
})
toast.success(tCommon('actionMsg.modifiedSuccessfully'))
},
onError: () => {
toast.error(tCommon('actionMsg.modifiedUnsuccessfully'))
},
}))
const apiKeys = apiKeysQuery.data?.data ?? []
const isCreating = createApiKeyMutation.isPending
const isDeleting = deleteApiKeyMutation.isPending
function handleCreateApiKey() {
createApiKeyMutation.mutate({
params: {
agent_id: agentId,
},
})
}
function handleDeleteApiKey() {
if (!apiKeyToDelete)
return
deleteApiKeyMutation.mutate({
params: {
agent_id: agentId,
api_key_id: apiKeyToDelete.id,
},
})
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) {
setNewKey(null)
setApiKeyToDelete(null)
}
onOpenChange(nextOpen)
}
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="flex w-full max-w-[800px]! flex-col overflow-hidden px-8">
<DialogCloseButton />
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('apiKeyModal.apiSecretKey')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('apiKeyModal.apiSecretKeyTips')}
</DialogDescription>
<div className="mt-4 min-h-20 overflow-hidden">
<div className="flex h-9 shrink-0 items-center border-b border-divider-regular text-xs font-semibold text-text-tertiary">
<div className="w-64 shrink-0 px-3">{t('apiKeyModal.secretKey')}</div>
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.created')}</div>
<div className="w-[200px] shrink-0 px-3">{t('apiKeyModal.lastUsed')}</div>
<div className="grow px-3" />
</div>
<div className="max-h-[280px] overflow-auto">
{apiKeysQuery.isPending && (
<div role="status" className="flex h-20 items-center justify-center system-sm-regular text-text-tertiary">
{t('loading')}
</div>
)}
{apiKeysQuery.isError && (
<div className="flex h-20 items-center justify-center gap-2 system-sm-regular text-text-tertiary">
<span>{tCommon('api.actionFailed')}</span>
<Button
variant="secondary"
size="small"
onClick={() => {
void apiKeysQuery.refetch()
}}
>
{tCommon('operation.retry')}
</Button>
</div>
)}
{apiKeysQuery.isSuccess && apiKeys.length === 0 && (
<div className="flex h-20 items-center justify-center system-sm-regular text-text-tertiary">
{tCommon('noData')}
</div>
)}
{apiKeysQuery.isSuccess && apiKeys.map(apiKey => (
<div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary last:border-b-0" key={apiKey.id}>
<div className="w-64 shrink-0 truncate px-3 font-mono" translate="no">
{maskApiKey(apiKey.token)}
</div>
<div className="w-[200px] shrink-0 truncate px-3">
{apiKey.created_at ? formatTime(apiKey.created_at, t('dateTimeFormat', { ns: 'appLog' })) : t('never')}
</div>
<div className="w-[200px] shrink-0 truncate px-3">
{apiKey.last_used_at ? formatTime(apiKey.last_used_at, t('dateTimeFormat', { ns: 'appLog' })) : t('never')}
</div>
<div className="flex grow gap-2 px-3">
<CopyFeedback content={apiKey.token} />
<Button
variant="ghost"
size="small"
className="size-6 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={tCommon('operation.delete')}
disabled={isDeleting}
onClick={() => setApiKeyToDelete(apiKey)}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div className="mt-4 flex justify-start">
<Button onClick={handleCreateApiKey} loading={isCreating}>
<span aria-hidden className="mr-1 i-heroicons-plus-20-solid size-4" />
{t('apiKeyModal.createNewSecretKey')}
</Button>
</div>
</DialogContent>
</Dialog>
<AgentApiKeyGenerateModal
apiKey={newKey}
onClose={() => setNewKey(null)}
/>
<AlertDialog
open={Boolean(apiKeyToDelete)}
onOpenChange={(nextOpen) => {
if (!nextOpen)
setApiKeyToDelete(null)
}}
>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('actionMsg.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('actionMsg.deleteConfirmTips')}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>
{tCommon('operation.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isDeleting} onClick={handleDeleteApiKey}>
{tCommon('operation.confirm')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function AgentApiKeyGenerateModal({
apiKey,
onClose,
}: {
apiKey: ApiKeyItem | null
onClose: () => void
}) {
const { t } = useTranslation('appApi')
return (
<Dialog
open={Boolean(apiKey)}
onOpenChange={(nextOpen) => {
if (!nextOpen)
onClose()
}}
>
<DialogContent className="w-full max-w-[480px]! overflow-hidden px-8">
<DialogCloseButton />
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{t('apiKeyModal.apiSecretKey')}
</DialogTitle>
<DialogDescription className="mt-1 text-[13px] leading-5 font-normal text-text-tertiary">
{t('apiKeyModal.generateTips')}
</DialogDescription>
<div className="my-4 flex h-9 min-w-0 items-center rounded-lg bg-components-input-bg-normal px-2">
<span className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary" translate="no">
{apiKey?.token}
</span>
{apiKey && <CopyFeedback content={apiKey.token} />}
</div>
<div className="my-4 flex justify-end">
<Button className="w-16 shrink-0" onClick={onClose}>
{t('actionMsg.ok')}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
function maskApiKey(token: string) {
if (token.length <= 24)
return token
return `${token.slice(0, 3)}...${token.slice(-20)}`
}
@@ -0,0 +1,123 @@
'use client'
import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { consoleQuery } from '@/service/client'
import { accessSurfaceActionClassName, AccessSurfaceCard } from './access-surface-card'
import { AgentApiKeyModal } from './agent-api-key-modal'
export function ServiceApiAccessCard({
agentId,
}: {
agentId: string
}) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const docLink = useDocLink()
const queryClient = useQueryClient()
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false)
const apiAccessQueryOptions = consoleQuery.agent.byAgentId.apiAccess.get.queryOptions({
input: {
params: {
agent_id: agentId,
},
},
})
const apiAccessQuery = useQuery(apiAccessQueryOptions)
const apiAccess = apiAccessQuery.data
const toggleServiceApiMutation = useMutation(consoleQuery.agent.byAgentId.apiEnable.post.mutationOptions({
onSuccess: (updatedApiAccess, variables) => {
queryClient.setQueryData(apiAccessQueryOptions.queryKey, updatedApiAccess)
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
agentDetail => agentDetail
? {
...agentDetail,
enable_api: variables.body.enable_api,
}
: agentDetail,
)
toast.success(tCommon('actionMsg.modifiedSuccessfully'))
},
onError: () => {
toast.error(tCommon('actionMsg.modifiedUnsuccessfully'))
},
}))
const isBusy = apiAccessQuery.isPending || toggleServiceApiMutation.isPending
function handleEnabledChange(enabled: boolean) {
toggleServiceApiMutation.mutate({
params: {
agent_id: agentId,
},
body: {
enable_api: enabled,
},
})
}
return (
<>
<AccessSurfaceCard
title={t('agentDetail.access.serviceApi.title')}
icon="i-ri-node-tree"
iconClassName="bg-state-accent-solid text-text-primary-on-surface"
endpointLabel={t('agentDetail.access.serviceApi.endpoint')}
endpoint={apiAccess?.service_api_base_url ?? ''}
enabled={Boolean(apiAccess?.enabled)}
onEnabledChange={handleEnabledChange}
copyLabel={t('agentDetail.access.copyServiceEndpoint')}
disabled={apiAccessQuery.isPending || apiAccessQuery.isError}
busy={toggleServiceApiMutation.isPending}
>
<Button
variant="secondary"
size="medium"
className="gap-1.5 px-3"
disabled={isBusy || apiAccessQuery.isError}
onClick={() => setApiKeyModalOpen(true)}
>
<span aria-hidden className="i-ri-key-2-line size-4" />
{t('agentDetail.access.serviceApi.actions.apiKey')}
<span className="rounded-md bg-components-badge-bg-gray-soft px-1.5 code-xs-regular text-text-tertiary">
{apiAccess?.api_key_count ?? 0}
</span>
</Button>
<a
href={docLink('/use-dify/publish/developing-with-apis')}
target="_blank"
rel="noreferrer"
aria-label={t('agentDetail.access.serviceApi.actions.apiReference')}
className={accessSurfaceActionClassName}
>
<span aria-hidden className="i-ri-book-open-line size-4" />
{t('agentDetail.access.serviceApi.actions.apiReference')}
</a>
{apiAccessQuery.isError && (
<Button
variant="secondary"
size="medium"
className="gap-1.5 px-3"
onClick={() => {
void apiAccessQuery.refetch()
}}
>
<span aria-hidden className="i-ri-refresh-line size-4" />
{tCommon('operation.retry')}
</Button>
)}
</AccessSurfaceCard>
<AgentApiKeyModal
agentId={agentId}
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
/>
</>
)
}
@@ -0,0 +1,241 @@
'use client'
import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import CustomizeModal from '@/app/components/app/overview/customize'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import ShareQRCode from '@/app/components/base/qrcode'
import { AccessMode } from '@/models/access-control'
import { consoleQuery } from '@/service/client'
import { accessSurfaceActionClassName, AccessSurfaceCard } from './access-surface-card'
export function WebAppAccessCard({
agent,
agentId,
isLoading,
}: {
agent?: AgentAppDetailWithSite
agentId: string
isLoading: boolean
}) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const queryClient = useQueryClient()
const appId = agent?.app_id
const apiBaseUrl = agent?.api_base_url
const site = agent?.site
const accessToken = site?.access_token ?? site?.code
const appBaseUrl = site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin)
const webAppUrl = getAgentWebAppUrl(agent)
const isEnabled = Boolean(agent?.enable_site)
const canManageWebApp = Boolean(appId)
const embeddedConfig = appId && accessToken
? {
accessToken,
appBaseUrl,
siteInfo: {
title: site?.title ?? agent?.name ?? '',
chat_color_theme: site?.chat_color_theme ?? undefined,
chat_color_theme_inverted: site?.chat_color_theme_inverted ?? undefined,
},
}
: null
const customizeConfig = appId && apiBaseUrl
? {
apiBaseUrl,
appId,
}
: null
const showSsoBadge = agent?.access_mode === AccessMode.EXTERNAL_MEMBERS
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
const [showEmbeddedModal, setShowEmbeddedModal] = useState(false)
const toggleSiteMutation = useMutation(consoleQuery.apps.byAppId.siteEnable.post.mutationOptions({
onSuccess: (_updatedApp, variables) => {
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
agentDetail => agentDetail
? {
...agentDetail,
enable_site: variables.body.enable_site,
}
: agentDetail,
)
toast.success(tCommon('actionMsg.modifiedSuccessfully'))
},
onError: () => {
toast.error(tCommon('actionMsg.modifiedUnsuccessfully'))
},
}))
const resetAccessTokenMutation = useMutation(consoleQuery.apps.byAppId.site.accessTokenReset.post.mutationOptions({
onSuccess: (site) => {
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
(agentDetail) => {
if (!agentDetail || !agentDetail.site)
return agentDetail
return {
...agentDetail,
site: {
...agentDetail.site,
...site,
access_token: site.code,
},
}
},
)
toast.success(tCommon('actionMsg.generatedSuccessfully'))
},
onError: () => {
toast.error(tCommon('actionMsg.generatedUnsuccessfully'))
},
}))
const isBusy = toggleSiteMutation.isPending || resetAccessTokenMutation.isPending
function handleEnabledChange(enabled: boolean) {
if (!appId)
return
toggleSiteMutation.mutate({
params: {
app_id: appId,
},
body: {
enable_site: enabled,
},
})
}
function handleRefreshUrl() {
if (!appId)
return
resetAccessTokenMutation.mutate({
params: {
app_id: appId,
},
})
}
return (
<AccessSurfaceCard
title={t('agentDetail.access.webApp.title')}
icon="i-ri-window-line"
iconClassName="bg-state-accent-solid text-text-primary-on-surface"
endpointLabel={t('agentDetail.access.webApp.accessUrl')}
endpoint={webAppUrl}
enabled={isEnabled}
onEnabledChange={handleEnabledChange}
copyLabel={t('agentDetail.access.copyAccessUrl')}
badge={showSsoBadge ? <SsoBadge /> : undefined}
endpointActions={webAppUrl
? (
<>
<span className="mx-1.5 h-3.5 w-px shrink-0 bg-divider-regular" />
<ShareQRCode content={webAppUrl} />
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={t('agentDetail.access.webApp.refreshUrl')}
disabled={!canManageWebApp || isBusy}
onClick={handleRefreshUrl}
>
<span aria-hidden className="i-ri-refresh-line size-4" />
</Button>
</>
)
: undefined}
disabled={isLoading || !canManageWebApp}
busy={isBusy}
>
{webAppUrl && isEnabled
? (
<a
href={webAppUrl}
target="_blank"
rel="noreferrer"
aria-label={t('agentDetail.access.webApp.actions.launch')}
className={accessSurfaceActionClassName}
>
<span aria-hidden className="i-ri-external-link-line size-4" />
{t('agentDetail.access.webApp.actions.launch')}
</a>
)
: (
<Button variant="secondary" size="medium" className="gap-1.5 px-3" disabled>
<span aria-hidden className="i-ri-external-link-line size-4" />
{t('agentDetail.access.webApp.actions.launch')}
</Button>
)}
<Button
variant="secondary"
size="medium"
className="gap-1.5 px-3"
disabled={!embeddedConfig}
onClick={() => setShowEmbeddedModal(true)}
>
<span aria-hidden className="i-ri-window-line size-4" />
{t('agentDetail.access.webApp.actions.embedded')}
</Button>
<Button
variant="secondary"
size="medium"
className="gap-1.5 px-3"
disabled={!customizeConfig}
onClick={() => setShowCustomizeModal(true)}
>
<span aria-hidden className="i-ri-paint-brush-line size-4" />
{t('agentDetail.access.webApp.actions.customize')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" disabled>
<span aria-hidden className="i-ri-equalizer-2-line size-4" />
{t('agentDetail.access.webApp.actions.settings')}
</Button>
{customizeConfig && (
<CustomizeModal
isShow={showCustomizeModal}
onClose={() => setShowCustomizeModal(false)}
appId={customizeConfig.appId}
api_base_url={customizeConfig.apiBaseUrl}
sourceCodeRepository="webapp-conversation"
/>
)}
{embeddedConfig && (
<EmbeddedModal
isShow={showEmbeddedModal}
onClose={() => setShowEmbeddedModal(false)}
appBaseUrl={embeddedConfig.appBaseUrl}
accessToken={embeddedConfig.accessToken}
siteInfo={embeddedConfig.siteInfo}
webAppRoute="agent"
/>
)}
</AccessSurfaceCard>
)
}
function getAgentWebAppUrl(agent?: AgentAppDetailWithSite) {
const site = agent?.site
const token = site?.access_token ?? site?.code
if (!token)
return ''
const baseUrl = site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin)
return `${baseUrl.replace(/\/$/, '')}/agent/${token}`
}
function SsoBadge() {
const { t } = useTranslation('agentV2')
return (
<span className="inline-flex h-4.5 shrink-0 items-center gap-1 rounded-sm border border-divider-deep px-1.5 system-2xs-semibold-uppercase text-text-tertiary">
<span aria-hidden className="i-ri-shield-check-line size-3" />
{t('agentDetail.access.webApp.ssoEnabled')}
</span>
)
}
@@ -145,6 +145,8 @@ function WorkflowAccessRow({
<td className="px-3">
<Link
href={getWorkflowReferenceHref(reference)}
target="_blank"
rel="noopener noreferrer"
aria-label={t('agentDetail.access.workflow.openInStudioFor', { name: reference.app_name })}
className="inline-flex items-center gap-0.5 rounded-sm text-text-secondary hover:text-text-accent hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
@@ -1,57 +1,18 @@
'use client'
import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery } from '@tanstack/react-query'
import { useClipboard } from 'foxact/use-clipboard'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
import { consoleQuery } from '@/service/client'
import { ServiceApiAccessCard } from './components/service-api-access-card'
import { WebAppAccessCard } from './components/web-app-access-card'
import { WorkflowReferencesTable } from './components/workflow-references-table'
type AgentAccessPageProps = {
agentId: string
}
const serviceApiEndpoint = 'https://api.dify.ai/v1'
type AgentWebAppSite = NonNullable<AgentAppDetailWithSite['site']> & {
access_token?: string | null
app_base_url?: string | null
code?: string | null
}
type AccessSurfaceCardProps = {
title: string
icon: string
iconClassName: string
endpointLabel: string
endpoint: string
enabled: boolean
onEnabledChange: (enabled: boolean) => void
copyLabel: string
children: ReactNode
badge?: ReactNode
onUnavailableAction: () => void
}
const getAgentWebAppUrl = (agent?: AgentAppDetailWithSite) => {
const site = agent?.site as AgentWebAppSite | null | undefined
const token = site?.access_token ?? site?.code
if (!token)
return ''
const baseUrl = site?.app_base_url || (typeof window === 'undefined' ? '' : window.location.origin)
return `${baseUrl.replace(/\/$/, '')}/agent/${token}`
}
export function AgentAccessPage({
agentId,
}: AgentAccessPageProps) {
@@ -64,12 +25,6 @@ export function AgentAccessPage({
},
},
}))
const [isWebAppEnabled, setIsWebAppEnabled] = useState(true)
const [isServiceApiEnabled, setIsServiceApiEnabled] = useState(true)
const webAppUrl = getAgentWebAppUrl(agentQuery.data)
const handleUnavailableAction = () => {
toast.info(t('agentDetail.access.actionUnavailable'))
}
return (
<section
@@ -104,71 +59,8 @@ export function AgentAccessPage({
>
<div className="w-full min-w-0 space-y-6">
<div className="grid w-full grid-cols-1 gap-3 xl:grid-cols-2">
<AccessSurfaceCard
title={t('agentDetail.access.webApp.title')}
icon="i-ri-window-line"
iconClassName="bg-state-accent-solid text-text-primary-on-surface"
endpointLabel={t('agentDetail.access.webApp.accessUrl')}
endpoint={webAppUrl}
enabled={isWebAppEnabled}
onEnabledChange={setIsWebAppEnabled}
copyLabel={t('agentDetail.access.copyAccessUrl')}
badge={<SsoBadge />}
onUnavailableAction={handleUnavailableAction}
>
<Button
variant="secondary"
size="medium"
nativeButton={false}
className="gap-1.5 px-3"
render={<a href={webAppUrl} target="_blank" rel="noreferrer" aria-label={t('agentDetail.access.webApp.actions.launch')} />}
>
<span aria-hidden className="i-ri-external-link-line size-4" />
{t('agentDetail.access.webApp.actions.launch')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" onClick={handleUnavailableAction}>
<span aria-hidden className="i-ri-window-line size-4" />
{t('agentDetail.access.webApp.actions.embedded')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" onClick={handleUnavailableAction}>
<span aria-hidden className="i-ri-paint-brush-line size-4" />
{t('agentDetail.access.webApp.actions.customize')}
</Button>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" onClick={handleUnavailableAction}>
<span aria-hidden className="i-ri-equalizer-line size-4" />
{t('agentDetail.access.webApp.actions.settings')}
</Button>
</AccessSurfaceCard>
<AccessSurfaceCard
title={t('agentDetail.access.serviceApi.title')}
icon="i-ri-node-tree"
iconClassName="bg-state-accent-solid text-text-primary-on-surface"
endpointLabel={t('agentDetail.access.serviceApi.endpoint')}
endpoint={serviceApiEndpoint}
enabled={isServiceApiEnabled}
onEnabledChange={setIsServiceApiEnabled}
copyLabel={t('agentDetail.access.copyServiceEndpoint')}
onUnavailableAction={handleUnavailableAction}
>
<Button variant="secondary" size="medium" className="gap-1.5 px-3" onClick={handleUnavailableAction}>
<span aria-hidden className="i-ri-key-2-line size-4" />
{t('agentDetail.access.serviceApi.actions.apiKey')}
<span className="rounded-md bg-components-badge-bg-gray-soft px-1.5 code-xs-regular text-text-tertiary">
21
</span>
</Button>
<Button
variant="secondary"
size="medium"
nativeButton={false}
className="gap-1.5 px-3"
render={<a href={docLink('/use-dify/publish/developing-with-apis')} target="_blank" rel="noreferrer" aria-label={t('agentDetail.access.serviceApi.actions.apiReference')} />}
>
<span aria-hidden className="i-ri-book-open-line size-4" />
{t('agentDetail.access.serviceApi.actions.apiReference')}
</Button>
</AccessSurfaceCard>
<WebAppAccessCard agent={agentQuery.data} agentId={agentId} isLoading={agentQuery.isPending} />
<ServiceApiAccessCard agentId={agentId} />
</div>
<section aria-labelledby="agent-workflow-access-title">
@@ -188,121 +80,3 @@ export function AgentAccessPage({
</section>
)
}
function AccessSurfaceCard({
title,
icon,
iconClassName,
endpointLabel,
endpoint,
enabled,
onEnabledChange,
copyLabel,
children,
badge,
onUnavailableAction,
}: AccessSurfaceCardProps) {
const { t } = useTranslation('agentV2')
const { copy } = useClipboard({
onCopyError: () => {
toast.error(t('agentDetail.access.copyFailed'))
},
})
const handleCopyEndpoint = () => {
void copy(endpoint)
}
return (
<article className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg shadow-xs">
<div className="px-4 pt-4 pb-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className={cn('flex size-6 shrink-0 items-center justify-center rounded-lg', iconClassName)}>
<span aria-hidden className={cn(icon, 'size-4')} />
</span>
<h3 className="truncate system-md-semibold text-text-secondary">
{title}
</h3>
{badge}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className={cn(
'inline-flex items-center gap-1 system-xs-semibold-uppercase',
enabled ? 'text-util-colors-green-green-700' : 'text-text-tertiary',
)}
>
<StatusDot status={enabled ? 'success' : 'disabled'} size="small" />
{t(enabled ? 'agentDetail.access.status.inService' : 'agentDetail.access.status.outOfService')}
</span>
<Switch
size="md"
checked={enabled}
aria-label={t('agentDetail.access.toggleSurface', { name: title })}
onCheckedChange={onEnabledChange}
/>
</div>
</div>
<div className="mt-3">
<div className="system-xs-medium text-text-tertiary">
{endpointLabel}
</div>
<div className="mt-1 flex h-8 min-w-0 items-center rounded-lg bg-components-input-bg-normal px-2">
<span className="min-w-0 flex-1 truncate system-sm-regular text-text-secondary" translate="no">
{endpoint}
</span>
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={copyLabel}
onClick={handleCopyEndpoint}
>
<span aria-hidden className="i-ri-file-copy-line size-4" />
</Button>
{badge !== undefined && badge !== null && (
<>
<span className="mx-1.5 h-3.5 w-px shrink-0 bg-divider-regular" />
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={t('agentDetail.access.webApp.showQrCode')}
onClick={onUnavailableAction}
>
<span aria-hidden className="i-ri-qr-code-line size-4" />
</Button>
<Button
variant="ghost"
size="small"
className="size-6 shrink-0 px-0 text-text-tertiary hover:text-text-secondary"
aria-label={t('agentDetail.access.webApp.refreshUrl')}
onClick={onUnavailableAction}
>
<span aria-hidden className="i-ri-refresh-line size-4" />
</Button>
</>
)}
</div>
</div>
</div>
<div className="flex min-h-16 flex-wrap items-center gap-2 border-t border-divider-subtle px-4 py-4">
{children}
</div>
</article>
)
}
function SsoBadge() {
const { t } = useTranslation('agentV2')
return (
<span className="inline-flex h-4.5 shrink-0 items-center gap-1 rounded-sm border border-divider-deep px-1.5 system-2xs-semibold-uppercase text-text-tertiary">
<span aria-hidden className="i-ri-shield-check-line size-3" />
{t('agentDetail.access.webApp.ssoEnabled')}
</span>
)
}
@@ -1,11 +1,13 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentConfigurePage } from '../page'
const mocks = vi.hoisted(() => ({
queryState: {
agent: {
data: {
debug_conversation_id: 'debug-conversation-old',
icon: 'agent',
icon_background: '#E0F2FE',
icon_type: 'emoji',
@@ -98,8 +100,20 @@ vi.mock('../components/orchestrate', () => ({
AgentOrchestratePanel: () => <div role="region" aria-label="orchestrate-panel" />,
}))
vi.mock('../components/preview/chat', () => ({
AgentPreviewChat: () => <div role="region" aria-label="preview-chat" />,
vi.mock('../components/preview/build-chat', () => ({
AgentBuildChat: () => (
<div role="region" aria-label="preview-chat">
build
</div>
),
}))
vi.mock('../components/preview/preview-chat', () => ({
AgentPreviewChat: () => (
<div role="region" aria-label="preview-chat">
preview
</div>
),
}))
vi.mock('../components/preview/chat-features-panel', () => ({
@@ -107,7 +121,21 @@ vi.mock('../components/preview/chat-features-panel', () => ({
}))
vi.mock('../components/preview/header', () => ({
AgentPreviewHeader: () => <div role="banner" />,
AgentPreviewHeader: (props: {
mode: 'build' | 'preview'
onModeChange: (mode: 'build' | 'preview') => void
onRestart: () => void
}) => (
<div>
<div>{props.mode}</div>
<button type="button" onClick={() => props.onModeChange('preview')}>
preview mode
</button>
<button type="button" onClick={props.onRestart}>
restart preview
</button>
</div>
),
}))
vi.mock('../components/preview/versions-panel', () => ({
@@ -125,6 +153,7 @@ describe('AgentConfigurePage', () => {
icon_background: '#E0F2FE',
icon_type: 'emoji',
name: 'Research Agent',
debug_conversation_id: 'debug-conversation-old',
},
isFetching: false,
isPending: false,
@@ -158,4 +187,29 @@ describe('AgentConfigurePage', () => {
expect(screen.queryByRole('region', { name: 'orchestrate-panel' })).not.toBeInTheDocument()
})
})
describe('Right panel mode', () => {
it('should render build mode by default and switch to preview mode', async () => {
const user = userEvent.setup()
const queryClient = new QueryClient()
mocks.queryState.composer = {
data: {},
isFetching: false,
isPending: false,
isSuccess: true,
}
render(
<QueryClientProvider client={queryClient}>
<AgentConfigurePage agentId="agent-1" />
</QueryClientProvider>,
)
expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('build')
await user.click(screen.getByRole('button', { name: 'preview mode' }))
expect(screen.getByRole('region', { name: 'preview-chat' })).toHaveTextContent('preview')
})
})
})

Some files were not shown because too many files have changed in this diff Show More