mirror of
https://github.com/langgenius/dify.git
synced 2026-07-29 08:10:53 +08:00
perf: add lightweight recent apps endpoint (#39625)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
4313d23889
commit
b3774bfe1c
@ -58,6 +58,7 @@ from services.app_service import (
|
||||
AppResponseView,
|
||||
AppService,
|
||||
CreateAppParams,
|
||||
RecentAppMode,
|
||||
StarredAppListParams,
|
||||
)
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
@ -139,6 +140,10 @@ class AppListBaseQuery(BaseModel):
|
||||
raise ValueError("Invalid UUID format in creator_ids.") from exc
|
||||
|
||||
|
||||
class RecentAppListQuery(BaseModel):
|
||||
limit: int = Field(default=8, ge=1, le=8, description="Number of recently modified apps to return (1-8)")
|
||||
|
||||
|
||||
class AppListQuery(AppListBaseQuery):
|
||||
pass
|
||||
|
||||
@ -411,6 +416,33 @@ class AppPartial(AppResponseModel):
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class RecentAppResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
icon_type: IconType | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
mode: RecentAppMode
|
||||
author_name: str | None = None
|
||||
updated_at: int
|
||||
permission_keys: list[str] = Field(default_factory=list)
|
||||
maintainer: str | None = None
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore[prop-decorator]
|
||||
@property
|
||||
def icon_url(self) -> str | None:
|
||||
return build_icon_url(self.icon_type, self.icon)
|
||||
|
||||
@field_validator("updated_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int) -> int:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class RecentAppListResponse(ResponseModel):
|
||||
data: list[RecentAppResponse]
|
||||
|
||||
|
||||
class AppDetail(AppResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
@ -575,6 +607,8 @@ register_schema_models(
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AppPartial,
|
||||
RecentAppResponse,
|
||||
RecentAppListResponse,
|
||||
AppDetailWithSite,
|
||||
AppPagination,
|
||||
)
|
||||
@ -699,6 +733,49 @@ class AppListApi(Resource):
|
||||
return app_detail.model_dump(mode="json"), 201
|
||||
|
||||
|
||||
@console_ns.route("/apps/recent")
|
||||
class RecentAppListApi(Resource):
|
||||
@console_ns.doc("list_recent_apps")
|
||||
@console_ns.doc(description="Get recently modified apps for the home Continue Work section")
|
||||
@console_ns.doc(params=query_params_from_model(RecentAppListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[RecentAppListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_session(write=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
|
||||
"""Return the lightweight app cards needed by the Explore home page."""
|
||||
args = query_params_from_request(RecentAppListQuery)
|
||||
params = AppListParams(limit=args.limit)
|
||||
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
current_tenant_id,
|
||||
current_user_id,
|
||||
session=session,
|
||||
)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
access_filter = resolve_app_access_filter(
|
||||
current_tenant_id,
|
||||
current_user_id,
|
||||
session=session,
|
||||
permissions=permissions,
|
||||
)
|
||||
access_filter.apply_to_params(params)
|
||||
|
||||
recent_apps = AppService().get_recent_apps(current_user_id, current_tenant_id, params, session)
|
||||
permission_keys_map = permissions.app.permission_keys_by_resource_ids([app.id for app in recent_apps])
|
||||
response_items = [
|
||||
RecentAppResponse.model_validate(app, from_attributes=True).model_copy(
|
||||
update={"permission_keys": permission_keys_map.get(app.id, [])}
|
||||
)
|
||||
for app in recent_apps
|
||||
]
|
||||
return dump_response(RecentAppListResponse, {"data": response_items}), 200
|
||||
|
||||
|
||||
@console_ns.route("/apps/starred")
|
||||
class StarredAppListApi(Resource):
|
||||
@console_ns.doc("list_starred_apps")
|
||||
|
||||
@ -1672,6 +1672,23 @@ Create a new application
|
||||
| 200 | Import confirmed | **application/json**: [Import](#import)<br> |
|
||||
| 400 | Import failed | **application/json**: [Import](#import)<br> |
|
||||
|
||||
### [GET] /apps/recent
|
||||
**Return the lightweight app cards needed by the Explore home page**
|
||||
|
||||
Get recently modified apps for the home Continue Work section
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| limit | query | Number of recently modified apps to return (1-8) | No | integer, <br>**Default:** 8 |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [RecentAppListResponse](#recentapplistresponse)<br> |
|
||||
|
||||
### [GET] /apps/starred
|
||||
Get applications starred by the current account
|
||||
|
||||
@ -21018,6 +21035,28 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
|
||||
| result | string | | Yes |
|
||||
| updated_at | integer | | Yes |
|
||||
|
||||
#### RecentAppListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [RecentAppResponse](#recentappresponse) ] | | Yes |
|
||||
|
||||
#### RecentAppResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| author_name | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | [IconType](#icontype) | | No |
|
||||
| icon_url | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| maintainer | string | | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | *Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes |
|
||||
| name | string | | Yes |
|
||||
| permission_keys | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
|
||||
#### RecommendedAppDetailNullableResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, NotRequired, TypedDict, cast, override
|
||||
|
||||
@ -41,6 +42,20 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AppListSortBy = Literal["last_modified", "recently_created", "earliest_created"]
|
||||
RecentAppMode = Literal[
|
||||
AppMode.COMPLETION,
|
||||
AppMode.WORKFLOW,
|
||||
AppMode.CHAT,
|
||||
AppMode.ADVANCED_CHAT,
|
||||
AppMode.AGENT_CHAT,
|
||||
]
|
||||
RECENT_APP_MODES: tuple[RecentAppMode, ...] = (
|
||||
AppMode.COMPLETION,
|
||||
AppMode.WORKFLOW,
|
||||
AppMode.CHAT,
|
||||
AppMode.ADVANCED_CHAT,
|
||||
AppMode.AGENT_CHAT,
|
||||
)
|
||||
|
||||
|
||||
class AppListBaseParams(BaseModel):
|
||||
@ -65,6 +80,19 @@ class StarredAppListParams(AppListBaseParams):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecentAppListItem:
|
||||
id: str
|
||||
name: str
|
||||
icon_type: IconType | None
|
||||
icon: str | None
|
||||
icon_background: str | None
|
||||
mode: RecentAppMode
|
||||
author_name: str | None
|
||||
updated_at: datetime
|
||||
maintainer: str | None
|
||||
|
||||
|
||||
class CreateAppParams(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
description: str | None = None
|
||||
@ -323,6 +351,62 @@ class AppService:
|
||||
|
||||
return app_models
|
||||
|
||||
def get_recent_apps(
|
||||
self,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
params: AppListParams,
|
||||
session: Session,
|
||||
) -> list[RecentAppListItem]:
|
||||
"""Return recently modified apps as one lightweight, non-paginated projection."""
|
||||
filters = self._build_app_list_filters(user_id, tenant_id, params, session)
|
||||
if not filters:
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
sa.select(
|
||||
App.id,
|
||||
App.name,
|
||||
App.icon_type,
|
||||
App.icon,
|
||||
App.icon_background,
|
||||
App.mode,
|
||||
Account.name.label("author_name"),
|
||||
App.updated_at,
|
||||
App.maintainer,
|
||||
)
|
||||
.outerjoin(Account, Account.id == App.created_by)
|
||||
.where(*filters, App.mode.in_(RECENT_APP_MODES))
|
||||
.order_by(App.updated_at.desc())
|
||||
.limit(params.limit)
|
||||
)
|
||||
rows = session.execute(stmt).all()
|
||||
|
||||
return [
|
||||
RecentAppListItem(
|
||||
id=str(app_id),
|
||||
name=name,
|
||||
icon_type=icon_type,
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
mode=cast(RecentAppMode, mode),
|
||||
author_name=author_name,
|
||||
updated_at=updated_at,
|
||||
maintainer=maintainer,
|
||||
)
|
||||
for (
|
||||
app_id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
mode,
|
||||
author_name,
|
||||
updated_at,
|
||||
maintainer,
|
||||
) in rows
|
||||
]
|
||||
|
||||
def get_paginate_starred_apps(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -708,6 +708,121 @@ def test_app_list_api_attaches_permission_keys(app, app_module):
|
||||
assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
|
||||
|
||||
|
||||
def test_recent_app_list_api_returns_only_home_card_fields(app, app_module):
|
||||
method = app_module.RecentAppListApi.get
|
||||
while hasattr(method, "__wrapped__"):
|
||||
method = method.__wrapped__
|
||||
|
||||
recent_app = SimpleNamespace(
|
||||
id="app-1",
|
||||
name="Recent App",
|
||||
icon_type="emoji",
|
||||
icon="🚀",
|
||||
icon_background="#FFFFFF",
|
||||
mode="chat",
|
||||
author_name="Recent Author",
|
||||
updated_at=_ts(15),
|
||||
maintainer="acct-1",
|
||||
)
|
||||
get_recent_apps = MagicMock(return_value=[recent_app])
|
||||
|
||||
with app.test_request_context("/apps/recent?limit=8"):
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
monkeypatch.setattr(dify_config, "RBAC_ENABLED", False)
|
||||
monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps)
|
||||
monkeypatch.setattr(
|
||||
app_module.enterprise_rbac_service.RBACService.MyPermissions,
|
||||
"get",
|
||||
lambda tenant_id, account_id, session: app_module.enterprise_rbac_service.MyPermissionsResponse(
|
||||
app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot(
|
||||
overrides=[
|
||||
app_module.enterprise_rbac_service.ResourcePermissionKeys(
|
||||
resource_id="app-1",
|
||||
permission_keys=["app.acl.monitor"],
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock())
|
||||
|
||||
assert status == 200
|
||||
assert resp == {
|
||||
"data": [
|
||||
{
|
||||
"id": "app-1",
|
||||
"name": "Recent App",
|
||||
"icon_type": "emoji",
|
||||
"icon": "🚀",
|
||||
"icon_background": "#FFFFFF",
|
||||
"mode": "chat",
|
||||
"author_name": "Recent Author",
|
||||
"updated_at": int(_ts(15).timestamp()),
|
||||
"permission_keys": ["app.acl.monitor"],
|
||||
"maintainer": "acct-1",
|
||||
"icon_url": None,
|
||||
}
|
||||
]
|
||||
}
|
||||
params = get_recent_apps.call_args.args[2]
|
||||
assert params.limit == 8
|
||||
assert "total" not in resp
|
||||
assert "description" not in resp["data"][0]
|
||||
assert "tags" not in resp["data"][0]
|
||||
assert "workflow" not in resp["data"][0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["channel", "rag-pipeline", "agent"])
|
||||
def test_recent_app_response_rejects_non_home_app_modes(app_module, mode: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
app_module.RecentAppResponse.model_validate(
|
||||
{
|
||||
"id": "app-1",
|
||||
"name": "Recent App",
|
||||
"mode": mode,
|
||||
"updated_at": _ts(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module):
|
||||
method = app_module.RecentAppListApi.get
|
||||
while hasattr(method, "__wrapped__"):
|
||||
method = method.__wrapped__
|
||||
|
||||
get_recent_apps = MagicMock(return_value=[])
|
||||
with app.test_request_context("/apps/recent"):
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
|
||||
monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps)
|
||||
monkeypatch.setattr(
|
||||
app_module.enterprise_rbac_service.RBACService.MyPermissions,
|
||||
"get",
|
||||
lambda tenant_id, account_id, session: app_module.enterprise_rbac_service.MyPermissionsResponse(
|
||||
workspace=app_module.enterprise_rbac_service.WorkspacePermissionSnapshot(
|
||||
permission_keys=["app.create_and_management"]
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.enterprise_rbac_service.RBACService.AppAccess,
|
||||
"whitelist_resources",
|
||||
lambda tenant_id, account_id: SimpleNamespace(
|
||||
unrestricted=False,
|
||||
resource_ids=["app-shared"],
|
||||
),
|
||||
)
|
||||
|
||||
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock())
|
||||
|
||||
assert status == 200
|
||||
assert resp == {"data": []}
|
||||
params = get_recent_apps.call_args.args[2]
|
||||
assert params.accessible_app_ids == ["app-shared"]
|
||||
assert params.include_own_apps is True
|
||||
|
||||
|
||||
def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(app, app_module):
|
||||
method = app_module.AppListApi.get
|
||||
while hasattr(method, "__wrapped__"):
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models import Account
|
||||
from models.model import App, AppMode, AppModelConfig
|
||||
from models.model import App, AppMode, AppModelConfig, IconType
|
||||
from models.workflow import Workflow
|
||||
from services.agent.errors import AgentNameConflictError
|
||||
from services.app_service import AppService, CreateAppParams
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
|
||||
|
||||
class TestCreateAppTransactionBoundary:
|
||||
@ -236,6 +240,92 @@ class TestOpenapiVisibilityHelpers:
|
||||
mock_session.execute.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, App, AppModelConfig)], indirect=True)
|
||||
def test_get_recent_apps_uses_one_tenant_scoped_projection_query(sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
other_tenant_id = str(uuid4())
|
||||
account = Account(name="Recent Apps Author", email="recent-apps@example.com")
|
||||
sqlite_session.add(account)
|
||||
sqlite_session.flush()
|
||||
|
||||
def create_app(*, name: str, tenant_id: str, updated_at: datetime, mode: AppMode = AppMode.CHAT) -> App:
|
||||
app = App()
|
||||
app.id = str(uuid4())
|
||||
app.tenant_id = tenant_id
|
||||
app.name = name
|
||||
app.description = ""
|
||||
app.mode = mode
|
||||
app.icon_type = IconType.EMOJI
|
||||
app.icon = "🚀"
|
||||
app.icon_background = "#FFFFFF"
|
||||
app.enable_site = False
|
||||
app.enable_api = False
|
||||
app.created_by = account.id
|
||||
app.maintainer = account.id
|
||||
app.created_at = updated_at
|
||||
app.updated_at = updated_at
|
||||
app.use_icon_as_answer_icon = False
|
||||
return app
|
||||
|
||||
newest = create_app(name="Newest", tenant_id=tenant_id, updated_at=datetime(2026, 7, 3))
|
||||
legacy_agent = AppModelConfig(app_id=newest.id)
|
||||
legacy_agent.agent_mode = '{"enabled": true, "strategy": "react"}'
|
||||
newest.app_model_config_id = legacy_agent.id
|
||||
second = create_app(
|
||||
name="Second",
|
||||
tenant_id=tenant_id,
|
||||
updated_at=datetime(2026, 7, 2),
|
||||
mode=AppMode.WORKFLOW,
|
||||
)
|
||||
second.icon_type = None
|
||||
second.icon = None
|
||||
second.icon_background = None
|
||||
second.created_by = None
|
||||
second.maintainer = None
|
||||
channel = create_app(
|
||||
name="Channel",
|
||||
tenant_id=tenant_id,
|
||||
updated_at=datetime(2026, 7, 5),
|
||||
mode=AppMode.CHANNEL,
|
||||
)
|
||||
rag_pipeline = create_app(
|
||||
name="RAG Pipeline",
|
||||
tenant_id=tenant_id,
|
||||
updated_at=datetime(2026, 7, 4),
|
||||
mode=AppMode.RAG_PIPELINE,
|
||||
)
|
||||
oldest = create_app(name="Oldest", tenant_id=tenant_id, updated_at=datetime(2026, 7, 1))
|
||||
foreign = create_app(name="Foreign", tenant_id=other_tenant_id, updated_at=datetime(2026, 7, 4))
|
||||
sqlite_session.add_all([newest, legacy_agent, second, channel, rag_pipeline, oldest, foreign])
|
||||
sqlite_session.commit()
|
||||
|
||||
statements: list[str] = []
|
||||
bind = sqlite_session.get_bind()
|
||||
|
||||
def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(bind, "before_cursor_execute", record_sql)
|
||||
try:
|
||||
recent_apps = AppService().get_recent_apps(
|
||||
account.id,
|
||||
tenant_id,
|
||||
AppListParams(limit=2),
|
||||
sqlite_session,
|
||||
)
|
||||
finally:
|
||||
event.remove(bind, "before_cursor_execute", record_sql)
|
||||
|
||||
assert [(app.name, app.mode, app.icon_type, app.author_name, app.maintainer) for app in recent_apps] == [
|
||||
("Newest", AppMode.CHAT, IconType.EMOJI, "Recent Apps Author", account.id),
|
||||
("Second", AppMode.WORKFLOW, None, None, None),
|
||||
]
|
||||
select_statements = [statement for statement in statements if statement.lstrip().upper().startswith("SELECT")]
|
||||
assert len(select_statements) == 1
|
||||
assert "count(" not in select_statements[0].lower()
|
||||
assert "app_model_configs" not in select_statements[0].lower()
|
||||
|
||||
|
||||
class TestAppMeta:
|
||||
def test_loads_workflow_with_caller_session(self):
|
||||
session = MagicMock()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -80,6 +80,10 @@ export type CheckDependenciesResult = {
|
||||
leaked_dependencies?: Array<PluginDependency>
|
||||
}
|
||||
|
||||
export type RecentAppListResponse = {
|
||||
data: Array<RecentAppResponse>
|
||||
}
|
||||
|
||||
export type WorkflowOnlineUsersPayload = {
|
||||
app_ids?: Array<string>
|
||||
}
|
||||
@ -1408,6 +1412,20 @@ export type PluginDependency = {
|
||||
value: Github | Marketplace | Package
|
||||
}
|
||||
|
||||
export type RecentAppResponse = {
|
||||
author_name?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: IconType | null
|
||||
readonly icon_url: string | null
|
||||
id: string
|
||||
maintainer?: string | null
|
||||
mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
|
||||
name: string
|
||||
permission_keys?: Array<string>
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type WorkflowOnlineUsersByApp = {
|
||||
app_id: string
|
||||
users: Array<WorkflowOnlineUser>
|
||||
@ -3114,6 +3132,10 @@ export type AppDetailWithSiteWritable = {
|
||||
workflow?: WorkflowPartial | null
|
||||
}
|
||||
|
||||
export type RecentAppListResponseWritable = {
|
||||
data: Array<RecentAppResponseWritable>
|
||||
}
|
||||
|
||||
export type GeneratedAppResponseWritable = JsonValue
|
||||
|
||||
export type WorkflowCommentBasicListWritable = {
|
||||
@ -3196,6 +3218,19 @@ export type AppDetailSiteResponseWritable = {
|
||||
use_icon_as_answer_icon?: boolean | null
|
||||
}
|
||||
|
||||
export type RecentAppResponseWritable = {
|
||||
author_name?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: IconType | null
|
||||
id: string
|
||||
maintainer?: string | null
|
||||
mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
|
||||
name: string
|
||||
permission_keys?: Array<string>
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type WorkflowCommentBasicWritable = {
|
||||
content: string
|
||||
created_at?: number | null
|
||||
@ -3356,6 +3391,21 @@ export type PostAppsImportsByImportIdConfirmResponses = {
|
||||
export type PostAppsImportsByImportIdConfirmResponse =
|
||||
PostAppsImportsByImportIdConfirmResponses[keyof PostAppsImportsByImportIdConfirmResponses]
|
||||
|
||||
export type GetAppsRecentData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
limit?: number
|
||||
}
|
||||
url: '/apps/recent'
|
||||
}
|
||||
|
||||
export type GetAppsRecentResponses = {
|
||||
200: RecentAppListResponse
|
||||
}
|
||||
|
||||
export type GetAppsRecentResponse = GetAppsRecentResponses[keyof GetAppsRecentResponses]
|
||||
|
||||
export type GetAppsStarredData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@ -1076,6 +1076,30 @@ export const zAppImportResponse = z.object({
|
||||
warnings: z.array(zDslImportWarning).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RecentAppResponse
|
||||
*/
|
||||
export const zRecentAppResponse = z.object({
|
||||
author_name: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: zIconType.nullish(),
|
||||
icon_url: z.string().nullable(),
|
||||
id: z.string(),
|
||||
maintainer: z.string().nullish(),
|
||||
mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']),
|
||||
name: z.string(),
|
||||
permission_keys: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RecentAppListResponse
|
||||
*/
|
||||
export const zRecentAppListResponse = z.object({
|
||||
data: z.array(zRecentAppResponse),
|
||||
})
|
||||
|
||||
export const zJsonValue = z
|
||||
.union([
|
||||
z.string(),
|
||||
@ -4279,6 +4303,29 @@ export const zAppDetailWithSiteWritable = z.object({
|
||||
workflow: zWorkflowPartial.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RecentAppResponse
|
||||
*/
|
||||
export const zRecentAppResponseWritable = z.object({
|
||||
author_name: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: zIconType.nullish(),
|
||||
id: z.string(),
|
||||
maintainer: z.string().nullish(),
|
||||
mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']),
|
||||
name: z.string(),
|
||||
permission_keys: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RecentAppListResponse
|
||||
*/
|
||||
export const zRecentAppListResponseWritable = z.object({
|
||||
data: z.array(zRecentAppResponseWritable),
|
||||
})
|
||||
|
||||
/**
|
||||
* AccountWithRoleResponse
|
||||
*/
|
||||
@ -4442,6 +4489,15 @@ export const zPostAppsImportsByImportIdConfirmPath = z.object({
|
||||
*/
|
||||
export const zPostAppsImportsByImportIdConfirmResponse = zImport
|
||||
|
||||
export const zGetAppsRecentQuery = z.object({
|
||||
limit: z.int().gte(1).lte(8).optional().default(8),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetAppsRecentResponse = zRecentAppListResponse
|
||||
|
||||
export const zGetAppsStarredQuery = z.object({
|
||||
creator_ids: z.array(z.string()).optional(),
|
||||
is_created_by_me: z.boolean().optional(),
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type {
|
||||
StepByStepTourStatePatchPayload,
|
||||
StepByStepTourStateResponse,
|
||||
@ -9,7 +10,6 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
|
||||
import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types'
|
||||
import type { Banner as BannerType } from '@/models/app'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider, useSetAtom } from 'jotai'
|
||||
@ -57,7 +57,7 @@ let mockExploreData: { categories: string[]; allList: App[] } | undefined = {
|
||||
}
|
||||
let mockLearnDifyApps: App[] = []
|
||||
let mockLearnDifyLoading = false
|
||||
let mockWorkspaceApps: WorkspaceApp[] = []
|
||||
let mockWorkspaceApps: RecentAppResponse[] = []
|
||||
let mockWorkspaceAppsLoading = false
|
||||
let mockBanners: BannerType[] = []
|
||||
let mockBannersLoading = false
|
||||
@ -67,6 +67,10 @@ const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
const mockTrackCreateApp = vi.fn()
|
||||
const mockTrackEvent = vi.hoisted(() => vi.fn())
|
||||
const mockAppQueries = vi.hoisted(() => ({
|
||||
listQueryOptions: vi.fn(),
|
||||
recentQueryOptions: vi.fn(),
|
||||
}))
|
||||
const mockStepByStepTour = vi.hoisted(() => {
|
||||
const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const
|
||||
const createState = (
|
||||
@ -242,13 +246,14 @@ vi.mock('@/service/client', () => ({
|
||||
queryOptions: (options: {
|
||||
input?: { query?: { limit?: number } }
|
||||
select?: (response: {
|
||||
data: WorkspaceApp[]
|
||||
data: RecentAppResponse[]
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
total: number
|
||||
}) => unknown
|
||||
}) => {
|
||||
mockAppQueries.listQueryOptions(options)
|
||||
const limit = options.input?.query?.limit ?? mockWorkspaceApps.length
|
||||
if (mockWorkspaceAppsLoading) {
|
||||
return {
|
||||
@ -272,6 +277,33 @@ vi.mock('@/service/client', () => ({
|
||||
}
|
||||
},
|
||||
},
|
||||
recent: {
|
||||
get: {
|
||||
queryOptions: (options: {
|
||||
input?: { query?: { limit?: number } }
|
||||
select?: (response: { data: RecentAppResponse[] }) => unknown
|
||||
}) => {
|
||||
mockAppQueries.recentQueryOptions(options)
|
||||
const limit = options.input?.query?.limit ?? mockWorkspaceApps.length
|
||||
if (mockWorkspaceAppsLoading) {
|
||||
return {
|
||||
queryKey: ['console', 'apps', 'recent', 'get', options],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
select: options.select,
|
||||
}
|
||||
}
|
||||
const response = {
|
||||
data: mockWorkspaceApps.slice(0, limit),
|
||||
}
|
||||
return {
|
||||
queryKey: ['console', 'apps', 'recent', 'get', options],
|
||||
queryFn: () => Promise.resolve(response),
|
||||
initialData: response,
|
||||
select: options.select,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
stepByStepTour: {
|
||||
@ -455,33 +487,19 @@ const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
is_agent: overrides.is_agent ?? false,
|
||||
})
|
||||
|
||||
const createWorkspaceApp = (overrides: Partial<WorkspaceApp> = {}): WorkspaceApp =>
|
||||
({
|
||||
id: overrides.id ?? 'workspace-app-1',
|
||||
name: overrides.name ?? 'Workspace App',
|
||||
description: overrides.description ?? 'Workspace app description',
|
||||
author_name: overrides.author_name ?? 'Evan',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '😀',
|
||||
icon_background: overrides.icon_background ?? '#fff',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
|
||||
mode: overrides.mode ?? AppModeEnum.CHAT,
|
||||
created_at: overrides.created_at ?? 1704067200,
|
||||
updated_at: overrides.updated_at ?? 1704153600,
|
||||
enable_site: overrides.enable_site ?? false,
|
||||
enable_api: overrides.enable_api ?? false,
|
||||
api_rpm: overrides.api_rpm ?? 60,
|
||||
api_rph: overrides.api_rph ?? 3600,
|
||||
is_demo: overrides.is_demo ?? false,
|
||||
model_config: overrides.model_config,
|
||||
app_model_config: overrides.app_model_config,
|
||||
site: overrides.site,
|
||||
api_base_url: overrides.api_base_url ?? '',
|
||||
tags: overrides.tags ?? [],
|
||||
access_mode: overrides.access_mode,
|
||||
permission_keys: overrides.permission_keys,
|
||||
}) as WorkspaceApp
|
||||
const createWorkspaceApp = (overrides: Partial<RecentAppResponse> = {}): RecentAppResponse => ({
|
||||
id: overrides.id ?? 'workspace-app-1',
|
||||
name: overrides.name ?? 'Workspace App',
|
||||
author_name: overrides.author_name ?? 'Evan',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '😀',
|
||||
icon_background: overrides.icon_background ?? '#fff',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
mode: overrides.mode ?? 'chat',
|
||||
updated_at: overrides.updated_at ?? 1704153600,
|
||||
maintainer: overrides.maintainer ?? 'user-1',
|
||||
permission_keys: overrides.permission_keys,
|
||||
})
|
||||
|
||||
const createBanner = (overrides: Partial<BannerType> = {}): BannerType => ({
|
||||
id: overrides.id ?? 'banner-1',
|
||||
@ -748,6 +766,27 @@ describe('AppList', () => {
|
||||
).toHaveAttribute('href', '/apps')
|
||||
})
|
||||
|
||||
it('should load continue work from the lightweight recent apps query', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockWorkspaceApps = [createWorkspaceApp()]
|
||||
|
||||
renderAppList()
|
||||
|
||||
expect(mockAppQueries.recentQueryOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: {
|
||||
query: {
|
||||
limit: 8,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(mockAppQueries.listQueryOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render preview-only continue work app as a dimmed card and warn on click', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import ContinueWork from '@/app/components/explore/continue-work'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
@ -17,7 +17,7 @@ export function ExploreRecommendations({
|
||||
onTry,
|
||||
}: {
|
||||
canCreate: boolean
|
||||
continueWorkApps: WorkspaceApp[]
|
||||
continueWorkApps: RecentAppResponse[]
|
||||
forceShowLearnDify?: boolean
|
||||
onCreate: (app: App) => void
|
||||
onTry: (params: TryAppSelection) => void
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { StepByStepTourTaskId } from '@/app/components/step-by-step-tour/types'
|
||||
import type { Banner as BannerType } from '@/models/app'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@ -39,7 +39,6 @@ import { DSLImportMode } from '@/models/app'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore'
|
||||
import { normalizeAppPagination } from '@/service/use-apps'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { ExploreAppListHeader } from './explore-app-list-header'
|
||||
@ -61,9 +60,7 @@ type ExploreAppListData = {
|
||||
|
||||
const homeContinueWorkAppsInput = {
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 8,
|
||||
name: '',
|
||||
},
|
||||
}
|
||||
|
||||
@ -91,9 +88,9 @@ function getExploreAppListQueryOptions(locale?: string) {
|
||||
}
|
||||
|
||||
function getContinueWorkAppsQueryOptions() {
|
||||
return consoleQuery.apps.get.queryOptions({
|
||||
return consoleQuery.apps.recent.get.queryOptions({
|
||||
input: homeContinueWorkAppsInput,
|
||||
select: (response): WorkspaceApp[] => normalizeAppPagination(response).data,
|
||||
select: (response): RecentAppResponse[] => response.data,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AnchorHTMLAttributes, ReactNode } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import ContinueWorkItem from '../item'
|
||||
|
||||
@ -52,37 +50,23 @@ vi.mock('@/next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
const createApp = (overrides: Partial<RecentAppResponse> = {}): RecentAppResponse => ({
|
||||
id: 'app-1',
|
||||
name: 'Continue App',
|
||||
description: 'Continue app description',
|
||||
author_name: 'Alice',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
mode: AppModeEnum.CHAT,
|
||||
enable_site: false,
|
||||
enable_api: false,
|
||||
api_rpm: 60,
|
||||
api_rph: 3600,
|
||||
is_demo: false,
|
||||
model_config: {} as App['model_config'],
|
||||
app_model_config: {} as App['app_model_config'],
|
||||
created_at: 100,
|
||||
mode: 'chat',
|
||||
maintainer: 'maintainer-1',
|
||||
updated_at: 200,
|
||||
site: {} as App['site'],
|
||||
api_base_url: '',
|
||||
tags: [],
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
permission_keys: [AppACLPermission.Edit],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderItem = (
|
||||
app: App,
|
||||
app: RecentAppResponse,
|
||||
systemFeatures: NonNullable<Parameters<typeof renderWithConsoleQuery>[1]>['systemFeatures'] = {
|
||||
rbac_enabled: true,
|
||||
},
|
||||
@ -109,12 +93,6 @@ describe('ContinueWorkItem', () => {
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000)
|
||||
})
|
||||
|
||||
it('should use created time when updated time is missing', () => {
|
||||
renderItem(createApp({ updated_at: 0, created_at: 123 }))
|
||||
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(123000)
|
||||
})
|
||||
|
||||
it('should link to access config when RBAC is enabled and only access config permission is available', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }))
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -8,7 +8,7 @@ import Link from '@/next/link'
|
||||
import ContinueWorkItem from './item'
|
||||
|
||||
type ContinueWorkProps = {
|
||||
apps: WorkspaceApp[]
|
||||
apps: RecentAppResponse[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { App } from '@/types/app'
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
@ -18,7 +18,7 @@ import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
import { hasOnlyAppPreviewPermission } from '@/utils/permission'
|
||||
|
||||
type ContinueWorkItemProps = {
|
||||
app: App
|
||||
app: RecentAppResponse
|
||||
}
|
||||
|
||||
const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
@ -28,7 +28,7 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const updatedAt = (app.updated_at || app.created_at) * 1000
|
||||
const updatedAt = app.updated_at * 1000
|
||||
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
|
||||
const href = getRedirectionPath(app, {
|
||||
currentUserId,
|
||||
@ -58,7 +58,7 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
icon={app.icon ?? undefined}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user