Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf7e849f1 | ||
|
|
a60a26d419 | ||
|
|
b24b49178a | ||
|
|
5bb8658b0d | ||
|
|
a656e514aa | ||
|
|
c0d1e85698 | ||
|
|
fe274fc28c | ||
|
|
63e91b40ae | ||
|
|
dbb7e36ab9 | ||
|
|
4ddc9ceb5c | ||
|
|
01c138e7a9 | ||
|
|
71df1572b7 | ||
|
|
5d0ec54e8c | ||
|
|
ac80d95aed | ||
|
|
b1feacca6b | ||
|
|
7ef6c13d0d | ||
|
|
673d84073b | ||
|
|
3d24e21709 | ||
|
|
f11b09abf1 | ||
|
|
238b9202d6 | ||
|
|
573f9de17a | ||
|
|
d6e902e5a7 | ||
|
|
bf5caeb9f1 | ||
|
|
1e17c0f314 | ||
|
|
73c9017e63 | ||
|
|
6dd394edee | ||
|
|
53b59eea8b | ||
|
|
c9a3fa9a45 | ||
|
|
bf01ad417c | ||
|
|
6e1b7d806d | ||
|
|
7bb4b67885 | ||
|
|
8203d42233 | ||
|
|
3efc1f93b9 | ||
|
|
c06d924094 | ||
|
|
c3cb134e73 | ||
|
|
dd3ba72c2c | ||
|
|
44a4fb8888 | ||
|
|
6c05219af8 | ||
|
|
ce5033c554 | ||
|
|
31ee7bb467 |
@@ -312,7 +312,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
@@ -513,7 +513,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
if run_input.knowledge is not None and run_input.knowledge.sets:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
|
||||
@@ -137,6 +137,7 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
def post(self, tenant_id: str, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
@@ -271,6 +272,7 @@ class AgentComposerValidateApi(Resource):
|
||||
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_publish_payload(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id=tenant_id, agent_soul=payload.agent_soul)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
|
||||
@@ -38,6 +38,7 @@ from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceResult,
|
||||
SkillToolInferenceService,
|
||||
)
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
@@ -181,6 +182,22 @@ def _agent_not_bound() -> tuple[dict[str, str], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _sync_active_soul_files(
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
committed_items: list[dict[str, Any]],
|
||||
) -> None:
|
||||
AgentSoulFilesService.sync_drive_commit_to_active_soul(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=account_id,
|
||||
committed_items=committed_items,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _upload_skill_for_app(*, current_user: Account, app_model: App):
|
||||
"""Upload one skill package and commit its normalized files into the agent drive."""
|
||||
|
||||
@@ -195,8 +212,9 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
|
||||
|
||||
upload = request.files["file"]
|
||||
content = upload.stream.read()
|
||||
standardize_service = SkillStandardizeService()
|
||||
try:
|
||||
result = SkillStandardizeService().standardize(
|
||||
result = standardize_service.standardize(
|
||||
content=content,
|
||||
filename=upload.filename or "",
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -205,6 +223,12 @@ def _upload_skill_for_app(*, current_user: Account, app_model: App):
|
||||
)
|
||||
except (SkillPackageError, AgentDriveError) as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
_sync_active_soul_files(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
committed_items=standardize_service.last_committed_items,
|
||||
)
|
||||
return result, 201
|
||||
|
||||
|
||||
@@ -243,6 +267,12 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
_sync_active_soul_files(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
committed_items=committed,
|
||||
)
|
||||
|
||||
row = committed[0]
|
||||
return {
|
||||
@@ -267,15 +297,13 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[DriveCommitItem(key=key, file_ref=None)],
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
result = [{"key": key, "removed": True}]
|
||||
_sync_active_soul_files(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
committed_items=result,
|
||||
)
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
@@ -290,18 +318,34 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
|
||||
try:
|
||||
result = AgentDriveService().commit(
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(
|
||||
session=db.session,
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
|
||||
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
|
||||
],
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
removed_keys = [item["key"] for item in result if item.get("removed")]
|
||||
skill_prefix = f"{slug}/"
|
||||
removed_keys = [
|
||||
file_ref.drive_key
|
||||
for skill in agent_soul.files.skills
|
||||
if (
|
||||
skill.path
|
||||
or (AgentSoulFilesService.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")
|
||||
).strip("/")
|
||||
== slug
|
||||
for file_ref in skill.file_refs
|
||||
if file_ref.drive_key
|
||||
]
|
||||
if f"{slug}/SKILL.md" not in removed_keys:
|
||||
removed_keys.append(f"{slug}/SKILL.md")
|
||||
result = [{"key": key, "removed": True} for key in removed_keys if key.startswith(skill_prefix)]
|
||||
_sync_active_soul_files(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
committed_items=result,
|
||||
)
|
||||
return {"result": "success", "removed_keys": removed_keys}
|
||||
|
||||
|
||||
|
||||
@@ -28,10 +28,13 @@ from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.agent import AgentDriveFileKind
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.soul_files_service import AgentSoulFilesService
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
@@ -160,6 +163,111 @@ def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
def _versioned_manifest(*, tenant_id: str, agent_id: str, prefix: str = "") -> list[dict[str, Any]]:
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
normalized_prefix = prefix.strip().lstrip("/")
|
||||
skill_prefixes = AgentSoulFilesService.allowed_skill_prefixes(agent_soul)
|
||||
if normalized_prefix and any(normalized_prefix.startswith(p) for p in skill_prefixes):
|
||||
return AgentSoulFilesService.list_manifest_items(
|
||||
session=db.session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=normalized_prefix,
|
||||
)
|
||||
return AgentSoulFilesService.list_files(
|
||||
session=db.session,
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
prefix=normalized_prefix,
|
||||
)
|
||||
|
||||
|
||||
def _versioned_skills(*, tenant_id: str, agent_id: str) -> list[dict[str, Any]]:
|
||||
return AgentSoulFilesService.list_skills(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
|
||||
|
||||
def _assert_key_in_active_soul(*, tenant_id: str, agent_id: str, key: str) -> None:
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
if not AgentSoulFilesService.key_allowed_by_soul(agent_soul=agent_soul, key=key):
|
||||
raise AgentDriveError(
|
||||
"drive_key_not_in_agent_soul",
|
||||
"drive key is not part of the active Agent Soul version",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
def _file_ref_for_active_soul(*, tenant_id: str, agent_id: str, key: str):
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
file_ref = AgentSoulFilesService.file_ref_for_key(agent_soul=agent_soul, key=key)
|
||||
if file_ref is None and not AgentSoulFilesService.key_allowed_by_soul(agent_soul=agent_soul, key=key):
|
||||
raise AgentDriveError(
|
||||
"drive_key_not_in_agent_soul",
|
||||
"drive key is not part of the active Agent Soul version",
|
||||
status_code=404,
|
||||
)
|
||||
return file_ref
|
||||
|
||||
|
||||
def _file_kind_from_ref(file_ref) -> AgentDriveFileKind | None:
|
||||
raw = file_ref.transfer_method or ("upload_file" if file_ref.upload_file_id else None)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return AgentDriveFileKind(raw)
|
||||
except ValueError as exc:
|
||||
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref has invalid transfer method") from exc
|
||||
|
||||
|
||||
def _preview_versioned_file(*, tenant_id: str, agent_id: str, key: str) -> dict[str, Any]:
|
||||
file_ref = _file_ref_for_active_soul(tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
if file_ref is None:
|
||||
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
file_kind = _file_kind_from_ref(file_ref)
|
||||
file_id = file_ref.file_id or file_ref.upload_file_id
|
||||
if file_kind is None or not file_id:
|
||||
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref is missing file id", status_code=404)
|
||||
return AgentDriveService().preview_file_ref(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
key=key,
|
||||
file_kind=file_kind,
|
||||
file_id=file_id,
|
||||
size=file_ref.get("size"),
|
||||
)
|
||||
|
||||
|
||||
def _download_versioned_file(*, tenant_id: str, agent_id: str, key: str) -> str:
|
||||
file_ref = _file_ref_for_active_soul(tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
if file_ref is None:
|
||||
return AgentDriveService().download_url(tenant_id=tenant_id, agent_id=agent_id, key=key)
|
||||
file_kind = _file_kind_from_ref(file_ref)
|
||||
file_id = file_ref.file_id or file_ref.upload_file_id
|
||||
if file_kind is None or not file_id:
|
||||
raise AgentDriveError("invalid_drive_file_ref", "Agent Soul file ref is missing file id", status_code=404)
|
||||
return AgentDriveService().download_url_for_ref(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
file_kind=file_kind,
|
||||
file_id=file_id,
|
||||
)
|
||||
|
||||
|
||||
def _assert_skill_in_active_soul(*, tenant_id: str, agent_id: str, skill_path: str) -> None:
|
||||
agent_soul = AgentSoulFilesService.active_agent_soul(session=db.session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
wanted = skill_path.strip().strip("/")
|
||||
for skill in agent_soul.files.skills:
|
||||
path = skill.path
|
||||
if not path and skill.skill_md_key:
|
||||
path = AgentSoulFilesService.skill_path_from_key(skill.skill_md_key)
|
||||
if path == wanted:
|
||||
return
|
||||
raise AgentDriveError(
|
||||
"skill_not_in_agent_soul",
|
||||
"skill is not part of the active Agent Soul version",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
|
||||
def _json_response(data: Mapping[str, Any]):
|
||||
return Response(
|
||||
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
|
||||
@@ -184,7 +292,7 @@ class AgentDriveListByAgentApi(Resource):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
|
||||
items = _versioned_manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
@@ -203,7 +311,7 @@ class AgentDriveSkillListByAgentApi(Resource):
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
items = _versioned_skills(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
@@ -222,6 +330,7 @@ class AgentDriveSkillInspectByAgentApi(Resource):
|
||||
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
_assert_skill_in_active_soul(tenant_id=tenant_id, agent_id=str(agent_id), skill_path=skill_path)
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=tenant_id,
|
||||
@@ -247,7 +356,7 @@ class AgentDrivePreviewByAgentApi(Resource):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
return _preview_versioned_file(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
@@ -266,7 +375,7 @@ class AgentDriveDownloadByAgentApi(Resource):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
url = _download_versioned_file(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
@@ -288,7 +397,7 @@ class AgentDriveListApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
|
||||
items = _versioned_manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
# the inner manifest exposes file_id for agent-side pulls; the console
|
||||
@@ -312,7 +421,7 @@ class AgentDriveSkillListApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().list_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
|
||||
items = _versioned_skills(tenant_id=app_model.tenant_id, agent_id=agent_id)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": items}
|
||||
@@ -340,6 +449,7 @@ class AgentDriveSkillInspectApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
_assert_skill_in_active_soul(tenant_id=app_model.tenant_id, agent_id=agent_id, skill_path=skill_path)
|
||||
return _json_response(
|
||||
AgentDriveService().inspect_skill(
|
||||
tenant_id=app_model.tenant_id,
|
||||
@@ -367,7 +477,7 @@ class AgentDrivePreviewApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
return _preview_versioned_file(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
@@ -388,7 +498,7 @@ class AgentDriveDownloadApi(Resource):
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
url = _download_versioned_file(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
@@ -331,7 +331,7 @@ class ModelConfig(ResponseModel):
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class Site(ResponseModel):
|
||||
class AppDetailSiteResponse(ResponseModel):
|
||||
access_token: str | None = Field(default=None, validation_alias="code")
|
||||
code: str | None = None
|
||||
title: str | None = None
|
||||
@@ -461,7 +461,7 @@ class AppDetailWithSite(AppDetail):
|
||||
api_base_url: str | None = None
|
||||
max_active_requests: int | None = None
|
||||
deleted_tools: list[DeletedTool] = Field(default_factory=list)
|
||||
site: Site | None = None
|
||||
site: AppDetailSiteResponse | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
@@ -546,7 +546,7 @@ register_schema_models(
|
||||
WorkflowPartial,
|
||||
ModelConfigPartial,
|
||||
ModelConfig,
|
||||
Site,
|
||||
AppDetailSiteResponse,
|
||||
DeletedTool,
|
||||
AppDetail,
|
||||
AppExportResponse,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Any, Final, Literal
|
||||
from typing import Annotated, Any, Final, Literal, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, field_validator, model_validator
|
||||
|
||||
from core.rag.entities.metadata_entities import ConditionValue, SupportedComparisonOperator
|
||||
from core.workflow.file_reference import is_canonical_file_reference
|
||||
from graphon.file import FileTransferMethod
|
||||
|
||||
@@ -159,6 +160,22 @@ class AgentSkillRefConfig(AgentFlexibleConfig):
|
||||
# Zip member path listing from standardization (ENG-371): lets infer-tools
|
||||
# show the model strong signals like ``scripts/*.sh`` without unpacking.
|
||||
manifest_files: list[str] | None = None
|
||||
# Versioned drive KV entries that belong to this skill package. The drive
|
||||
# table remains the storage index, but Agent Soul owns which concrete file
|
||||
# ids are visible for a snapshot/version.
|
||||
file_refs: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentSoulFilesConfig(BaseModel):
|
||||
"""Versioned Agent Soul references to drive-backed skills and files.
|
||||
|
||||
File bytes and drive value pointers stay in ``agent_drive_files``. This
|
||||
section records which drive keys belong to one Agent Soul snapshot so version
|
||||
restore/copy/runtime use the same skills/files view the user published.
|
||||
"""
|
||||
|
||||
skills: list[AgentSkillRefConfig] = Field(default_factory=list)
|
||||
files: list[AgentFileRefConfig] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentPermissionConfig(BaseModel):
|
||||
@@ -236,17 +253,161 @@ class AgentCliToolConfig(AgentFlexibleConfig):
|
||||
inferred_from: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeDatasetConfig(AgentFlexibleConfig):
|
||||
class AgentKnowledgeDatasetConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str | None = Field(default=None, max_length=255)
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeQueryConfig(AgentFlexibleConfig):
|
||||
query: str | None = None
|
||||
class AgentKnowledgeQueryConfig(BaseModel):
|
||||
"""Per-set query policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Agent v2 stores knowledge as explicit ``knowledge.sets`` rather than the
|
||||
legacy flat ``datasets`` / ``query_mode`` / ``query_config`` shape. Each
|
||||
set owns its own query policy, so ``user_query`` must carry an explicit
|
||||
``value`` while ``generated_query`` leaves that value empty.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: AgentKnowledgeQueryMode
|
||||
value: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query(self) -> Self:
|
||||
if self.mode == AgentKnowledgeQueryMode.USER_QUERY and not (self.value or "").strip():
|
||||
raise ValueError("knowledge query.value is required for user_query mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
mode: str = Field(min_length=1, max_length=64)
|
||||
completion_params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AgentKnowledgeRerankingModelConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
provider: str = Field(min_length=1, max_length=255)
|
||||
model: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class AgentKnowledgeWeightedScoreConfig(AgentFlexibleConfig):
|
||||
weight_type: str | None = Field(default=None, max_length=64)
|
||||
vector_setting: dict[str, Any] | None = None
|
||||
keyword_setting: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AgentKnowledgeRetrievalConfig(BaseModel):
|
||||
"""Per-set retrieval policy for Agent v2 knowledge retrieval.
|
||||
|
||||
Retrieval settings now live on each knowledge set instead of one shared
|
||||
flat config. A set may use either ``multiple`` retrieval with ``top_k`` or
|
||||
``single`` retrieval with a required model config.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: Literal["single", "multiple"]
|
||||
top_k: int | None = Field(default=None, ge=1)
|
||||
score_threshold: float | None = Field(default=None, ge=0, le=1)
|
||||
score_threshold_enabled: bool | None = None
|
||||
reranking_mode: str = "reranking_model"
|
||||
reranking_enable: bool = True
|
||||
reranking_model: AgentKnowledgeRerankingModelConfig | None = None
|
||||
weights: AgentKnowledgeWeightedScoreConfig | None = None
|
||||
model: AgentKnowledgeModelConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "multiple" and self.top_k is None:
|
||||
raise ValueError("knowledge retrieval.top_k is required for multiple mode")
|
||||
if self.mode == "single" and self.model is None:
|
||||
raise ValueError("knowledge retrieval.model is required for single mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataCondition(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
comparison_operator: SupportedComparisonOperator
|
||||
value: ConditionValue = None
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataConditions(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
logical_operator: Literal["and", "or"] = "and"
|
||||
conditions: list[AgentKnowledgeMetadataCondition] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentKnowledgeMetadataFilteringConfig(BaseModel):
|
||||
"""Per-set metadata filtering policy.
|
||||
|
||||
The Python attribute uses ``metadata_model_config`` for clarity because the
|
||||
model belongs to metadata filtering specifically, while the external API and
|
||||
generated schema keep the historical ``model_config`` field name via alias.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
mode: Literal["disabled", "automatic", "manual"] = "disabled"
|
||||
# Internal name is explicit; wire format remains ``model_config``.
|
||||
metadata_model_config: AgentKnowledgeModelConfig | None = Field(default=None, alias="model_config")
|
||||
conditions: AgentKnowledgeMetadataConditions | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_mode_fields(self) -> Self:
|
||||
if self.mode == "automatic" and self.metadata_model_config is None:
|
||||
raise ValueError("metadata_filtering.model_config is required for automatic mode")
|
||||
if self.mode == "manual" and (self.conditions is None or not self.conditions.conditions):
|
||||
raise ValueError("metadata_filtering.conditions is required for manual mode")
|
||||
return self
|
||||
|
||||
|
||||
class AgentKnowledgeSetConfig(BaseModel):
|
||||
"""One explicit knowledge set in Agent v2.
|
||||
|
||||
``knowledge.sets`` replaces the old flat knowledge config. Each set owns
|
||||
its datasets plus query, retrieval, and metadata policies. An individual
|
||||
set must contain at least one dataset id even though the overall knowledge
|
||||
section may be empty, which is how callers express "no knowledge layer".
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
datasets: list[AgentKnowledgeDatasetConfig]
|
||||
query: AgentKnowledgeQueryConfig
|
||||
retrieval: AgentKnowledgeRetrievalConfig
|
||||
metadata_filtering: AgentKnowledgeMetadataFilteringConfig = Field(
|
||||
default_factory=AgentKnowledgeMetadataFilteringConfig
|
||||
)
|
||||
|
||||
@field_validator("id", "name")
|
||||
@classmethod
|
||||
def validate_non_blank_identity(cls, value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("knowledge set id and name must not be blank")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_datasets(self) -> Self:
|
||||
dataset_ids = [(dataset.id or "").strip() for dataset in self.datasets]
|
||||
if not dataset_ids or any(not dataset_id for dataset_id in dataset_ids):
|
||||
raise ValueError("knowledge set requires at least one dataset id")
|
||||
if len(dataset_ids) != len(set(dataset_ids)):
|
||||
raise ValueError("knowledge set dataset ids must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class AgentHumanContactConfig(AgentFlexibleConfig):
|
||||
@@ -453,9 +614,28 @@ class AgentSoulToolsConfig(BaseModel):
|
||||
|
||||
|
||||
class AgentSoulKnowledgeConfig(BaseModel):
|
||||
datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
|
||||
query_mode: AgentKnowledgeQueryMode | None = None
|
||||
query_config: AgentKnowledgeQueryConfig = Field(default_factory=AgentKnowledgeQueryConfig)
|
||||
"""Top-level Agent v2 knowledge config.
|
||||
|
||||
Agent v2 models knowledge as explicit sets instead of one flat
|
||||
``datasets`` / ``query_mode`` / ``query_config`` block. An empty ``sets``
|
||||
list means no knowledge layer should be emitted at runtime, while set-name
|
||||
uniqueness stays case-insensitive because runtime selection addresses sets
|
||||
by name.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
sets: list[AgentKnowledgeSetConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_sets(self) -> Self:
|
||||
set_ids = [item.id.strip() for item in self.sets]
|
||||
if len(set_ids) != len(set(set_ids)):
|
||||
raise ValueError("knowledge set ids must be unique")
|
||||
set_names = [item.name.strip().lower() for item in self.sets]
|
||||
if len(set_names) != len(set(set_names)):
|
||||
raise ValueError("knowledge set names must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class AgentSoulHumanConfig(BaseModel):
|
||||
@@ -515,6 +695,7 @@ class AgentSoulConfig(BaseModel):
|
||||
env: AgentSoulEnvConfig = Field(default_factory=AgentSoulEnvConfig)
|
||||
sandbox: AgentSoulSandboxConfig = Field(default_factory=AgentSoulSandboxConfig)
|
||||
memory: AgentSoulMemoryConfig = Field(default_factory=AgentSoulMemoryConfig)
|
||||
files: AgentSoulFilesConfig = Field(default_factory=AgentSoulFilesConfig)
|
||||
model: AgentSoulModelConfig | None = None
|
||||
app_features: AgentSoulAppFeaturesConfig = Field(default_factory=AgentSoulAppFeaturesConfig)
|
||||
app_variables: list[AppVariableConfig] = Field(default_factory=list)
|
||||
|
||||
@@ -12453,6 +12453,25 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| current_snapshot_id | string | | No |
|
||||
| workflow_node_count | integer | | Yes |
|
||||
|
||||
#### AgentComposerKnowledgeDatasetCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| id | string | | No |
|
||||
| missing | boolean | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### AgentComposerKnowledgeSetCandidateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentComposerKnowledgeDatasetCandidateResponse](#agentcomposerknowledgedatasetcandidateresponse) ] | | No |
|
||||
| description | string | | No |
|
||||
| id | string | | Yes |
|
||||
| missing_dataset_ids | [ string ] | | No |
|
||||
| name | string | | Yes |
|
||||
|
||||
#### AgentComposerNodeJobCandidatesResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -12468,7 +12487,7 @@ Risk marker for CLI tool bootstrap commands.
|
||||
| cli_tools | [ [AgentCliToolConfig](#agentclitoolconfig) ] | | No |
|
||||
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
|
||||
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
|
||||
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| knowledge_sets | [ [AgentComposerKnowledgeSetCandidateResponse](#agentcomposerknowledgesetcandidateresponse) ] | | No |
|
||||
|
||||
#### AgentComposerSoulLockResponse
|
||||
|
||||
@@ -12862,14 +12881,44 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### AgentKnowledgeMetadataCondition
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| comparison_operator | string, <br>**Available values:** "<", "=", ">", "after", "before", "contains", "empty", "end with", "in", "is", "is not", "not contains", "not empty", "not in", "start with", "≠", "≤", "≥" | *Enum:* `"<"`, `"="`, `">"`, `"after"`, `"before"`, `"contains"`, `"empty"`, `"end with"`, `"in"`, `"is"`, `"is not"`, `"not contains"`, `"not empty"`, `"not in"`, `"start with"`, `"≠"`, `"≤"`, `"≥"` | Yes |
|
||||
| name | string | | Yes |
|
||||
| value | string<br>[ string ]<br>number | | No |
|
||||
|
||||
#### AgentKnowledgeMetadataConditions
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [AgentKnowledgeMetadataCondition](#agentknowledgemetadatacondition) ] | | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or", <br>**Default:** and | *Enum:* `"and"`, `"or"` | No |
|
||||
|
||||
#### AgentKnowledgeMetadataFilteringConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [AgentKnowledgeMetadataConditions](#agentknowledgemetadataconditions) | | No |
|
||||
| mode | string, <br>**Available values:** "automatic", "disabled", "manual", <br>**Default:** disabled | *Enum:* `"automatic"`, `"disabled"`, `"manual"` | No |
|
||||
| model_config | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
|
||||
|
||||
#### AgentKnowledgeModelConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| completion_params | object | | No |
|
||||
| mode | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### AgentKnowledgeQueryConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| query | string | | No |
|
||||
| score_threshold | number | | No |
|
||||
| score_threshold_enabled | boolean | | No |
|
||||
| top_k | integer | | No |
|
||||
| mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | Yes |
|
||||
| value | string | | No |
|
||||
|
||||
#### AgentKnowledgeQueryMode
|
||||
|
||||
@@ -12877,6 +12926,46 @@ the current roster/workflow APIs scoped to Dify Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentKnowledgeQueryMode | string | | |
|
||||
|
||||
#### AgentKnowledgeRerankingModelConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| model | string | | Yes |
|
||||
| provider | string | | Yes |
|
||||
|
||||
#### AgentKnowledgeRetrievalConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| mode | string, <br>**Available values:** "multiple", "single" | *Enum:* `"multiple"`, `"single"` | Yes |
|
||||
| model | [AgentKnowledgeModelConfig](#agentknowledgemodelconfig) | | No |
|
||||
| reranking_enable | boolean, <br>**Default:** true | | No |
|
||||
| reranking_mode | string, <br>**Default:** reranking_model | | No |
|
||||
| reranking_model | [AgentKnowledgeRerankingModelConfig](#agentknowledgererankingmodelconfig) | | No |
|
||||
| score_threshold | number | | No |
|
||||
| top_k | integer | | No |
|
||||
| weights | [AgentKnowledgeWeightedScoreConfig](#agentknowledgeweightedscoreconfig) | | No |
|
||||
|
||||
#### AgentKnowledgeSetConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | Yes |
|
||||
| description | string | | No |
|
||||
| id | string | | Yes |
|
||||
| metadata_filtering | [AgentKnowledgeMetadataFilteringConfig](#agentknowledgemetadatafilteringconfig) | | No |
|
||||
| name | string | | Yes |
|
||||
| query | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | Yes |
|
||||
| retrieval | [AgentKnowledgeRetrievalConfig](#agentknowledgeretrievalconfig) | | Yes |
|
||||
|
||||
#### AgentKnowledgeWeightedScoreConfig
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | object | | No |
|
||||
| vector_setting | object | | No |
|
||||
| weight_type | string | | No |
|
||||
|
||||
#### AgentLogConversationItemResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13278,9 +13367,7 @@ old Agent tool payloads can be read while new payloads stay explicit.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
|
||||
| query_config | [AgentKnowledgeQueryConfig](#agentknowledgequeryconfig) | | No |
|
||||
| query_mode | [AgentKnowledgeQueryMode](#agentknowledgequerymode) | | No |
|
||||
| sets | [ [AgentKnowledgeSetConfig](#agentknowledgesetconfig) ] | | No |
|
||||
|
||||
#### AgentSoulMemoryConfig
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from models.agent_config_entities import (
|
||||
AgentSoulConfig,
|
||||
DeclaredOutputConfig,
|
||||
)
|
||||
from services.agent.knowledge_datasets import list_agent_soul_knowledge_dataset_ids
|
||||
|
||||
MAX_CANDIDATES_PER_LIST = 200
|
||||
|
||||
@@ -139,30 +140,49 @@ def soul_candidates(
|
||||
|
||||
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
|
||||
|
||||
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
|
||||
dataset_ids = list_agent_soul_knowledge_dataset_ids(soul)
|
||||
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
|
||||
knowledge_datasets: list[dict[str, Any]] = []
|
||||
for dataset in soul.knowledge.datasets:
|
||||
if not dataset.id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset.id)
|
||||
knowledge_datasets.append(
|
||||
knowledge_sets: list[dict[str, Any]] = []
|
||||
for knowledge_set in soul.knowledge.sets:
|
||||
missing_dataset_ids: list[str] = []
|
||||
datasets: list[dict[str, Any]] = []
|
||||
for dataset in knowledge_set.datasets:
|
||||
dataset_id = (dataset.id or "").strip()
|
||||
if not dataset_id:
|
||||
continue
|
||||
row = dataset_rows.get(dataset_id)
|
||||
if row is None:
|
||||
missing_dataset_ids.append(dataset_id)
|
||||
datasets.append(
|
||||
{
|
||||
"id": dataset_id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset_id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
}
|
||||
)
|
||||
knowledge_sets.append(
|
||||
{
|
||||
"id": dataset.id,
|
||||
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
|
||||
"description": getattr(row, "description", None) or dataset.description,
|
||||
"missing": row is None,
|
||||
"id": knowledge_set.id,
|
||||
"name": knowledge_set.name,
|
||||
"description": knowledge_set.description,
|
||||
"datasets": datasets,
|
||||
"missing_dataset_ids": missing_dataset_ids,
|
||||
}
|
||||
)
|
||||
|
||||
human_contacts = [contact.model_dump(exclude_none=True) for contact in soul.human.contacts]
|
||||
skills = [skill.model_dump(exclude_none=True) for skill in soul.files.skills]
|
||||
files = [file_ref.model_dump(exclude_none=True) for file_ref in soul.files.files]
|
||||
dify_tools = workspace_tools_loader()
|
||||
|
||||
lists = {
|
||||
"dify_tools": dify_tools,
|
||||
"cli_tools": cli_tools,
|
||||
"knowledge_datasets": knowledge_datasets,
|
||||
"knowledge_sets": knowledge_sets,
|
||||
"human_contacts": human_contacts,
|
||||
"skills": skills,
|
||||
"files": files,
|
||||
}
|
||||
capped: dict[str, list[dict[str, Any]]] = {}
|
||||
for key, values in lists.items():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -148,15 +148,15 @@ class ComposerConfigValidator:
|
||||
cls,
|
||||
payload: ComposerSavePayload,
|
||||
*,
|
||||
existing_dataset_ids: set[str] | None = None,
|
||||
existing_knowledge_set_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""ENG-617 §5.3/§5.4 soft findings — never block save.
|
||||
|
||||
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
|
||||
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge-set
|
||||
mentions with a placeholder name (0522 consensus) instead of dropping or
|
||||
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
|
||||
deleted datasets surface as placeholders too.
|
||||
rejecting them. With ``existing_knowledge_set_ids`` provided, mentions
|
||||
that no longer exist in the current Agent Soul surface as placeholders too.
|
||||
"""
|
||||
warnings: list[dict[str, Any]] = []
|
||||
placeholders: list[dict[str, str]] = []
|
||||
@@ -188,7 +188,7 @@ class ComposerConfigValidator:
|
||||
resolved = resolver(mention)
|
||||
if mention.kind == MentionKind.KNOWLEDGE:
|
||||
dangling = resolved is None or (
|
||||
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
|
||||
existing_knowledge_set_ids is not None and mention.ref_id not in existing_knowledge_set_ids
|
||||
)
|
||||
if dangling:
|
||||
placeholders.append(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
|
||||
def list_agent_soul_knowledge_dataset_ids(agent_soul: AgentSoulConfig) -> list[str]:
|
||||
"""Return normalized unique knowledge dataset ids in config order.
|
||||
|
||||
Agent v2 knowledge dataset selection is owned by ``knowledge.sets``. This
|
||||
helper keeps composer, workflow validation, candidates, and runtime
|
||||
diagnostics aligned on the same normalization rules: strip whitespace, drop
|
||||
blanks, preserve first-seen order, and deduplicate.
|
||||
"""
|
||||
dataset_ids: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
for dataset in knowledge_set.datasets:
|
||||
dataset_id = (dataset.id or "").strip()
|
||||
if not dataset_id or dataset_id in seen:
|
||||
continue
|
||||
seen.add(dataset_id)
|
||||
dataset_ids.append(dataset_id)
|
||||
return dataset_ids
|
||||
|
||||
|
||||
def get_tenant_knowledge_dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
|
||||
"""Return tenant-scoped dataset rows for normalized knowledge dataset ids.
|
||||
|
||||
Knowledge ids come from user-editable config. Malformed ids can never match
|
||||
a dataset row, so they are treated as missing instead of breaking the
|
||||
UUID-typed dataset lookup.
|
||||
"""
|
||||
from services.dataset_service import DatasetService
|
||||
|
||||
valid_ids: list[str] = []
|
||||
for dataset_id in dataset_ids:
|
||||
try:
|
||||
UUID(dataset_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
valid_ids.append(dataset_id)
|
||||
|
||||
if not valid_ids:
|
||||
return {}
|
||||
|
||||
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
|
||||
return {str(row.id): row for row in rows}
|
||||
|
||||
|
||||
def list_missing_tenant_knowledge_dataset_ids(*, tenant_id: str, agent_soul: AgentSoulConfig | None) -> list[str]:
|
||||
"""Return normalized knowledge dataset ids missing from the tenant scope."""
|
||||
if agent_soul is None:
|
||||
return []
|
||||
|
||||
dataset_ids = list_agent_soul_knowledge_dataset_ids(agent_soul)
|
||||
if not dataset_ids:
|
||||
return []
|
||||
|
||||
rows = get_tenant_knowledge_dataset_rows(tenant_id=tenant_id, dataset_ids=dataset_ids)
|
||||
return [dataset_id for dataset_id in dataset_ids if dataset_id not in rows]
|
||||
@@ -6,7 +6,7 @@ Slash-menu insertions are stored inline in the plain-string prompt as tokens:
|
||||
|
||||
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent
|
||||
runtime context. For prompt-owned entities that means Agent Soul lists such as
|
||||
``tools`` / ``knowledge.datasets`` / ``human.contacts`` and workflow job lists
|
||||
``tools`` / ``knowledge.sets`` / ``human.contacts`` and workflow job lists
|
||||
such as ``previous_node_output_refs`` / ``declared_outputs``. For drive-backed
|
||||
``skill`` / ``file`` mentions the field stores a URL-encoded drive key and is
|
||||
resolved against ``agent_drive_files`` at runtime. ``label`` is an optional
|
||||
@@ -211,9 +211,9 @@ def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
|
||||
if mention.ref_id in (cli_tool.id, cli_tool.name):
|
||||
return cli_tool.name or cli_tool.id
|
||||
case MentionKind.KNOWLEDGE:
|
||||
for dataset in agent_soul.knowledge.datasets:
|
||||
if mention.ref_id == dataset.id:
|
||||
return dataset.name or dataset.id
|
||||
for knowledge_set in agent_soul.knowledge.sets:
|
||||
if mention.ref_id == knowledge_set.id:
|
||||
return knowledge_set.name or knowledge_set.id
|
||||
case MentionKind.HUMAN:
|
||||
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
|
||||
case _:
|
||||
|
||||
@@ -50,6 +50,7 @@ class SkillStandardizeService:
|
||||
self._package = package_service or SkillPackageService()
|
||||
self._drive = drive_service or AgentDriveService()
|
||||
self._tool_files = tool_file_manager or ToolFileManager()
|
||||
self.last_committed_items: list[dict[str, Any]] = []
|
||||
|
||||
def standardize(
|
||||
self,
|
||||
@@ -109,7 +110,7 @@ class SkillStandardizeService:
|
||||
)
|
||||
)
|
||||
|
||||
self._drive.commit(
|
||||
committed_items = self._drive.commit(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
@@ -133,6 +134,7 @@ class SkillStandardizeService:
|
||||
*member_items,
|
||||
],
|
||||
)
|
||||
self.last_committed_items = committed_items
|
||||
|
||||
drive_skill = next(
|
||||
skill
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigRevision,
|
||||
AgentConfigRevisionOperation,
|
||||
AgentConfigSnapshot,
|
||||
AgentDriveFile,
|
||||
)
|
||||
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig, AgentSoulConfig
|
||||
from services.agent.agent_soul_state import agent_soul_has_model
|
||||
from services.agent_drive_service import AgentDriveError, DriveSkillMetadata, normalize_drive_key
|
||||
|
||||
_SKILL_MD_SUFFIX = "/SKILL.md"
|
||||
_SKILL_ARCHIVE_NAME = ".DIFY-SKILL-FULL.zip"
|
||||
_FILES_PREFIX = "files/"
|
||||
|
||||
|
||||
class AgentSoulFilesService:
|
||||
"""Versioned Agent Soul view of drive-backed skills and files.
|
||||
|
||||
``agent_drive_files`` remains the storage/index for bytes and drive values.
|
||||
``AgentSoulConfig.files`` records the versioned pointers that a specific
|
||||
Agent Soul snapshot owns, so restore/publish/runtime do not accidentally see
|
||||
later drive mutations.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def sync_drive_commit_to_active_soul(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
committed_items: list[dict[str, Any]],
|
||||
) -> AgentConfigSnapshot | None:
|
||||
if not committed_items:
|
||||
return None
|
||||
|
||||
agent = db.session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id))
|
||||
if agent is None or not agent.active_config_snapshot_id:
|
||||
return None
|
||||
current_snapshot = db.session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
|
||||
)
|
||||
)
|
||||
if current_snapshot is None:
|
||||
return None
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(current_snapshot.config_snapshot_dict).model_copy(deep=True)
|
||||
before = agent_soul.files.model_dump(mode="json")
|
||||
for item in committed_items:
|
||||
cls._apply_commit_item(agent_soul=agent_soul, item=item)
|
||||
if agent_soul.files.model_dump(mode="json") == before:
|
||||
return None
|
||||
|
||||
version = cls._create_config_version(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=account_id,
|
||||
agent_soul=agent_soul,
|
||||
previous_snapshot_id=current_snapshot.id,
|
||||
)
|
||||
agent.active_config_snapshot_id = version.id
|
||||
agent.active_config_has_model = agent_soul_has_model(agent_soul)
|
||||
agent.updated_by = account_id
|
||||
db.session.flush()
|
||||
return version
|
||||
|
||||
@classmethod
|
||||
def list_files(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prefix: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
file_keys = [file.drive_key for file in agent_soul.files.files if file.drive_key]
|
||||
if prefix:
|
||||
normalized_prefix = normalize_drive_key(prefix)
|
||||
file_keys = [key for key in file_keys if key.startswith(normalized_prefix)]
|
||||
if not file_keys:
|
||||
return []
|
||||
|
||||
rows = cls._drive_rows_by_key(session=session, tenant_id=tenant_id, agent_id=agent_id, keys=file_keys)
|
||||
items: list[dict[str, Any]] = []
|
||||
for file_ref in agent_soul.files.files:
|
||||
key = file_ref.drive_key
|
||||
if not key or key not in file_keys:
|
||||
continue
|
||||
row = rows.get(key)
|
||||
item = cls._file_item_from_ref(file_ref)
|
||||
item.update(cls._row_item(row) if row is not None else {"key": key, "missing": True})
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
def list_manifest_items(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
prefix: str = "",
|
||||
) -> list[dict[str, Any]]:
|
||||
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
refs = cls._all_file_refs(agent_soul)
|
||||
if prefix:
|
||||
normalized_prefix = normalize_drive_key(prefix)
|
||||
refs = [ref for ref in refs if (ref.drive_key or "").startswith(normalized_prefix)]
|
||||
keys = [ref.drive_key for ref in refs if ref.drive_key]
|
||||
rows = cls._drive_rows_by_key(session=session, tenant_id=tenant_id, agent_id=agent_id, keys=keys)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for file_ref in refs:
|
||||
key = file_ref.drive_key
|
||||
if not key:
|
||||
continue
|
||||
row = rows.get(key)
|
||||
item = cls._file_item_from_ref(file_ref)
|
||||
item.update(cls._row_item(row) if row is not None else {"key": key, "missing": True})
|
||||
item["file_id"] = file_ref.file_id or file_ref.upload_file_id
|
||||
items.append(item)
|
||||
return sorted(items, key=lambda item: str(item.get("key") or ""))
|
||||
|
||||
@classmethod
|
||||
def list_skills(
|
||||
cls,
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
agent_soul = cls.active_agent_soul(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
skill_keys = [skill.skill_md_key for skill in agent_soul.files.skills if skill.skill_md_key]
|
||||
archive_keys = [skill.full_archive_key for skill in agent_soul.files.skills if skill.full_archive_key]
|
||||
rows = cls._drive_rows_by_key(
|
||||
session=session, tenant_id=tenant_id, agent_id=agent_id, keys=[*skill_keys, *archive_keys]
|
||||
)
|
||||
items: list[dict[str, Any]] = []
|
||||
for skill in agent_soul.files.skills:
|
||||
if not skill.skill_md_key:
|
||||
continue
|
||||
row = rows.get(skill.skill_md_key)
|
||||
archive_key = skill.full_archive_key if skill.full_archive_key in rows else None
|
||||
skill_md_ref = cls.file_ref_for_key(agent_soul=agent_soul, key=skill.skill_md_key)
|
||||
items.append(
|
||||
{
|
||||
"path": skill.path or cls.skill_path_from_key(skill.skill_md_key),
|
||||
"skill_md_key": skill.skill_md_key,
|
||||
"archive_key": archive_key or skill.full_archive_key,
|
||||
"name": skill.name,
|
||||
"description": skill.description,
|
||||
"size": row.size if row is not None else None,
|
||||
"mime_type": row.mime_type if row is not None else (skill_md_ref.type if skill_md_ref else None),
|
||||
"hash": row.hash if row is not None else None,
|
||||
"created_at": int(row.created_at.timestamp()) if row is not None and row.created_at else None,
|
||||
"missing": row is None,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
def allowed_drive_keys(cls, agent_soul: AgentSoulConfig) -> set[str]:
|
||||
keys: set[str] = set()
|
||||
for file_ref in agent_soul.files.files:
|
||||
if file_ref.drive_key:
|
||||
keys.add(file_ref.drive_key)
|
||||
for skill in agent_soul.files.skills:
|
||||
if skill.skill_md_key:
|
||||
keys.add(skill.skill_md_key)
|
||||
if skill.full_archive_key:
|
||||
keys.add(skill.full_archive_key)
|
||||
for file_ref in skill.file_refs:
|
||||
if file_ref.drive_key:
|
||||
keys.add(file_ref.drive_key)
|
||||
return keys
|
||||
|
||||
@classmethod
|
||||
def allowed_skill_prefixes(cls, agent_soul: AgentSoulConfig) -> set[str]:
|
||||
prefixes: set[str] = set()
|
||||
for skill in agent_soul.files.skills:
|
||||
path = skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else None)
|
||||
if path:
|
||||
prefixes.add(f"{path}/")
|
||||
return prefixes
|
||||
|
||||
@classmethod
|
||||
def key_allowed_by_soul(cls, *, agent_soul: AgentSoulConfig, key: str) -> bool:
|
||||
normalized_key = normalize_drive_key(key)
|
||||
if normalized_key in cls.allowed_drive_keys(agent_soul):
|
||||
return True
|
||||
return any(normalized_key.startswith(prefix) for prefix in cls.allowed_skill_prefixes(agent_soul))
|
||||
|
||||
@classmethod
|
||||
def file_ref_for_key(cls, *, agent_soul: AgentSoulConfig, key: str) -> AgentFileRefConfig | None:
|
||||
normalized_key = normalize_drive_key(key)
|
||||
for file_ref in agent_soul.files.files:
|
||||
if file_ref.drive_key == normalized_key:
|
||||
return file_ref
|
||||
for skill in agent_soul.files.skills:
|
||||
for file_ref in skill.file_refs:
|
||||
if file_ref.drive_key == normalized_key:
|
||||
return file_ref
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def drive_copy_scopes(cls, *, agent_soul: AgentSoulConfig) -> tuple[set[str], set[str]]:
|
||||
exact_keys = cls.allowed_drive_keys(agent_soul)
|
||||
prefixes = cls.allowed_skill_prefixes(agent_soul)
|
||||
return exact_keys, prefixes
|
||||
|
||||
@staticmethod
|
||||
def active_agent_soul(*, session: Session, tenant_id: str, agent_id: str) -> AgentSoulConfig:
|
||||
snapshot = session.scalar(
|
||||
select(AgentConfigSnapshot)
|
||||
.join(Agent, Agent.active_config_snapshot_id == AgentConfigSnapshot.id)
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.id == agent_id,
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
if snapshot is None:
|
||||
raise AgentDriveError(
|
||||
"agent_snapshot_not_found",
|
||||
"agent has no active Agent Soul snapshot",
|
||||
status_code=404,
|
||||
)
|
||||
return AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
|
||||
|
||||
@staticmethod
|
||||
def skill_path_from_key(key: str) -> str:
|
||||
if not key.endswith(_SKILL_MD_SUFFIX):
|
||||
raise AgentDriveError(
|
||||
"invalid_skill_key",
|
||||
"skill rows must use the canonical '<path>/SKILL.md' key",
|
||||
status_code=500,
|
||||
)
|
||||
return key[: -len(_SKILL_MD_SUFFIX)]
|
||||
|
||||
@staticmethod
|
||||
def skill_archive_key(skill_md_key: str) -> str:
|
||||
return f"{AgentSoulFilesService.skill_path_from_key(skill_md_key)}/{_SKILL_ARCHIVE_NAME}"
|
||||
|
||||
@classmethod
|
||||
def _apply_commit_item(cls, *, agent_soul: AgentSoulConfig, item: dict[str, Any]) -> None:
|
||||
key = normalize_drive_key(str(item.get("key") or ""))
|
||||
if item.get("removed"):
|
||||
cls._remove_ref(agent_soul=agent_soul, key=key)
|
||||
return
|
||||
|
||||
if item.get("is_skill"):
|
||||
cls._upsert_skill_ref(agent_soul=agent_soul, key=key, item=item)
|
||||
return
|
||||
if key.startswith(_FILES_PREFIX):
|
||||
cls._upsert_file_ref(agent_soul=agent_soul, key=key, item=item)
|
||||
return
|
||||
cls._upsert_skill_file_ref(agent_soul=agent_soul, key=key, item=item)
|
||||
|
||||
@classmethod
|
||||
def _upsert_skill_ref(cls, *, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
|
||||
metadata = cls._parse_skill_metadata(item.get("skill_metadata"))
|
||||
path = cls.skill_path_from_key(key)
|
||||
ref = AgentSkillRefConfig(
|
||||
id=path,
|
||||
name=metadata.name,
|
||||
description=metadata.description,
|
||||
file_id=str(item.get("file_id") or ""),
|
||||
path=path,
|
||||
skill_md_key=key,
|
||||
skill_md_file_id=str(item.get("file_id") or ""),
|
||||
full_archive_key=cls.skill_archive_key(key),
|
||||
manifest_files=metadata.manifest_files,
|
||||
)
|
||||
existing_ref = next(
|
||||
(
|
||||
existing
|
||||
for existing in agent_soul.files.skills
|
||||
if existing.skill_md_key == key or existing.path == path
|
||||
),
|
||||
None,
|
||||
)
|
||||
file_refs = list(existing_ref.file_refs) if existing_ref else []
|
||||
file_refs = [file_ref for file_ref in file_refs if file_ref.drive_key != key]
|
||||
file_refs.append(cls._file_ref_from_item(key=key, item=item, name="SKILL.md"))
|
||||
archive_key = cls.skill_archive_key(key)
|
||||
archive_ref = next((file_ref for file_ref in file_refs if file_ref.drive_key == archive_key), None)
|
||||
if archive_ref:
|
||||
ref.full_archive_file_id = archive_ref.file_id
|
||||
ref.file_refs = sorted(file_refs, key=lambda value: value.drive_key or value.name)
|
||||
skills = [
|
||||
existing
|
||||
for existing in agent_soul.files.skills
|
||||
if existing.skill_md_key != key and existing.path != path
|
||||
]
|
||||
skills.append(ref)
|
||||
skills.sort(key=lambda value: value.path or value.skill_md_key or "")
|
||||
agent_soul.files.skills = skills
|
||||
|
||||
@staticmethod
|
||||
def _upsert_file_ref(*, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
|
||||
name = key.removeprefix(_FILES_PREFIX) or key.rsplit("/", 1)[-1]
|
||||
file_id = str(item.get("file_id") or "")
|
||||
ref = AgentFileRefConfig(
|
||||
id=key,
|
||||
file_id=file_id,
|
||||
upload_file_id=file_id if item.get("file_kind") == "upload_file" else None,
|
||||
name=name,
|
||||
type=str(item.get("mime_type") or ""),
|
||||
transfer_method=str(item.get("file_kind") or ""),
|
||||
drive_key=key,
|
||||
size=item.get("size"),
|
||||
hash=item.get("hash"),
|
||||
)
|
||||
files = [existing for existing in agent_soul.files.files if existing.drive_key != key]
|
||||
files.append(ref)
|
||||
files.sort(key=lambda value: value.drive_key or value.name)
|
||||
agent_soul.files.files = files
|
||||
|
||||
@classmethod
|
||||
def _upsert_skill_file_ref(cls, *, agent_soul: AgentSoulConfig, key: str, item: dict[str, Any]) -> None:
|
||||
path = key.split("/", 1)[0]
|
||||
if not path:
|
||||
return
|
||||
updated: list[AgentSkillRefConfig] = []
|
||||
changed = False
|
||||
for skill in agent_soul.files.skills:
|
||||
skill_path = skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")
|
||||
if skill_path != path:
|
||||
updated.append(skill)
|
||||
continue
|
||||
file_ref = cls._file_ref_from_item(key=key, item=item)
|
||||
file_refs = [existing for existing in skill.file_refs if existing.drive_key != key]
|
||||
file_refs.append(file_ref)
|
||||
replacement = skill.model_copy(
|
||||
update={"file_refs": sorted(file_refs, key=lambda value: value.drive_key or value.name)}
|
||||
)
|
||||
if key.endswith(f"/{_SKILL_ARCHIVE_NAME}"):
|
||||
replacement = replacement.model_copy(
|
||||
update={
|
||||
"full_archive_key": key,
|
||||
"full_archive_file_id": file_ref.file_id,
|
||||
}
|
||||
)
|
||||
updated.append(replacement)
|
||||
changed = True
|
||||
if changed:
|
||||
agent_soul.files.skills = updated
|
||||
|
||||
@staticmethod
|
||||
def _file_ref_from_item(*, key: str, item: dict[str, Any], name: str | None = None) -> AgentFileRefConfig:
|
||||
file_id = str(item.get("file_id") or "")
|
||||
return AgentFileRefConfig(
|
||||
id=key,
|
||||
file_id=file_id,
|
||||
upload_file_id=file_id if item.get("file_kind") == "upload_file" else None,
|
||||
name=name or key.rsplit("/", 1)[-1],
|
||||
type=str(item.get("mime_type") or ""),
|
||||
transfer_method=str(item.get("file_kind") or ""),
|
||||
drive_key=key,
|
||||
size=item.get("size"),
|
||||
hash=item.get("hash"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _remove_ref(cls, *, agent_soul: AgentSoulConfig, key: str) -> None:
|
||||
agent_soul.files.files = [file_ref for file_ref in agent_soul.files.files if file_ref.drive_key != key]
|
||||
if key.endswith(_SKILL_MD_SUFFIX):
|
||||
path = cls.skill_path_from_key(key)
|
||||
agent_soul.files.skills = [
|
||||
skill for skill in agent_soul.files.skills if skill.skill_md_key != key and skill.path != path
|
||||
]
|
||||
return
|
||||
if key.endswith(f"/{_SKILL_ARCHIVE_NAME}"):
|
||||
agent_soul.files.skills = [
|
||||
skill.model_copy(
|
||||
update={
|
||||
"full_archive_key": None,
|
||||
"full_archive_file_id": None,
|
||||
"file_refs": [file_ref for file_ref in skill.file_refs if file_ref.drive_key != key],
|
||||
}
|
||||
)
|
||||
if skill.full_archive_key == key
|
||||
else skill
|
||||
for skill in agent_soul.files.skills
|
||||
]
|
||||
return
|
||||
path = key.split("/", 1)[0]
|
||||
if path:
|
||||
agent_soul.files.skills = [
|
||||
skill.model_copy(
|
||||
update={"file_refs": [file_ref for file_ref in skill.file_refs if file_ref.drive_key != key]}
|
||||
)
|
||||
if (skill.path or (cls.skill_path_from_key(skill.skill_md_key) if skill.skill_md_key else "")) == path
|
||||
else skill
|
||||
for skill in agent_soul.files.skills
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _all_file_refs(agent_soul: AgentSoulConfig) -> list[AgentFileRefConfig]:
|
||||
refs = list(agent_soul.files.files)
|
||||
for skill in agent_soul.files.skills:
|
||||
refs.extend(skill.file_refs)
|
||||
return refs
|
||||
|
||||
@staticmethod
|
||||
def _parse_skill_metadata(raw_metadata: Any) -> DriveSkillMetadata:
|
||||
if isinstance(raw_metadata, DriveSkillMetadata):
|
||||
return raw_metadata
|
||||
if isinstance(raw_metadata, str):
|
||||
return DriveSkillMetadata.model_validate(json.loads(raw_metadata))
|
||||
return DriveSkillMetadata.model_validate(raw_metadata or {})
|
||||
|
||||
@staticmethod
|
||||
def _drive_rows_by_key(
|
||||
*,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
keys: list[str],
|
||||
) -> dict[str, AgentDriveFile]:
|
||||
if not keys:
|
||||
return {}
|
||||
return {
|
||||
row.key: row
|
||||
for row in session.scalars(
|
||||
select(AgentDriveFile).where(
|
||||
AgentDriveFile.tenant_id == tenant_id,
|
||||
AgentDriveFile.agent_id == agent_id,
|
||||
AgentDriveFile.key.in_(sorted(set(keys))),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _row_item(row: AgentDriveFile | None) -> dict[str, Any]:
|
||||
if row is None:
|
||||
return {}
|
||||
return {
|
||||
"key": row.key,
|
||||
"size": row.size,
|
||||
"hash": row.hash,
|
||||
"mime_type": row.mime_type,
|
||||
"file_kind": row.file_kind.value,
|
||||
"is_skill": row.is_skill,
|
||||
"skill_metadata": row.skill_metadata,
|
||||
"created_at": int(row.created_at.timestamp()) if row.created_at else None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _file_item_from_ref(file_ref: AgentFileRefConfig) -> dict[str, Any]:
|
||||
key = file_ref.drive_key or file_ref.name
|
||||
return {
|
||||
"key": key,
|
||||
"name": file_ref.name,
|
||||
"mime_type": file_ref.type,
|
||||
"file_kind": file_ref.transfer_method,
|
||||
"is_skill": False,
|
||||
"size": file_ref.get("size"),
|
||||
"hash": file_ref.get("hash"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _create_config_version(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent_id: str,
|
||||
account_id: str,
|
||||
agent_soul: AgentSoulConfig,
|
||||
previous_snapshot_id: str,
|
||||
) -> AgentConfigSnapshot:
|
||||
next_version = (
|
||||
db.session.scalar(
|
||||
select(func.max(AgentConfigSnapshot.version)).where(
|
||||
AgentConfigSnapshot.tenant_id == tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) + 1
|
||||
version = AgentConfigSnapshot(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
version=next_version,
|
||||
config_snapshot=agent_soul,
|
||||
created_by=account_id,
|
||||
)
|
||||
db.session.add(version)
|
||||
db.session.flush()
|
||||
revision = AgentConfigRevision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent_id,
|
||||
previous_snapshot_id=previous_snapshot_id,
|
||||
current_snapshot_id=version.id,
|
||||
revision=cls._next_revision(tenant_id=tenant_id, agent_id=agent_id),
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
created_by=account_id,
|
||||
)
|
||||
db.session.add(revision)
|
||||
db.session.flush()
|
||||
return version
|
||||
|
||||
@staticmethod
|
||||
def _next_revision(*, tenant_id: str, agent_id: str) -> int:
|
||||
return (
|
||||
db.session.scalar(
|
||||
select(func.max(AgentConfigRevision.revision)).where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) + 1
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+158
-72
@@ -512,12 +512,55 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1"}, {"id": " "}, {"id": "dataset-2"}],
|
||||
"query_config": {
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"score_threshold_enabled": True,
|
||||
},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"description": "Support content",
|
||||
"datasets": [{"id": "dataset-1"}, {"id": "dataset-2"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
|
||||
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "manual",
|
||||
"conditions": {
|
||||
"logical_operator": "and",
|
||||
"conditions": [
|
||||
{"name": "category", "comparison_operator": "contains", "value": "auth"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "release",
|
||||
"name": "Release Notes",
|
||||
"datasets": [{"id": "dataset-3"}],
|
||||
"query": {"mode": "user_query", "value": "release notes"},
|
||||
"retrieval": {
|
||||
"mode": "single",
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "automatic",
|
||||
"model_config": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
@@ -531,25 +574,75 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config():
|
||||
knowledge_layer = layers["knowledge"]
|
||||
assert knowledge_layer["type"] == "dify.knowledge_base"
|
||||
assert knowledge_layer["deps"] == {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
|
||||
assert knowledge_layer["config"] == {
|
||||
"dataset_ids": ["dataset-1", "dataset-2"],
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": None,
|
||||
"weights": None,
|
||||
"model": None,
|
||||
assert knowledge_layer["config"]["sets"] == [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"description": "Support content",
|
||||
"datasets": [
|
||||
{"id": "dataset-1", "name": None, "description": None},
|
||||
{"id": "dataset-2", "name": None, "description": None},
|
||||
],
|
||||
"query": {"mode": "generated_query", "value": None},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 6,
|
||||
"score_threshold": 0.4,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": {"provider": "cohere", "model": "rerank-v3"},
|
||||
"weights": {"weight_type": "weighted_score", "vector_setting": {"vector_weight": 0.7}},
|
||||
"model": None,
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "manual",
|
||||
"metadata_model_config": None,
|
||||
"conditions": {
|
||||
"logical_operator": "and",
|
||||
"conditions": [
|
||||
{"name": "category", "comparison_operator": "contains", "value": "auth"}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {"mode": "disabled", "metadata_model_config": None, "conditions": None},
|
||||
"max_result_content_chars": 2000,
|
||||
"max_observation_chars": 12000,
|
||||
}
|
||||
{
|
||||
"id": "release",
|
||||
"name": "Release Notes",
|
||||
"description": None,
|
||||
"datasets": [{"id": "dataset-3", "name": None, "description": None}],
|
||||
"query": {"mode": "user_query", "value": "release notes"},
|
||||
"retrieval": {
|
||||
"mode": "single",
|
||||
"top_k": None,
|
||||
"score_threshold": 0.0,
|
||||
"reranking_mode": "reranking_model",
|
||||
"reranking_enable": True,
|
||||
"reranking_model": None,
|
||||
"weights": None,
|
||||
"model": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {"temperature": 0.2},
|
||||
},
|
||||
},
|
||||
"metadata_filtering": {
|
||||
"mode": "automatic",
|
||||
"metadata_model_config": {
|
||||
"provider": "openai",
|
||||
"name": "gpt-4o-mini",
|
||||
"mode": "chat",
|
||||
"completion_params": {},
|
||||
},
|
||||
"conditions": None,
|
||||
},
|
||||
},
|
||||
]
|
||||
assert knowledge_layer["config"]["max_result_content_chars"] == 2000
|
||||
assert knowledge_layer["config"]["max_observation_chars"] == 12000
|
||||
|
||||
|
||||
def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits_it():
|
||||
def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero():
|
||||
context = _context()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
@@ -565,8 +658,19 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query_config": {},
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "dataset-1"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {
|
||||
"mode": "multiple",
|
||||
"top_k": 4,
|
||||
"score_threshold": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
),
|
||||
@@ -577,10 +681,10 @@ def test_build_knowledge_layer_uses_stable_default_top_k_when_query_config_omits
|
||||
|
||||
dumped = result.request.model_dump(mode="json")
|
||||
knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge")
|
||||
assert knowledge_layer["config"]["retrieval"]["top_k"] == 4
|
||||
assert knowledge_layer["config"]["sets"][0]["retrieval"]["score_threshold"] == 0.0
|
||||
|
||||
|
||||
def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
|
||||
def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets():
|
||||
context = _context()
|
||||
snapshot = AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
@@ -595,9 +699,7 @@ def test_build_skips_knowledge_layer_when_agent_soul_has_no_valid_dataset_ids():
|
||||
"model_provider": "openai",
|
||||
"model": "gpt-test",
|
||||
},
|
||||
"knowledge": {
|
||||
"datasets": [{"id": " "}, {}],
|
||||
},
|
||||
"knowledge": {"sets": []},
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -840,46 +942,29 @@ def _soul_with_drive_skill() -> AgentSoulConfig:
|
||||
"and [§file:files%2Fsample.pdf:sample.pdf§]."
|
||||
)
|
||||
},
|
||||
files={
|
||||
"skills": [
|
||||
{
|
||||
"id": "tender-analyzer",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"full_archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
}
|
||||
],
|
||||
"files": [{"id": "files/sample.pdf", "name": "sample.pdf", "drive_key": "files/sample.pdf"}],
|
||||
},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
|
||||
|
||||
def _mock_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
"size": 123,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": "hash-1",
|
||||
"created_at": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [
|
||||
{"key": "tender-analyzer/SKILL.md", "is_skill": True},
|
||||
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "is_skill": False},
|
||||
{"key": "files/sample.pdf", "is_skill": False},
|
||||
],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _mock_empty_drive_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def test_build_drive_layer_config_catalogs_drive_skills_and_mentions(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -1005,14 +1090,6 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.dify_config.AGENT_DRIVE_MANIFEST_ENABLED", True
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.list_skills",
|
||||
lambda self, *, tenant_id, agent_id: [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.workflow.nodes.agent_v2.runtime_request_builder.AgentDriveService.manifest",
|
||||
lambda self, *, tenant_id, agent_id, prefix="", include_download_url=False: [],
|
||||
)
|
||||
context = _context()
|
||||
context.snapshot.config_snapshot = AgentSoulConfig(
|
||||
prompt={
|
||||
@@ -1021,6 +1098,7 @@ def test_workflow_runtime_missing_drive_mentions_fall_back_to_label_then_decoded
|
||||
"and [§file:files%2Fno-label.txt§]."
|
||||
)
|
||||
},
|
||||
files={"files": [{"id": "files/no-label.txt", "name": "no-label.txt", "drive_key": "files/no-label.txt"}]},
|
||||
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
|
||||
)
|
||||
|
||||
@@ -1094,7 +1172,15 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
|
||||
"sets": [
|
||||
{
|
||||
"id": "product",
|
||||
"name": "Product Docs",
|
||||
"datasets": [{"id": "dataset-1", "name": "Product Docs"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1106,13 +1192,13 @@ def test_feature_manifest_marks_knowledge_supported_without_warning_when_configu
|
||||
assert all("knowledge" not in w["section"] for w in manifest["unsupported_runtime_warnings"])
|
||||
|
||||
|
||||
def test_feature_manifest_treats_blank_knowledge_dataset_ids_as_not_configured():
|
||||
def test_feature_manifest_treats_empty_knowledge_sets_as_not_configured():
|
||||
from core.workflow.nodes.agent_v2.runtime_feature_manifest import build_runtime_feature_manifest
|
||||
|
||||
soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"datasets": [{"id": " "}, {}],
|
||||
"sets": [],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -55,6 +55,33 @@ def _snapshot() -> AgentConfigSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def _snapshot_with_knowledge_dataset(dataset_id: str) -> AgentConfigSnapshot:
|
||||
return AgentConfigSnapshot(
|
||||
id="snapshot-1",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
version=1,
|
||||
config_snapshot=AgentSoulConfig(
|
||||
model=AgentSoulModelConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="gpt-test",
|
||||
),
|
||||
knowledge={
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": dataset_id}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _graph(edges: list[dict]) -> dict:
|
||||
return {
|
||||
"nodes": [
|
||||
@@ -515,6 +542,35 @@ def test_publish_validation_rejects_missing_file_ref():
|
||||
)
|
||||
|
||||
|
||||
def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
dataset_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
node_job = WorkflowNodeJobConfig.model_validate({})
|
||||
snapshot = _snapshot_with_knowledge_dataset(dataset_id)
|
||||
session = Mock()
|
||||
session.scalar.side_effect = [_binding(node_job), _agent(), snapshot]
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_get_datasets_by_ids(ids, tenant_id):
|
||||
captured["ids"] = ids
|
||||
captured["tenant_id"] = tenant_id
|
||||
return [], 0
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id):
|
||||
WorkflowAgentNodeValidator.validate_published_workflow(
|
||||
session=session,
|
||||
workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])),
|
||||
)
|
||||
|
||||
assert captured == {"ids": [dataset_id], "tenant_id": "tenant-1"}
|
||||
|
||||
|
||||
def test_publish_validation_accepts_tool_node_agentic_manual_mode():
|
||||
session = Mock()
|
||||
|
||||
|
||||
@@ -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']",
|
||||
],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -55,6 +55,10 @@ type SendCallback = {
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
type UseChatOptions = {
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
export const useChat = (
|
||||
config?: ChatConfig,
|
||||
formSettings?: {
|
||||
@@ -66,9 +70,10 @@ export const useChat = (
|
||||
clearChatList?: boolean,
|
||||
clearChatListCallback?: (state: boolean) => void,
|
||||
initialConversationId?: string,
|
||||
options: UseChatOptions = {},
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { formatTime } = useTimestamp({ timezone: options.timezone })
|
||||
const conversationIdRef = useRef(initialConversationId ?? '')
|
||||
const initialConversationIdRef = useRef(initialConversationId ?? '')
|
||||
const hasStopRespondedRef = useRef(false)
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
import type { HumanInputFormSubmitData } from './answer/human-input-content/type'
|
||||
import type { InputForm } from './type'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { AppData } from '@/models/share'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -39,7 +41,7 @@ export type ChatProps = {
|
||||
onStopResponding?: () => void
|
||||
noChatInput?: boolean
|
||||
onSend?: OnSend
|
||||
inputs?: Record<string, any>
|
||||
inputs?: Record<string, unknown>
|
||||
inputsForm?: InputForm[]
|
||||
onRegenerate?: OnRegenerate
|
||||
chatContainerClassName?: string
|
||||
@@ -68,11 +70,13 @@ export type ChatProps = {
|
||||
onFeatureBarClick?: (state: boolean) => void
|
||||
noSpacing?: boolean
|
||||
inputDisabled?: boolean
|
||||
inputPlaceholder?: string
|
||||
inputPlaceholderBotName?: string
|
||||
sidebarCollapseState?: boolean
|
||||
hideAvatar?: boolean
|
||||
sendOnEnter?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: HumanInputFormSubmitData) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => any
|
||||
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
|
||||
}
|
||||
|
||||
const Chat: FC<ChatProps> = ({
|
||||
@@ -114,6 +118,8 @@ const Chat: FC<ChatProps> = ({
|
||||
onFeatureBarClick,
|
||||
noSpacing,
|
||||
inputDisabled,
|
||||
inputPlaceholder,
|
||||
inputPlaceholderBotName,
|
||||
sidebarCollapseState,
|
||||
hideAvatar,
|
||||
sendOnEnter,
|
||||
@@ -180,7 +186,7 @@ const Chat: FC<ChatProps> = ({
|
||||
appData={appData}
|
||||
key={item.id}
|
||||
item={item}
|
||||
question={chatList[index - 1]?.content!}
|
||||
question={chatList[index - 1]?.content ?? ''}
|
||||
index={index}
|
||||
config={config}
|
||||
answerIcon={answerIcon}
|
||||
@@ -240,7 +246,8 @@ const Chat: FC<ChatProps> = ({
|
||||
{
|
||||
!noChatInput && (
|
||||
<ChatInputArea
|
||||
botName={appData?.site?.title || 'Bot'}
|
||||
botName={inputPlaceholderBotName || appData?.site?.title || 'Bot'}
|
||||
placeholder={inputPlaceholder}
|
||||
disabled={inputDisabled}
|
||||
showFeatureBar={showFeatureBar}
|
||||
showFileUpload={showFileUpload}
|
||||
|
||||
@@ -81,6 +81,9 @@ const ChatWrapper = () => {
|
||||
opening_statement: currentConversationItem?.introduction || (config as any).opening_statement,
|
||||
} as ChatConfig
|
||||
}, [appParams, currentConversationItem?.introduction])
|
||||
const timezone = appSourceType === AppSourceType.webApp
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: undefined
|
||||
const {
|
||||
chatList,
|
||||
handleSend,
|
||||
@@ -98,6 +101,8 @@ const ChatWrapper = () => {
|
||||
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
undefined,
|
||||
{ timezone },
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
|
||||
@@ -384,6 +384,7 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).toHaveAttribute('href', '/')
|
||||
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: /common.menus.roster/ })).toHaveAttribute('href', '/roster')
|
||||
expect(screen.getByRole('link', { name: /common.menus.roster common.menus.status/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute('href', '/datasets')
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.integrations/ })).toHaveAttribute('href', '/integrations/model-provider')
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.marketplace/ })).toHaveAttribute('href', '/marketplace')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { MainNavItem } from '../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import Link from '@/next/link'
|
||||
@@ -17,11 +18,13 @@ const NavIcon = ({
|
||||
type MainNavLinkProps = {
|
||||
item: MainNavItem
|
||||
pathname: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const MainNavLink = ({
|
||||
item,
|
||||
pathname,
|
||||
children,
|
||||
}: MainNavLinkProps) => {
|
||||
const activated = item.active(pathname)
|
||||
|
||||
@@ -38,7 +41,8 @@ const MainNavLink = ({
|
||||
>
|
||||
<NavIcon icon={item.icon} className="group-aria-[current=page]:hidden" />
|
||||
<NavIcon icon={item.activeIcon} className="hidden group-aria-[current=page]:block" />
|
||||
<span className="truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">{item.label}</span>
|
||||
<span className="min-w-0 truncate group-aria-[current=page]:text-shadow-[0px_0px_8px_var(--color-components-main-nav-glass-text-glow)]">{item.label}</span>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import AppDetailTop from '@/app/components/app-sidebar/app-detail-top'
|
||||
import DatasetDetailSection from '@/app/components/app-sidebar/dataset-detail-section'
|
||||
import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import EnvNav from '@/app/components/header/env-nav'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@@ -299,7 +300,16 @@ const MainNav = ({
|
||||
<>
|
||||
<nav className="isolate flex flex-col gap-px p-2">
|
||||
{navItems.map(item => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname} />
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname}>
|
||||
{item.href === '/roster' && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="dimm"
|
||||
text={t('menus.status', { ns: 'common' })}
|
||||
className="ml-auto shrink-0"
|
||||
/>
|
||||
)}
|
||||
</MainNavLink>
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import type { AgentComposerAgentResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { SaveInlineAgentToRosterDialog } from '../save-inline-agent-to-roster-dialog'
|
||||
|
||||
const mutationMock = vi.hoisted(() => ({
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
}))
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
useMutation: () => ({
|
||||
isPending: mutationMock.isPending,
|
||||
mutate: mutationMock.mutate,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon-picker', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
initialEmoji,
|
||||
onSelect,
|
||||
open,
|
||||
}: {
|
||||
initialEmoji?: { icon: string, background: string }
|
||||
onSelect: (payload: { type: 'emoji', icon: string, background: string }) => void
|
||||
open: boolean
|
||||
}) => open
|
||||
? (
|
||||
<div>
|
||||
<span>{`${initialEmoji?.icon}:${initialEmoji?.background}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ type: 'emoji', icon: '🧠', background: '#E0F2FE' })}
|
||||
>
|
||||
Select brain icon
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
apps: {
|
||||
byAppId: {
|
||||
workflows: {
|
||||
draft: {
|
||||
nodes: {
|
||||
byNodeId: {
|
||||
agentComposer: {
|
||||
saveToRoster: {
|
||||
post: {
|
||||
mutationOptions: vi.fn(() => ({})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const inlineAgent: AgentComposerAgentResponse = {
|
||||
active_config_snapshot_id: 'snapshot-1',
|
||||
description: 'Drafts tender clarifications.',
|
||||
icon: '🤖',
|
||||
icon_background: '#F5F3FF',
|
||||
icon_type: 'emoji',
|
||||
id: 'inline-agent-1',
|
||||
name: 'Inline Tender Agent',
|
||||
role: 'Tender Analyst',
|
||||
scope: 'workflow_only',
|
||||
status: 'active',
|
||||
}
|
||||
|
||||
const renderDialog = () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const onSaved = vi.fn()
|
||||
|
||||
render(
|
||||
<SaveInlineAgentToRosterDialog
|
||||
appId="app-1"
|
||||
formKey={1}
|
||||
initialAgent={inlineAgent}
|
||||
nodeId="node-1"
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
)
|
||||
|
||||
return { onOpenChange, onSaved }
|
||||
}
|
||||
|
||||
describe('SaveInlineAgentToRosterDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mutationMock.isPending = false
|
||||
})
|
||||
|
||||
it('initializes form fields from the inline agent metadata', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
|
||||
expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' })).toHaveValue('Inline Tender Agent')
|
||||
expect(within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.roleLabel' })).toHaveValue('Tender Analyst')
|
||||
expect(within(dialog).getByPlaceholderText('agentV2.roster.createForm.descriptionPlaceholder')).toHaveValue('Drafts tender clarifications.')
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mutationMock.mutate).toHaveBeenCalledWith({
|
||||
params: {
|
||||
app_id: 'app-1',
|
||||
node_id: 'node-1',
|
||||
},
|
||||
body: {
|
||||
variant: 'workflow',
|
||||
save_strategy: 'save_to_roster',
|
||||
new_agent_name: 'Inline Tender Agent',
|
||||
description: 'Drafts tender clarifications.',
|
||||
role: 'Tender Analyst',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
const mutationOptions = mutationMock.mutate.mock.calls[0]?.[1]
|
||||
expect(mutationOptions).not.toHaveProperty('onError')
|
||||
})
|
||||
|
||||
it('initializes the icon picker from the inline agent and submits changed icon fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderDialog()
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' })
|
||||
await user.click(within(dialog).getByRole('button', { name: 'agentV2.roster.saveToRosterForm.changeIcon' }))
|
||||
|
||||
expect(screen.getByText('🤖:#F5F3FF')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { hidden: true, name: 'Select brain icon' }))
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' }))
|
||||
|
||||
expect(mutationMock.mutate).toHaveBeenCalledWith({
|
||||
params: {
|
||||
app_id: 'app-1',
|
||||
node_id: 'node-1',
|
||||
},
|
||||
body: {
|
||||
variant: 'workflow',
|
||||
save_strategy: 'save_to_roster',
|
||||
new_agent_name: 'Inline Tender Agent',
|
||||
description: 'Drafts tender clarifications.',
|
||||
role: 'Tender Analyst',
|
||||
icon_type: 'emoji',
|
||||
icon: '🧠',
|
||||
icon_background: '#E0F2FE',
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}))
|
||||
})
|
||||
})
|
||||
+4
-4
@@ -15,7 +15,7 @@ import { consoleQuery } from '@/service/client'
|
||||
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
|
||||
|
||||
type AgentOrchestrateDrawerPanelProps = {
|
||||
agentId: string
|
||||
agentId?: string
|
||||
appId?: string
|
||||
inlineComposerState?: WorkflowAgentComposerResponse
|
||||
isInline: boolean
|
||||
@@ -40,7 +40,7 @@ function AgentOrchestrateDrawerPanelContent({
|
||||
open,
|
||||
}: AgentOrchestrateDrawerPanelProps) {
|
||||
const rosterComposerQuery = useQuery(consoleQuery.agent.byAgentId.composer.get.queryOptions({
|
||||
input: open && !isInline
|
||||
input: open && !isInline && agentId
|
||||
? {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
@@ -62,12 +62,12 @@ function AgentOrchestrateDrawerPanelContent({
|
||||
})
|
||||
|
||||
useHydrateAgentSoulConfigDraft({
|
||||
agentId: isInline ? `${nodeId}:${agentId}` : agentId,
|
||||
agentId: isInline ? `${nodeId}:${agentId ?? 'pending'}` : agentId ?? nodeId,
|
||||
activeVersionId: activeConfigSnapshot?.id,
|
||||
config: agentSoulConfig as AgentSoulConfig | undefined,
|
||||
})
|
||||
|
||||
if (!agentSoulConfig) {
|
||||
if (!agentId || !agentSoulConfig) {
|
||||
return (
|
||||
<div className="flex h-full min-h-80 items-center justify-center bg-components-panel-bg">
|
||||
<Loading type="app" />
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentOutputVariables } from '../index'
|
||||
|
||||
const editorLabel = 'workflow.nodes.agent.outputVars.editorLabel'
|
||||
const nameLabel = 'workflow.nodes.agent.outputVars.nameLabel'
|
||||
const confirmLabel = 'workflow.nodes.agent.outputVars.confirm'
|
||||
|
||||
async function expandOutputVars(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.outputVars' }))
|
||||
}
|
||||
|
||||
function getAddButton(name: string) {
|
||||
return screen.getByRole('button', { name: `common.operation.add ${name}` })
|
||||
}
|
||||
|
||||
function getEditButton(name: string) {
|
||||
return screen.getByRole('button', {
|
||||
name: `workflow.nodes.agent.outputVars.edit:{"name":"${name}"}`,
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmEditorName(user: ReturnType<typeof userEvent.setup>, name: string) {
|
||||
const editor = screen.getByRole('form', { name: editorLabel })
|
||||
const nameInput = within(editor).getByLabelText(nameLabel)
|
||||
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, name)
|
||||
await user.click(within(editor).getByRole('button', { name: confirmLabel }))
|
||||
}
|
||||
|
||||
describe('AgentOutputVariables', () => {
|
||||
it('should add an object child without opening the parent editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const outputs: DeclaredOutputConfig[] = [{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
description: 'User profile',
|
||||
}]
|
||||
|
||||
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
await expandOutputVars(user)
|
||||
await user.click(getAddButton('profile'))
|
||||
|
||||
expect(screen.getAllByRole('form', { name: editorLabel })).toHaveLength(1)
|
||||
expect(screen.getByText('profile')).toBeInTheDocument()
|
||||
|
||||
await confirmEditorName(user, 'email')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
description: 'User profile',
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('should append children to array object items', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const outputs: DeclaredOutputConfig[] = [{
|
||||
name: 'addresses',
|
||||
type: 'array',
|
||||
required: false,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
},
|
||||
}]
|
||||
|
||||
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
await expandOutputVars(user)
|
||||
await user.click(getAddButton('addresses'))
|
||||
await confirmEditorName(user, 'city')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{
|
||||
name: 'addresses',
|
||||
type: 'array',
|
||||
required: false,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
children: [{
|
||||
name: 'city',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('should add nested children under the selected object child', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const outputs: DeclaredOutputConfig[] = [{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'contact',
|
||||
type: 'object',
|
||||
required: true,
|
||||
}],
|
||||
}]
|
||||
|
||||
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
await expandOutputVars(user)
|
||||
await user.click(getAddButton('contact'))
|
||||
await confirmEditorName(user, 'email')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'contact',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('should edit only the selected nested child', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const outputs: DeclaredOutputConfig[] = [{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'contact',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Primary email',
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
|
||||
render(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
await expandOutputVars(user)
|
||||
await user.click(getEditButton('email'))
|
||||
|
||||
expect(screen.getAllByRole('form', { name: editorLabel })).toHaveLength(1)
|
||||
|
||||
await confirmEditorName(user, 'work_email')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'contact',
|
||||
type: 'object',
|
||||
required: true,
|
||||
children: [{
|
||||
name: 'work_email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Primary email',
|
||||
}],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
})
|
||||
+46
@@ -6,6 +6,7 @@ import {
|
||||
} from '../utils'
|
||||
|
||||
const createDraft = (overrides: Partial<OutputDraft> = {}): OutputDraft => ({
|
||||
children: [],
|
||||
defaultValue: '',
|
||||
description: '',
|
||||
name: 'summary',
|
||||
@@ -54,4 +55,49 @@ describe('agent output variables utils', () => {
|
||||
type: 'array[file]',
|
||||
}))).toBe('nodes.agent.outputVars.defaultValueFileUnsupported')
|
||||
})
|
||||
|
||||
it('should preserve object children when building an output', () => {
|
||||
expect(createOutputFromDraft(createDraft({
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'User email',
|
||||
}],
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
}))).toMatchObject({
|
||||
name: 'profile',
|
||||
type: 'object',
|
||||
children: [{
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'User email',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('should preserve array object item children when building an output', () => {
|
||||
expect(createOutputFromDraft(createDraft({
|
||||
children: [{
|
||||
name: 'city',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
name: 'addresses',
|
||||
type: 'array[object]',
|
||||
}))).toMatchObject({
|
||||
name: 'addresses',
|
||||
type: 'array',
|
||||
array_item: {
|
||||
type: 'object',
|
||||
children: [{
|
||||
name: 'city',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+41
-35
@@ -1,5 +1,5 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { EditingState, OutputDraft } from './utils'
|
||||
import type { EditableOutputConfig, EditingState, OutputDraft } from './utils'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { CollapsiblePanel, CollapsibleRoot, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
|
||||
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
@@ -38,21 +38,25 @@ function ConfirmHotkeyHint() {
|
||||
|
||||
export function OutputEditCard({
|
||||
existingOutputs,
|
||||
editingIndex,
|
||||
allowDefaultValue = true,
|
||||
state,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
existingOutputs: DeclaredOutputConfig[]
|
||||
existingOutputs: EditableOutputConfig[]
|
||||
editingIndex?: number
|
||||
allowDefaultValue?: boolean
|
||||
state: EditingState
|
||||
onCancel: () => void
|
||||
onConfirm: (output: DeclaredOutputConfig, index?: number) => void
|
||||
onConfirm: (output: DeclaredOutputConfig, state: EditingState) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const nameErrorId = useId()
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const [draft, setDraft] = useState(state.draft)
|
||||
const trimmedName = draft.name.trim()
|
||||
const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== state.index)
|
||||
const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== editingIndex)
|
||||
const nameInvalid = !!trimmedName && !OUTPUT_NAME_PATTERN.test(trimmedName)
|
||||
const hasNameError = duplicateName || nameInvalid
|
||||
const defaultValueErrorKey = getDefaultValueErrorKey(draft)
|
||||
@@ -63,7 +67,7 @@ export function OutputEditCard({
|
||||
function handleConfirm() {
|
||||
if (confirmDisabled)
|
||||
return
|
||||
onConfirm(createOutputFromDraft(draft), state.index)
|
||||
onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state)
|
||||
}
|
||||
useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false })
|
||||
useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false })
|
||||
@@ -134,36 +138,38 @@ export function OutputEditCard({
|
||||
/>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
<CollapsibleRoot>
|
||||
<CollapsibleTrigger className="h-8 min-h-8 justify-start gap-x-0.5 rounded-none border-y border-divider-subtle pr-2 pl-2.5 system-xs-regular text-text-tertiary hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-tertiary focus-visible:bg-state-base-hover focus-visible:ring-inset data-panel-open:text-text-tertiary">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
{t('nodes.agent.outputVars.showAdvancedOptions', { ns: 'workflow' })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className="border-t border-divider-subtle">
|
||||
<div className="px-3 py-2">
|
||||
<FieldRoot name="defaultValue" className="gap-1">
|
||||
<FieldLabel className="py-0 system-xs-medium text-text-secondary">
|
||||
{t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={draft.defaultValue}
|
||||
placeholder={t('nodes.agent.outputVars.defaultValuePlaceholder', { ns: 'workflow' })}
|
||||
className="mt-1 min-h-6"
|
||||
onValueChange={defaultValue => updateDraft({ defaultValue })}
|
||||
/>
|
||||
{defaultValueErrorKey && (
|
||||
<FieldError match className="py-0 system-xs-regular text-text-destructive">
|
||||
{t(defaultValueErrorKey, { ns: 'workflow' })}
|
||||
</FieldError>
|
||||
)}
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</CollapsibleRoot>
|
||||
{allowDefaultValue && (
|
||||
<CollapsibleRoot>
|
||||
<CollapsibleTrigger className="h-8 min-h-8 justify-start gap-x-0.5 rounded-none border-y border-divider-subtle pr-2 pl-2.5 system-xs-regular text-text-tertiary hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-tertiary focus-visible:bg-state-base-hover focus-visible:ring-inset data-panel-open:text-text-tertiary">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
{t('nodes.agent.outputVars.showAdvancedOptions', { ns: 'workflow' })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className="border-t border-divider-subtle">
|
||||
<div className="px-3 py-2">
|
||||
<FieldRoot name="defaultValue" className="gap-1">
|
||||
<FieldLabel className="py-0 system-xs-medium text-text-secondary">
|
||||
{t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={draft.defaultValue}
|
||||
placeholder={t('nodes.agent.outputVars.defaultValuePlaceholder', { ns: 'workflow' })}
|
||||
className="mt-1 min-h-6"
|
||||
onValueChange={defaultValue => updateDraft({ defaultValue })}
|
||||
/>
|
||||
{defaultValueErrorKey && (
|
||||
<FieldError match className="py-0 system-xs-regular text-text-destructive">
|
||||
{t(defaultValueErrorKey, { ns: 'workflow' })}
|
||||
</FieldError>
|
||||
)}
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</CollapsibleRoot>
|
||||
)}
|
||||
<div className="flex h-12 items-center justify-end gap-x-2 px-3">
|
||||
<Button type="button" size="small" variant="secondary" onClick={onCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
|
||||
+172
-27
@@ -1,5 +1,6 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentOutputVariablesProps, EditingState } from './utils'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentOutputVariablesProps, DeclaredOutputChildConfig, EditableOutputConfig, EditingState } from './utils'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -7,21 +8,30 @@ import Divider from '@/app/components/base/divider'
|
||||
import OutputVars from '../../../_base/components/output-vars'
|
||||
import { OutputEditCard } from './edit-card'
|
||||
import {
|
||||
canOutputHaveChildren,
|
||||
createDraft,
|
||||
deleteOutputChildAtPath,
|
||||
getOutputChildren,
|
||||
getOutputChildrenAtPath,
|
||||
getOutputDescription,
|
||||
getOutputDisplayType,
|
||||
getOutputTypeOptionValue,
|
||||
insertOutputChildAtPath,
|
||||
isDefaultOutput,
|
||||
toDeclaredOutputChild,
|
||||
updateOutputChildAtPath,
|
||||
} from './utils'
|
||||
|
||||
function OutputRow({
|
||||
output,
|
||||
editable,
|
||||
onAddChild,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
output: DeclaredOutputConfig
|
||||
output: EditableOutputConfig
|
||||
editable: boolean
|
||||
onAddChild?: () => void
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
}) {
|
||||
@@ -43,6 +53,16 @@ function OutputRow({
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="pointer-events-none flex shrink-0 items-center gap-x-0.5 opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{onAddChild && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('operation.add', { ns: 'common' })} ${output.name}`}
|
||||
className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={onAddChild}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-add-circle-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nodes.agent.outputVars.edit', { ns: 'workflow', name: output.name })}
|
||||
@@ -70,6 +90,22 @@ function OutputRow({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChildOutputFrame({ children, depth }: { children: ReactNode, depth: number }) {
|
||||
return (
|
||||
<div className="flex items-stretch">
|
||||
{Array.from({ length: depth }, (_, index) => (
|
||||
<div key={index} aria-hidden="true" className="flex w-5 shrink-0 justify-center">
|
||||
<div className="w-px bg-divider-subtle" />
|
||||
</div>
|
||||
))}
|
||||
<div className="min-w-0 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentOutputVariables({
|
||||
outputs,
|
||||
onChange,
|
||||
@@ -80,49 +116,151 @@ export function AgentOutputVariables({
|
||||
setEditingState({ draft: createDraft() })
|
||||
}
|
||||
function handleEditOutput(index: number) {
|
||||
setEditingState({ index, draft: createDraft(outputs[index]) })
|
||||
setEditingState({ outputIndex: index, draft: createDraft(outputs[index]) })
|
||||
}
|
||||
function handleNewChild(outputIndex: number, parentPath: number[]) {
|
||||
setEditingState({ outputIndex, parentPath, draft: createDraft() })
|
||||
}
|
||||
function handleEditChild(outputIndex: number, childPath: number[], child: DeclaredOutputChildConfig) {
|
||||
setEditingState({ outputIndex, childPath, draft: createDraft(child) })
|
||||
}
|
||||
function handleDeleteOutput(index: number) {
|
||||
onChange(outputs.filter((_, outputIndex) => outputIndex !== index))
|
||||
}
|
||||
function handleConfirm(output: DeclaredOutputConfig, index?: number) {
|
||||
if (typeof index === 'number') {
|
||||
onChange(outputs.map((item, outputIndex) => outputIndex === index ? output : item))
|
||||
function handleDeleteChild(outputIndex: number, childPath: number[]) {
|
||||
const parent = outputs[outputIndex]
|
||||
if (!parent)
|
||||
return
|
||||
|
||||
onChange(outputs.map((item, itemIndex) => (
|
||||
itemIndex === outputIndex ? deleteOutputChildAtPath(item, childPath) : item
|
||||
)))
|
||||
}
|
||||
function handleConfirm(output: DeclaredOutputConfig, state: EditingState) {
|
||||
if (typeof state.outputIndex === 'number' && state.parentPath) {
|
||||
const parent = outputs[state.outputIndex]
|
||||
if (!parent)
|
||||
return
|
||||
|
||||
const childOutput = toDeclaredOutputChild(output)
|
||||
onChange(outputs.map((item, outputIndex) => (
|
||||
outputIndex === state.outputIndex ? insertOutputChildAtPath(item, state.parentPath!, childOutput) : item
|
||||
)))
|
||||
}
|
||||
else if (typeof state.outputIndex === 'number' && state.childPath) {
|
||||
const childOutput = toDeclaredOutputChild(output)
|
||||
onChange(outputs.map((item, outputIndex) => (
|
||||
outputIndex === state.outputIndex ? updateOutputChildAtPath(item, state.childPath!, childOutput) : item
|
||||
)))
|
||||
}
|
||||
else if (typeof state.outputIndex === 'number') {
|
||||
onChange(outputs.map((item, outputIndex) => outputIndex === state.outputIndex ? output : item))
|
||||
}
|
||||
else {
|
||||
onChange([...outputs, output])
|
||||
}
|
||||
setEditingState(null)
|
||||
}
|
||||
return (
|
||||
<OutputVars>
|
||||
<div className="pb-2">
|
||||
<div className="flex flex-col">
|
||||
{outputs.map((output, index) => (
|
||||
editingState?.index === index
|
||||
? (
|
||||
function renderChildren(output: DeclaredOutputConfig, outputIndex: number, depth: number, children: DeclaredOutputChildConfig[], editable: boolean, parentPath: number[] = []) {
|
||||
return (
|
||||
<>
|
||||
{children.map((child, childIndex) => {
|
||||
const childPath = [...parentPath, childIndex]
|
||||
const nestedChildren = getOutputChildren(child)
|
||||
const isEditingChild = editingState?.outputIndex === outputIndex && pathsEqual(editingState.childPath, childPath)
|
||||
return (
|
||||
<div key={`${childPath.join('.')}-${child.name}-${getOutputTypeOptionValue(child)}`} className="flex flex-col">
|
||||
{isEditingChild
|
||||
? (
|
||||
<ChildOutputFrame depth={depth}>
|
||||
<OutputEditCard
|
||||
allowDefaultValue={false}
|
||||
editingIndex={childIndex}
|
||||
existingOutputs={getOutputChildrenAtPath(output, parentPath)}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)
|
||||
: (
|
||||
<ChildOutputFrame depth={depth}>
|
||||
<OutputRow
|
||||
output={child}
|
||||
editable={editable}
|
||||
onAddChild={editable && canOutputHaveChildren(child) ? () => handleNewChild(outputIndex, childPath) : undefined}
|
||||
onDelete={() => handleDeleteChild(outputIndex, childPath)}
|
||||
onEdit={() => handleEditChild(outputIndex, childPath, child)}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
{renderChildren(output, outputIndex, depth + 1, nestedChildren, editable, childPath)}
|
||||
{editingState?.outputIndex === outputIndex && pathsEqual(editingState.parentPath, childPath) && (
|
||||
<ChildOutputFrame depth={depth + 1}>
|
||||
<OutputEditCard
|
||||
key={`${output.name}-editing`}
|
||||
existingOutputs={outputs}
|
||||
allowDefaultValue={false}
|
||||
existingOutputs={nestedChildren}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<OutputRow
|
||||
key={`${output.name}-${getOutputTypeOptionValue(output)}`}
|
||||
output={output}
|
||||
editable={!isDefaultOutput(output)}
|
||||
onDelete={() => handleDeleteOutput(index)}
|
||||
onEdit={() => handleEditOutput(index)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<OutputVars>
|
||||
<div className="pb-2">
|
||||
<div className="flex flex-col">
|
||||
{outputs.map((output, index) => {
|
||||
const editable = !isDefaultOutput(output)
|
||||
const children = getOutputChildren(output)
|
||||
const isEditingOutput = editingState?.outputIndex === index && !editingState.childPath && !editingState.parentPath
|
||||
return (
|
||||
<div key={`${output.name}-${getOutputTypeOptionValue(output)}`} className="flex flex-col">
|
||||
{isEditingOutput
|
||||
? (
|
||||
<OutputEditCard
|
||||
key={`${output.name}-editing`}
|
||||
editingIndex={index}
|
||||
existingOutputs={outputs}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<OutputRow
|
||||
output={output}
|
||||
editable={editable}
|
||||
onAddChild={editable && canOutputHaveChildren(output) ? () => handleNewChild(index, []) : undefined}
|
||||
onDelete={() => handleDeleteOutput(index)}
|
||||
onEdit={() => handleEditOutput(index)}
|
||||
/>
|
||||
)}
|
||||
{renderChildren(output, index, 1, children, editable)}
|
||||
{editingState?.outputIndex === index && editingState.parentPath && !editingState.parentPath.length && (
|
||||
<ChildOutputFrame depth={1}>
|
||||
<OutputEditCard
|
||||
allowDefaultValue={false}
|
||||
existingOutputs={children}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="py-1">
|
||||
<Divider type="horizontal" className="h-px bg-divider-subtle" />
|
||||
</div>
|
||||
{editingState && editingState.index == null
|
||||
{editingState && editingState.outputIndex == null
|
||||
? (
|
||||
<OutputEditCard
|
||||
existingOutputs={outputs}
|
||||
@@ -149,3 +287,10 @@ export function AgentOutputVariables({
|
||||
</OutputVars>
|
||||
)
|
||||
}
|
||||
|
||||
function pathsEqual(left?: number[], right?: number[]) {
|
||||
if (!left || !right || left.length !== right.length)
|
||||
return false
|
||||
|
||||
return left.every((item, index) => item === right[index])
|
||||
}
|
||||
|
||||
+209
-8
@@ -2,6 +2,10 @@ import type { DeclaredOutputConfig, DeclaredOutputType } from '@dify/contracts/a
|
||||
import type { TFunction } from 'i18next'
|
||||
import { defaultAgentV2DeclaredOutputs } from '../../output-variables'
|
||||
|
||||
export type DeclaredOutputChildConfig = NonNullable<DeclaredOutputConfig['children']>[number]
|
||||
|
||||
export type EditableOutputConfig = DeclaredOutputConfig | DeclaredOutputChildConfig
|
||||
|
||||
export type OutputTypeOptionValue
|
||||
= DeclaredOutputType
|
||||
| 'array[boolean]'
|
||||
@@ -23,11 +27,14 @@ export type OutputDraft = {
|
||||
name: string
|
||||
required: boolean
|
||||
type: OutputTypeOptionValue
|
||||
children: DeclaredOutputChildConfig[]
|
||||
}
|
||||
|
||||
export type EditingState = {
|
||||
draft: OutputDraft
|
||||
index?: number
|
||||
outputIndex?: number
|
||||
childPath?: number[]
|
||||
parentPath?: number[]
|
||||
}
|
||||
|
||||
export type AgentOutputVariablesProps = {
|
||||
@@ -51,7 +58,7 @@ export const OUTPUT_TYPE_OPTIONS: OutputTypeOption[] = [
|
||||
{ value: 'array[file]', label: 'array[file]', type: 'array', arrayItemType: 'file' },
|
||||
]
|
||||
|
||||
export function getOutputTypeOptionValue(output: DeclaredOutputConfig): OutputTypeOptionValue {
|
||||
export function getOutputTypeOptionValue(output: EditableOutputConfig): OutputTypeOptionValue {
|
||||
if (output.type !== 'array')
|
||||
return output.type
|
||||
|
||||
@@ -62,7 +69,7 @@ export function getOutputTypeOption(value: OutputTypeOptionValue) {
|
||||
return OUTPUT_TYPE_OPTIONS.find(option => option.value === value) || OUTPUT_TYPE_OPTIONS[0]!
|
||||
}
|
||||
|
||||
export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
export function createDraft(output?: EditableOutputConfig): OutputDraft {
|
||||
if (!output) {
|
||||
return {
|
||||
defaultValue: '',
|
||||
@@ -70,6 +77,7 @@ export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
name: '',
|
||||
required: false,
|
||||
type: 'string',
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +87,14 @@ export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
name: output.name,
|
||||
required: output.required ?? true,
|
||||
type: getOutputTypeOptionValue(output),
|
||||
children: getOutputChildren(output),
|
||||
}
|
||||
}
|
||||
|
||||
export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig {
|
||||
export function createOutputFromDraft(
|
||||
draft: OutputDraft,
|
||||
{ includeDefaultValue = true }: { includeDefaultValue?: boolean } = {},
|
||||
): DeclaredOutputConfig {
|
||||
const option = getOutputTypeOption(draft.type)
|
||||
const output: DeclaredOutputConfig = {
|
||||
name: draft.name.trim(),
|
||||
@@ -99,6 +111,16 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.children.length && draft.type === 'object')
|
||||
output.children = draft.children
|
||||
|
||||
if (draft.children.length && draft.type === 'array[object]') {
|
||||
output.array_item = {
|
||||
type: 'object',
|
||||
children: draft.children,
|
||||
}
|
||||
}
|
||||
|
||||
if (option.type === 'file') {
|
||||
output.file = {
|
||||
extensions: [],
|
||||
@@ -106,7 +128,7 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.defaultValue.trim()) {
|
||||
if (includeDefaultValue && draft.defaultValue.trim()) {
|
||||
output.failure_strategy = {
|
||||
on_failure: 'default_value',
|
||||
default_value: coerceDefaultValue(draft.defaultValue, option),
|
||||
@@ -116,6 +138,113 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
return output
|
||||
}
|
||||
|
||||
export function toDeclaredOutputChild(output: DeclaredOutputConfig): DeclaredOutputChildConfig {
|
||||
return {
|
||||
name: output.name,
|
||||
type: output.type,
|
||||
required: output.required,
|
||||
...(output.description ? { description: output.description } : {}),
|
||||
...(output.file ? { file: output.file } : {}),
|
||||
...(output.children ? { children: output.children } : {}),
|
||||
...(output.array_item ? { array_item: output.array_item } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function getOutputChildren(output: EditableOutputConfig): DeclaredOutputChildConfig[] {
|
||||
if (getOutputTypeOptionValue(output) === 'array[object]')
|
||||
return readOutputChildren(output.array_item?.children)
|
||||
|
||||
if (output.type === 'object')
|
||||
return readOutputChildren(output.children)
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function canOutputHaveChildren(output: EditableOutputConfig) {
|
||||
const type = getOutputTypeOptionValue(output)
|
||||
return type === 'object' || type === 'array[object]'
|
||||
}
|
||||
|
||||
export function updateOutputChildren(
|
||||
output: DeclaredOutputConfig,
|
||||
children: DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputConfig {
|
||||
if (getOutputTypeOptionValue(output) === 'array[object]') {
|
||||
return {
|
||||
...output,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
...output.array_item,
|
||||
children: children.length ? children : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (output.type === 'object') {
|
||||
return {
|
||||
...output,
|
||||
children: children.length ? children : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function getOutputChildrenAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
path: number[],
|
||||
): DeclaredOutputChildConfig[] {
|
||||
const target = getOutputChildAtPath(output, path)
|
||||
return target ? getOutputChildren(target) : getOutputChildren(output)
|
||||
}
|
||||
|
||||
export function getOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
path: number[],
|
||||
): DeclaredOutputChildConfig | undefined {
|
||||
let current: DeclaredOutputChildConfig | undefined
|
||||
let children = getOutputChildren(output)
|
||||
for (const index of path) {
|
||||
current = children[index]
|
||||
if (!current)
|
||||
return undefined
|
||||
children = getOutputChildren(current)
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
export function insertOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
parentPath: number[],
|
||||
child: DeclaredOutputChildConfig,
|
||||
) {
|
||||
return updateOutputChildrenAtPath(output, parentPath, children => [...children, child])
|
||||
}
|
||||
|
||||
export function updateOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
childPath: number[],
|
||||
child: DeclaredOutputChildConfig,
|
||||
) {
|
||||
const childIndex = childPath.at(-1)
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.map((item, index) => index === childIndex ? child : item))
|
||||
}
|
||||
|
||||
export function deleteOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
childPath: number[],
|
||||
) {
|
||||
const childIndex = childPath.at(-1)
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.filter((_, index) => index !== childIndex))
|
||||
}
|
||||
|
||||
export function getDefaultValueErrorKey(draft: OutputDraft) {
|
||||
const trimmed = draft.defaultValue.trim()
|
||||
if (!trimmed)
|
||||
@@ -157,7 +286,7 @@ export function isDefaultOutput(output: DeclaredOutputConfig) {
|
||||
)
|
||||
}
|
||||
|
||||
export function getOutputDescription(output: DeclaredOutputConfig, t: TFunction) {
|
||||
export function getOutputDescription(output: EditableOutputConfig, t: TFunction) {
|
||||
if (output.name === 'text')
|
||||
return t('nodes.agent.outputVars.text', { ns: 'workflow' })
|
||||
if (output.name === 'files')
|
||||
@@ -167,11 +296,83 @@ export function getOutputDescription(output: DeclaredOutputConfig, t: TFunction)
|
||||
return output.description || ''
|
||||
}
|
||||
|
||||
export function getOutputDisplayType(output: DeclaredOutputConfig) {
|
||||
export function getOutputDisplayType(output: EditableOutputConfig) {
|
||||
return getOutputTypeOption(getOutputTypeOptionValue(output)).label
|
||||
}
|
||||
|
||||
function getOutputDefaultValue(output: DeclaredOutputConfig) {
|
||||
function readOutputChildren(children: EditableOutputConfig['children']) {
|
||||
return (children ?? []) as DeclaredOutputChildConfig[]
|
||||
}
|
||||
|
||||
function updateOutputChildrenAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
parentPath: number[],
|
||||
updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputConfig {
|
||||
if (!parentPath.length)
|
||||
return updateOutputChildren(output, updater(getOutputChildren(output)))
|
||||
|
||||
const [childIndex, ...restPath] = parentPath
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
const children = getOutputChildren(output)
|
||||
const nextChildren = children.map((child, index) => (
|
||||
index === childIndex ? updateChildChildrenAtPath(child, restPath, updater) : child
|
||||
))
|
||||
|
||||
return updateOutputChildren(output, nextChildren)
|
||||
}
|
||||
|
||||
function updateChildChildrenAtPath(
|
||||
child: DeclaredOutputChildConfig,
|
||||
parentPath: number[],
|
||||
updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputChildConfig {
|
||||
if (!parentPath.length)
|
||||
return updateChildChildren(child, updater(getOutputChildren(child)))
|
||||
|
||||
const [childIndex, ...restPath] = parentPath
|
||||
if (childIndex == null)
|
||||
return child
|
||||
|
||||
const children = getOutputChildren(child)
|
||||
const nextChildren = children.map((nestedChild, index) => (
|
||||
index === childIndex ? updateChildChildrenAtPath(nestedChild, restPath, updater) : nestedChild
|
||||
))
|
||||
|
||||
return updateChildChildren(child, nextChildren)
|
||||
}
|
||||
|
||||
function updateChildChildren(
|
||||
child: DeclaredOutputChildConfig,
|
||||
children: DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputChildConfig {
|
||||
if (getOutputTypeOptionValue(child) === 'array[object]') {
|
||||
return {
|
||||
...child,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
...child.array_item,
|
||||
children: children.length ? children : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (child.type === 'object') {
|
||||
return {
|
||||
...child,
|
||||
children: children.length ? children : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
function getOutputDefaultValue(output: EditableOutputConfig) {
|
||||
if (!('failure_strategy' in output))
|
||||
return ''
|
||||
|
||||
const defaultValue = output.failure_strategy?.default_value
|
||||
if (defaultValue == null)
|
||||
return ''
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import {
|
||||
Popover,
|
||||
@@ -76,7 +82,7 @@ function InlineSetupAvatar({
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('flex size-8 shrink-0 items-center justify-center rounded-full bg-background-default-burn', className)}>
|
||||
<span aria-hidden className="i-custom-vender-agent-v2-robot-3 size-5 text-text-tertiary" />
|
||||
<span aria-hidden className="i-custom-vender-agent-v2-configure h-3.5 w-3 text-text-tertiary" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -91,23 +97,30 @@ function AgentRosterDrawer({
|
||||
showAccessIcon = true,
|
||||
showConsoleLink = true,
|
||||
showDetailActions = true,
|
||||
isCopyPending = false,
|
||||
onMakeCopy,
|
||||
onSaveInlineToRoster,
|
||||
onClose,
|
||||
}: {
|
||||
agent: AgentRosterDisplayData
|
||||
children?: ReactNode
|
||||
isInlineSetup?: boolean
|
||||
isCopyPending?: boolean
|
||||
mode?: AgentRosterDrawerMode
|
||||
open: boolean
|
||||
portalContainerRef: RefObject<HTMLDivElement | null>
|
||||
showAccessIcon?: boolean
|
||||
showConsoleLink?: boolean
|
||||
showDetailActions?: boolean
|
||||
onMakeCopy?: () => void
|
||||
onSaveInlineToRoster?: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const isSetup = mode === 'setup'
|
||||
const title = isInlineSetup ? t(`${i18nPrefix}.roster.inlineSetup.name`, { ns: 'workflow' }) : agent.name
|
||||
const description = isSetup ? t(`${i18nPrefix}.roster.inlineSetup.description`, { ns: 'workflow' }) : agent.role
|
||||
const showInlineActions = isInlineSetup && !!onSaveInlineToRoster
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -160,16 +173,27 @@ function AgentRosterDrawer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</button>
|
||||
<div className="flex h-3.5 items-start px-1">
|
||||
<div className="h-full w-px bg-divider-regular" />
|
||||
</div>
|
||||
{showInlineActions && (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(`${i18nPrefix}.roster.more`, { ns: 'workflow' })}
|
||||
className="flex size-6 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-state-base-hover"
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44 w-max">
|
||||
<DropdownMenuItem className="gap-2 whitespace-nowrap" onClick={onSaveInlineToRoster}>
|
||||
<span aria-hidden className="i-ri-inbox-archive-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t('roster.saveToRoster', { ns: 'agentV2' })}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex h-3.5 items-start px-1">
|
||||
<div className="h-full w-px bg-divider-regular" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<DrawerCloseButton
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
className="size-6 rounded-md"
|
||||
@@ -193,6 +217,8 @@ function AgentRosterDrawer({
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
className="min-w-0 flex-1 gap-1.5 px-3"
|
||||
loading={isCopyPending}
|
||||
onClick={onMakeCopy}
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-copy-2-line size-4 shrink-0" />
|
||||
<span className="truncate">
|
||||
@@ -222,6 +248,7 @@ export function AgentRosterField({
|
||||
agentId,
|
||||
canOpenPanel = true,
|
||||
isPanelOpen,
|
||||
isPanelCopyPending = false,
|
||||
isPending = false,
|
||||
isLoading = false,
|
||||
isInlineSetup = false,
|
||||
@@ -230,13 +257,16 @@ export function AgentRosterField({
|
||||
showPanelDetailActions = true,
|
||||
portalContainerRef,
|
||||
onChange,
|
||||
onMakeCopy,
|
||||
onPanelOpenChange,
|
||||
onSaveInlineToRoster,
|
||||
onStartFromScratch,
|
||||
}: {
|
||||
agent?: AgentRosterDisplayData
|
||||
agentId?: string
|
||||
canOpenPanel?: boolean
|
||||
isPanelOpen?: boolean
|
||||
isPanelCopyPending?: boolean
|
||||
isLoading?: boolean
|
||||
isInlineSetup?: boolean
|
||||
isPending?: boolean
|
||||
@@ -245,7 +275,9 @@ export function AgentRosterField({
|
||||
showPanelDetailActions?: boolean
|
||||
portalContainerRef: RefObject<HTMLDivElement | null>
|
||||
onChange: (agent: AgentRosterNodeData) => void
|
||||
onMakeCopy?: () => void
|
||||
onPanelOpenChange?: (open: boolean) => void
|
||||
onSaveInlineToRoster?: () => void
|
||||
onStartFromScratch?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
@@ -358,6 +390,9 @@ export function AgentRosterField({
|
||||
portalContainerRef={portalContainerRef}
|
||||
showAccessIcon={!isInlineSetup}
|
||||
showDetailActions={showPanelDetailActions}
|
||||
isCopyPending={isPanelCopyPending}
|
||||
onMakeCopy={onMakeCopy}
|
||||
onSaveInlineToRoster={onSaveInlineToRoster}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
>
|
||||
{panelBody}
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentComposerAgentResponse, AgentComposerBindingResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentFormValues, AgentIconSelection } from '@/features/agent-v2/roster/components/agent-form'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import { createAgentIconSelection, defaultAgentIcon } from '@/features/agent-v2/roster/components/agent-form'
|
||||
import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type SaveInlineAgentToRosterDialogProps = {
|
||||
appId?: string
|
||||
formKey: number
|
||||
initialAgent?: AgentComposerAgentResponse | null
|
||||
nodeId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: (binding: AgentComposerBindingResponse) => void
|
||||
}
|
||||
|
||||
export function SaveInlineAgentToRosterDialog({
|
||||
appId,
|
||||
formKey,
|
||||
initialAgent,
|
||||
nodeId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: SaveInlineAgentToRosterDialogProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [name, setName] = useState(initialAgent?.name ?? '')
|
||||
const [description, setDescription] = useState(initialAgent?.description ?? '')
|
||||
const [role, setRole] = useState(initialAgent?.role ?? '')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [isIconChanged, setIsIconChanged] = useState(false)
|
||||
const [agentIcon, setAgentIcon] = useState<AgentIconSelection>(() => initialAgent
|
||||
? createAgentIconSelection(initialAgent)
|
||||
: defaultAgentIcon)
|
||||
const saveToRosterMutation = useMutation(
|
||||
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(),
|
||||
)
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setName(initialAgent?.name ?? '')
|
||||
setDescription(initialAgent?.description ?? '')
|
||||
setRole(initialAgent?.role ?? '')
|
||||
setIsIconChanged(false)
|
||||
setAgentIcon(initialAgent
|
||||
? createAgentIconSelection(initialAgent)
|
||||
: defaultAgentIcon)
|
||||
}
|
||||
else {
|
||||
setIconPickerOpen(false)
|
||||
}
|
||||
onOpenChange(nextOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = (formValues: AgentFormValues) => {
|
||||
if (saveToRosterMutation.isPending)
|
||||
return
|
||||
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const trimmedName = formValues.name?.trim() ?? ''
|
||||
const trimmedRole = formValues.role?.trim() ?? ''
|
||||
|
||||
saveToRosterMutation.mutate({
|
||||
params: {
|
||||
app_id: appId,
|
||||
node_id: nodeId,
|
||||
},
|
||||
body: {
|
||||
variant: 'workflow',
|
||||
save_strategy: 'save_to_roster',
|
||||
new_agent_name: trimmedName,
|
||||
description: formValues.description?.trim() ?? '',
|
||||
role: trimmedRole,
|
||||
...(isIconChanged
|
||||
? {
|
||||
icon_type: agentIcon.type,
|
||||
icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon,
|
||||
icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}, {
|
||||
onSuccess: (composerState) => {
|
||||
const binding = composerState.binding
|
||||
if (binding?.binding_type !== 'roster_agent' || !binding.agent_id)
|
||||
return
|
||||
|
||||
toast.success(t('roster.saveToRosterSuccess'))
|
||||
onSaved(binding)
|
||||
handleOpenChange(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} disablePointerDismissal>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-130 flex-col overflow-hidden! p-0!">
|
||||
<DialogCloseButton />
|
||||
<div className="shrink-0 pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('roster.saveToRosterDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('roster.saveToRosterDialog.description')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Form<AgentFormValues>
|
||||
key={formKey}
|
||||
className="min-h-0 flex-1"
|
||||
onFormSubmit={handleSubmit}
|
||||
>
|
||||
<AgentFormFields
|
||||
description={description}
|
||||
icon={agentIcon}
|
||||
iconAriaLabel={t('roster.saveToRosterForm.changeIcon')}
|
||||
name={name}
|
||||
role={role}
|
||||
onDescriptionChange={setDescription}
|
||||
onIconClick={() => setIconPickerOpen(true)}
|
||||
onNameChange={setName}
|
||||
onRoleChange={setRole}
|
||||
/>
|
||||
<div className="flex shrink-0 justify-end gap-2 px-6 pt-5 pb-6">
|
||||
<Button type="button" className="min-w-18" onClick={() => handleOpenChange(false)} disabled={saveToRosterMutation.isPending}>
|
||||
{tCommon('operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="min-w-18"
|
||||
loading={saveToRosterMutation.isPending}
|
||||
>
|
||||
{tCommon('operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AppIconPicker
|
||||
open={iconPickerOpen}
|
||||
initialEmoji={agentIcon.type === 'emoji'
|
||||
? { icon: agentIcon.icon, background: agentIcon.background }
|
||||
: undefined}
|
||||
onOpenChange={setIconPickerOpen}
|
||||
onSelect={(icon) => {
|
||||
setAgentIcon(icon)
|
||||
setIsIconChanged(true)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
+68
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
+364
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
+4
-2
@@ -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
Reference in New Issue
Block a user