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:
非法操作 2026-07-27 16:00:39 +08:00 committed by GitHub
parent 4313d23889
commit b3774bfe1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 806 additions and 256 deletions

View File

@ -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")

View File

@ -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 |

View File

@ -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,

View File

@ -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__"):

View File

@ -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

View File

@ -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

View File

@ -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(),

View File

@ -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'],

View File

@ -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

View File

@ -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,
})
}

View File

@ -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] }))

View File

@ -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
}

View File

@ -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}
/>