refactor(web): remove custom console contract loaders (#38284)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou
2026-07-02 11:32:02 +08:00
committed by GitHub
parent 4fc8f0ddd1
commit 61650d34ce
37 changed files with 967 additions and 422 deletions
+13 -1
View File
@@ -51,6 +51,18 @@ jobs:
with:
files: |
api/**
- name: Check frontend contract inputs
if: github.event_name != 'merge_group'
id: frontend-contract-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
api/**
packages/contracts/openapi-ts.api.config.ts
packages/contracts/package.json
packages/contracts/openapi/**
pnpm-lock.yaml
pnpm-workspace.yaml
- name: Check dify-agent inputs
if: github.event_name != 'merge_group'
id: dify-agent-changes
@@ -143,7 +155,7 @@ jobs:
uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json
- name: Generate frontend contracts
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
- name: ESLint autofix
@@ -147,11 +147,15 @@ register_schema_models(
InstalledAppCreatePayload,
InstalledAppUpdatePayload,
InstalledAppsListQuery,
)
register_response_schema_models(
console_ns,
InstalledAppInfoResponse,
InstalledAppResponse,
InstalledAppListResponse,
SimpleMessageResponse,
SimpleResultMessageResponse,
)
register_response_schema_models(console_ns, SimpleMessageResponse, SimpleResultMessageResponse)
@console_ns.route("/installed-apps")
+6 -1
View File
@@ -13,7 +13,12 @@ from services.app_service import AppService
class ExploreAppMetaResponse(BaseModel):
tool_icons: dict[str, Any] = Field(default_factory=dict)
"""Metadata consumed by the installed-app chat UI.
Built-in tool icons are URL strings; API-based tool icons are provider-defined payload objects.
"""
tool_icons: dict[str, str | dict[str, Any]] = Field(default_factory=dict)
register_response_schema_models(console_ns, fields.Parameters, ExploreAppMetaResponse)
@@ -3,7 +3,7 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic import BaseModel, Field, RootModel, computed_field, field_validator
from constants.languages import languages
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
@@ -80,15 +80,23 @@ class RecommendedAppDetailResponse(ResponseModel):
can_trial: bool | None = None
class RecommendedAppDetailNullableResponse(RootModel[RecommendedAppDetailResponse | None]):
pass
register_schema_models(
console_ns,
RecommendedAppsQuery,
)
register_response_schema_models(
console_ns,
RecommendedAppInfoResponse,
RecommendedAppResponse,
RecommendedAppListResponse,
LearnDifyAppListResponse,
RecommendedAppDetailResponse,
RecommendedAppDetailNullableResponse,
)
register_response_schema_models(console_ns, RecommendedAppDetailResponse)
def _resolve_language(language: str | None, user: Account) -> str:
@@ -136,7 +144,7 @@ class LearnDifyAppListApi(Resource):
@console_ns.route("/explore/apps/<uuid:app_id>")
class RecommendedAppApi(Resource):
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[RecommendedAppDetailNullableResponse.__name__])
@login_required
@account_initialization_required
def get(self, app_id: UUID):
+13 -1
View File
@@ -6802,7 +6802,7 @@ Check if dataset is in use
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [RecommendedAppDetailResponse](#recommendedappdetailresponse)<br> |
| 200 | Success | **application/json**: [RecommendedAppDetailNullableResponse](#recommendedappdetailnullableresponse)<br> |
### [GET] /features
**Get feature configuration for current tenant**
@@ -17287,6 +17287,10 @@ The type of the parameter
#### ExploreAppMetaResponse
Metadata consumed by the installed-app chat UI.
Built-in tool icons are URL strings; API-based tool icons are provider-defined payload objects.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| tool_icons | object | | No |
@@ -17984,6 +17988,7 @@ Input field definition for snippet parameters.
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | Yes |
| id | string | | Yes |
| mode | string | | No |
| name | string | | No |
@@ -20080,6 +20085,12 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| result | string | | Yes |
| updated_at | integer | | Yes |
#### RecommendedAppDetailNullableResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| RecommendedAppDetailNullableResponse | [RecommendedAppDetailResponse](#recommendedappdetailresponse) | | |
#### RecommendedAppDetailResponse
| Name | Type | Description | Required |
@@ -20099,6 +20110,7 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| icon | string | | No |
| icon_background | string | | No |
| icon_type | string | | No |
| icon_url | string | | Yes |
| id | string | | Yes |
| mode | string | | No |
| name | string | | No |
@@ -204,6 +204,18 @@ def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp
assert app_detail_schema["properties"]["id"]["type"] == "string"
assert app_detail_schema["properties"]["export_data"]["type"] == "string"
assert {"type": "boolean"} in app_detail_schema["properties"]["can_trial"]["anyOf"]
app_detail_nullable_schema = schemas["RecommendedAppDetailNullableResponse"]
assert _response_schema(paths["/explore/apps/{app_id}"]["get"])["$ref"] == (
"#/components/schemas/RecommendedAppDetailNullableResponse"
)
assert {"$ref": "#/components/schemas/RecommendedAppDetailResponse"} in app_detail_nullable_schema["anyOf"]
assert {"type": "null"} in app_detail_nullable_schema["anyOf"]
assert schemas["RecommendedAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True
assert schemas["InstalledAppInfoResponse"]["properties"]["icon_url"]["readOnly"] is True
tool_icon_schema = schemas["ExploreAppMetaResponse"]["properties"]["tool_icons"]["additionalProperties"]
assert {"type": "string"} in tool_icon_schema["anyOf"]
assert {"additionalProperties": True, "type": "object"} in tool_icon_schema["anyOf"]
assert "ToolIconResponse" not in schemas
plugin_versions = schemas["PluginVersionsResponse"]["properties"]["versions"]
assert plugin_versions["additionalProperties"]["anyOf"][0]["$ref"] == "#/components/schemas/LatestPluginCache"
-10
View File
@@ -7057,11 +7057,6 @@
"count": 1
}
},
"web/service/access-control/__tests__/index.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/__tests__/use-app-access-control.spec.tsx": {
"no-restricted-imports": {
"count": 1
@@ -7087,11 +7082,6 @@
"count": 1
}
},
"web/service/access-control/index.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/use-app-access-control.ts": {
"no-restricted-imports": {
"count": 1
@@ -13,15 +13,7 @@ export type LearnDifyAppListResponse = {
recommended_apps: Array<RecommendedAppResponse>
}
export type RecommendedAppDetailResponse = {
can_trial?: boolean | null
export_data: string
icon?: string | null
icon_background?: string | null
id: string
mode: string
name: string
}
export type RecommendedAppDetailNullableResponse = RecommendedAppDetailResponse | null
export type BannerListResponse = Array<BannerResponse>
@@ -38,6 +30,16 @@ export type RecommendedAppResponse = {
privacy_policy?: string | null
}
export type RecommendedAppDetailResponse = {
can_trial?: boolean | null
export_data: string
icon?: string | null
icon_background?: string | null
id: string
mode: string
name: string
}
export type BannerResponse = {
content: unknown
created_at?: string | null
@@ -48,6 +50,38 @@ export type BannerResponse = {
}
export type RecommendedAppInfoResponse = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
readonly icon_url: string | null
id: string
mode?: string | null
name?: string | null
}
export type RecommendedAppListResponseWritable = {
categories: Array<string>
recommended_apps: Array<RecommendedAppResponseWritable>
}
export type LearnDifyAppListResponseWritable = {
recommended_apps: Array<RecommendedAppResponseWritable>
}
export type RecommendedAppResponseWritable = {
app?: RecommendedAppInfoResponseWritable | null
app_id: string
can_trial?: boolean | null
categories?: Array<string>
copyright?: string | null
custom_disclaimer?: string | null
description?: string | null
is_listed?: boolean | null
position?: number | null
privacy_policy?: string | null
}
export type RecommendedAppInfoResponseWritable = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
@@ -97,7 +131,7 @@ export type GetExploreAppsByAppIdData = {
}
export type GetExploreAppsByAppIdResponses = {
200: RecommendedAppDetailResponse
200: RecommendedAppDetailNullableResponse
}
export type GetExploreAppsByAppIdResponse
@@ -15,6 +15,11 @@ export const zRecommendedAppDetailResponse = z.object({
name: z.string(),
})
/**
* RecommendedAppDetailNullableResponse
*/
export const zRecommendedAppDetailNullableResponse = zRecommendedAppDetailResponse.nullable()
/**
* BannerResponse
*/
@@ -39,6 +44,7 @@ export const zRecommendedAppInfoResponse = z.object({
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_url: z.string().nullable(),
id: z.string(),
mode: z.string().nullish(),
name: z.string().nullish(),
@@ -75,6 +81,49 @@ export const zLearnDifyAppListResponse = z.object({
recommended_apps: z.array(zRecommendedAppResponse),
})
/**
* RecommendedAppInfoResponse
*/
export const zRecommendedAppInfoResponseWritable = z.object({
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
id: z.string(),
mode: z.string().nullish(),
name: z.string().nullish(),
})
/**
* RecommendedAppResponse
*/
export const zRecommendedAppResponseWritable = z.object({
app: zRecommendedAppInfoResponseWritable.nullish(),
app_id: z.string(),
can_trial: z.boolean().nullish(),
categories: z.array(z.string()).optional(),
copyright: z.string().nullish(),
custom_disclaimer: z.string().nullish(),
description: z.string().nullish(),
is_listed: z.boolean().nullish(),
position: z.int().nullish(),
privacy_policy: z.string().nullish(),
})
/**
* RecommendedAppListResponse
*/
export const zRecommendedAppListResponseWritable = z.object({
categories: z.array(z.string()),
recommended_apps: z.array(zRecommendedAppResponseWritable),
})
/**
* LearnDifyAppListResponse
*/
export const zLearnDifyAppListResponseWritable = z.object({
recommended_apps: z.array(zRecommendedAppResponseWritable),
})
export const zGetExploreAppsQuery = z.object({
language: z.string().optional(),
})
@@ -100,7 +149,7 @@ export const zGetExploreAppsByAppIdPath = z.object({
/**
* Success
*/
export const zGetExploreAppsByAppIdResponse = zRecommendedAppDetailResponse
export const zGetExploreAppsByAppIdResponse = zRecommendedAppDetailNullableResponse
export const zGetExploreBannersQuery = z.object({
language: z.string().optional().default('en-US'),
@@ -117,7 +117,11 @@ export type SuggestedQuestionsResponse = {
export type ExploreAppMetaResponse = {
tool_icons?: {
[key: string]: unknown
[key: string]:
| string
| {
[key: string]: unknown
}
}
}
@@ -237,6 +241,7 @@ export type InstalledAppInfoResponse = {
icon?: string | null
icon_background?: string | null
icon_type?: string | null
readonly icon_url: string | null
id: string
mode?: string | null
name?: string | null
@@ -402,6 +407,33 @@ export type FileTransferMethod = 'datasource_file' | 'local_file' | 'remote_url'
export type ValueSourceType = 'constant' | 'variable'
export type InstalledAppListResponseWritable = {
installed_apps: Array<InstalledAppResponseWritable>
}
export type GeneratedAppResponseWritable = JsonValue
export type InstalledAppResponseWritable = {
app: InstalledAppInfoResponseWritable
app_owner_tenant_id: string
editable: boolean
id: string
is_pinned: boolean
last_used_at?: number | null
uninstallable: boolean
}
export type InstalledAppInfoResponseWritable = {
description?: string | null
icon?: string | null
icon_background?: string | null
icon_type?: string | null
id: string
mode?: string | null
name?: string | null
use_icon_as_answer_icon?: boolean | null
}
export type GetInstalledAppsData = {
body?: never
path?: never
@@ -113,9 +113,15 @@ export const zSuggestedQuestionsResponse = z.object({
/**
* ExploreAppMetaResponse
*
* Metadata consumed by the installed-app chat UI.
*
* Built-in tool icons are URL strings; API-based tool icons are provider-defined payload objects.
*/
export const zExploreAppMetaResponse = z.object({
tool_icons: z.record(z.string(), z.unknown()).optional(),
tool_icons: z
.record(z.string(), z.union([z.string(), z.record(z.string(), z.unknown())]))
.optional(),
})
/**
@@ -234,6 +240,7 @@ export const zInstalledAppInfoResponse = z.object({
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
icon_url: z.string().nullable(),
id: z.string(),
mode: z.string().nullish(),
name: z.string().nullish(),
@@ -533,6 +540,45 @@ export const zExploreMessageInfiniteScrollPagination = z.object({
limit: z.int(),
})
/**
* GeneratedAppResponse
*/
export const zGeneratedAppResponseWritable = zJsonValue
/**
* InstalledAppInfoResponse
*/
export const zInstalledAppInfoResponseWritable = z.object({
description: z.string().nullish(),
icon: z.string().nullish(),
icon_background: z.string().nullish(),
icon_type: z.string().nullish(),
id: z.string(),
mode: z.string().nullish(),
name: z.string().nullish(),
use_icon_as_answer_icon: z.boolean().nullish(),
})
/**
* InstalledAppResponse
*/
export const zInstalledAppResponseWritable = z.object({
app: zInstalledAppInfoResponseWritable,
app_owner_tenant_id: z.string(),
editable: z.boolean(),
id: z.string(),
is_pinned: z.boolean(),
last_used_at: z.int().nullish(),
uninstallable: z.boolean(),
})
/**
* InstalledAppListResponse
*/
export const zInstalledAppListResponseWritable = z.object({
installed_apps: z.array(zInstalledAppResponseWritable),
})
export const zGetInstalledAppsQuery = z.object({
app_id: z.string().optional(),
})
@@ -0,0 +1,109 @@
// This file is auto-generated by packages/contracts/openapi-ts.api.config.ts
import { contract as enterpriseContract } from '../../enterprise/orpc.gen'
import { account } from './account/orpc.gen'
import { activate } from './activate/orpc.gen'
import { agent } from './agent/orpc.gen'
import { allWorkspaces } from './all-workspaces/orpc.gen'
import { apiBasedExtension } from './api-based-extension/orpc.gen'
import { apiKeyAuth } from './api-key-auth/orpc.gen'
import { appDslVersion } from './app-dsl-version/orpc.gen'
import { app } from './app/orpc.gen'
import { apps } from './apps/orpc.gen'
import { auth } from './auth/orpc.gen'
import { billing } from './billing/orpc.gen'
import { codeBasedExtension } from './code-based-extension/orpc.gen'
import { compliance } from './compliance/orpc.gen'
import { dataSource } from './data-source/orpc.gen'
import { datasets } from './datasets/orpc.gen'
import { emailCodeLogin } from './email-code-login/orpc.gen'
import { emailRegister } from './email-register/orpc.gen'
import { explore } from './explore/orpc.gen'
import { features } from './features/orpc.gen'
import { files } from './files/orpc.gen'
import { forgotPassword } from './forgot-password/orpc.gen'
import { form } from './form/orpc.gen'
import { info } from './info/orpc.gen'
import { installedApps } from './installed-apps/orpc.gen'
import { instructionGenerate } from './instruction-generate/orpc.gen'
import { login } from './login/orpc.gen'
import { logout } from './logout/orpc.gen'
import { notification } from './notification/orpc.gen'
import { notion } from './notion/orpc.gen'
import { oauth } from './oauth/orpc.gen'
import { rag } from './rag/orpc.gen'
import { refreshToken } from './refresh-token/orpc.gen'
import { remoteFiles } from './remote-files/orpc.gen'
import { resetPassword } from './reset-password/orpc.gen'
import { ruleCodeGenerate } from './rule-code-generate/orpc.gen'
import { ruleGenerate } from './rule-generate/orpc.gen'
import { ruleStructuredOutputGenerate } from './rule-structured-output-generate/orpc.gen'
import { snippets } from './snippets/orpc.gen'
import { spec } from './spec/orpc.gen'
import { systemFeatures } from './system-features/orpc.gen'
import { tagBindings } from './tag-bindings/orpc.gen'
import { tags } from './tags/orpc.gen'
import { test } from './test/orpc.gen'
import { trialApps } from './trial-apps/orpc.gen'
import { trialModels } from './trial-models/orpc.gen'
import { website } from './website/orpc.gen'
import { workflowGenerate } from './workflow-generate/orpc.gen'
import { workflow } from './workflow/orpc.gen'
import { workspaces } from './workspaces/orpc.gen'
const communityContract = {
account,
activate,
agent,
allWorkspaces,
apiBasedExtension,
apiKeyAuth,
app,
appDslVersion,
apps,
auth,
billing,
codeBasedExtension,
compliance,
dataSource,
datasets,
emailCodeLogin,
emailRegister,
explore,
features,
files,
forgotPassword,
form,
info,
installedApps,
instructionGenerate,
login,
logout,
notification,
notion,
oauth,
rag,
refreshToken,
remoteFiles,
resetPassword,
ruleCodeGenerate,
ruleGenerate,
ruleStructuredOutputGenerate,
snippets,
spec,
systemFeatures,
tagBindings,
tags,
test,
trialApps,
trialModels,
website,
workflow,
workflowGenerate,
workspaces,
}
export const consoleRouterContract = {
enterprise: enterpriseContract,
...communityContract,
}
+42 -1
View File
@@ -362,6 +362,44 @@ const writeConsoleContractEntry = (segments: string[]) => {
fs.writeFileSync(entryPath, consoleContractEntryContent(segments))
}
const consoleRouterContractContent = (segments: string[]) => {
const contracts = segments.map((segment) => {
return {
importPath: toKebabCase(segment),
name: toCamelCase(segmentWords(segment)),
}
})
const imports = contracts
.map(contract => `import { ${contract.name} } from './${contract.importPath}/orpc.gen'`)
.join('\n')
const communityContractEntries = contracts
.map(contract => ` ${contract.name},`)
.join('\n')
return `// This file is auto-generated by packages/contracts/openapi-ts.api.config.ts
${imports}
import { contract as enterpriseContract } from '../../enterprise/orpc.gen'
const communityContract = {
${communityContractEntries}
}
export const consoleRouterContract = {
enterprise: enterpriseContract,
...communityContract,
}
`
}
const writeConsoleRouterContract = (segments: string[]) => {
const routerPath = path.resolve(currentDir, 'generated/api/console/router.gen.ts')
fs.mkdirSync(path.dirname(routerPath), { recursive: true })
fs.writeFileSync(routerPath, consoleRouterContractContent(segments))
}
const createConsoleContractEntryJob = (document: SwaggerDocument, segments: string[]): ApiJob => {
return {
clean: false,
@@ -369,7 +407,10 @@ const createConsoleContractEntryJob = (document: SwaggerDocument, segments: stri
outputPath: 'generated/api/console',
plugins: [],
source: {
callback: () => writeConsoleContractEntry(segments),
callback: () => {
writeConsoleContractEntry(segments)
writeConsoleRouterContract(segments)
},
enabled: true,
path: null,
serialize: () => '',
@@ -106,10 +106,14 @@ vi.mock('@/service/client', () => ({
},
explore: {
apps: {
queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'apps', input],
get: {
queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'apps', 'get', input],
},
},
banners: {
queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'banners', input],
get: {
queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'banners', 'get', input],
},
},
},
},
@@ -206,7 +210,7 @@ const mockMemberRole = (hasEditPermission: boolean) => {
}
const localeInput = { query: { language: 'en' } }
const exploreAppListQueryKey = ['console', 'explore', 'apps', localeInput, 'en']
const exploreAppListQueryKey = ['console', 'explore', 'apps', 'get', localeInput, 'en']
const homeContinueWorkAppsInput = {
query: {
page: 1,
@@ -81,7 +81,7 @@ const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
export default AppTypeSelector
type AppTypeIconProps = {
type: AppModeEnum
type: string
style?: React.CSSProperties
className?: string
wrapperClassName?: string
@@ -199,7 +199,7 @@ function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProp
)
}
function getAppTypeLabel(type: AppModeEnum, t: ReturnType<typeof useTranslation>['t']) {
function getAppTypeLabel(type: string, t: ReturnType<typeof useTranslation>['t']) {
if (type === AppModeEnum.CHAT)
return t('typeSelector.chatbot', { ns: 'app' })
if (type === AppModeEnum.AGENT_CHAT)
@@ -215,7 +215,7 @@ function getAppTypeLabel(type: AppModeEnum, t: ReturnType<typeof useTranslation>
}
type AppTypeLabelProps = {
type: AppModeEnum
type: string
className?: string
}
export function AppTypeLabel({ type, className }: AppTypeLabelProps) {
+2 -3
View File
@@ -12,10 +12,9 @@ import type {
} from '../types'
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 type { AppData, ToolIcon } from '@/models/share'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
@@ -52,7 +51,7 @@ export type ChatProps = {
showPromptLog?: boolean
questionIcon?: ReactNode
answerIcon?: ReactNode
allToolIcons?: Record<string, string | Emoji>
allToolIcons?: Record<string, ToolIcon>
onAnnotationEdited?: (question: string, answer: string, index: number) => void
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void
@@ -114,10 +114,14 @@ vi.mock('@/service/client', () => ({
},
explore: {
apps: {
queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'apps', input],
get: {
queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'apps', 'get', input],
},
},
banners: {
queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'banners', input],
get: {
queryKey: ({ input }: { input?: unknown } = {}) => ['console', 'explore', 'banners', 'get', input],
},
},
},
},
@@ -291,8 +295,8 @@ type RenderOptions = {
}
const localeInput = { query: { language: 'en' } }
const exploreAppListQueryKey = ['console', 'explore', 'apps', localeInput, 'en']
const exploreBannersQueryKey = ['console', 'explore', 'banners', localeInput, 'en']
const exploreAppListQueryKey = ['console', 'explore', 'apps', 'get', localeInput, 'en']
const exploreBannersQueryKey = ['console', 'explore', 'banners', 'get', localeInput, 'en']
const renderAppList = (
hasEditPermission = false,
@@ -61,7 +61,7 @@ function getExploreAppListQueryOptions(locale?: string) {
const language = input.query?.language
return queryOptions<ExploreAppListData>({
queryKey: [...consoleQuery.explore.apps.queryKey({ input }), language],
queryKey: [...consoleQuery.explore.apps.get.queryKey({ input }), language],
queryFn: async () => {
const { categories, recommended_apps } = await fetchAppList(language)
return {
@@ -84,7 +84,7 @@ function getBannersQueryOptions(locale?: string) {
const language = input.query?.language
return queryOptions<BannerType[]>({
queryKey: [...consoleQuery.explore.banners.queryKey({ input }), language],
queryKey: [...consoleQuery.explore.banners.get.queryKey({ input }), language],
queryFn: () => fetchBanners(language),
})
}
@@ -246,9 +246,11 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
async ({ name, icon_type, icon, icon_background, description }) => {
hideTryAppPanel()
const { export_data, mode } = await fetchAppDetail(
currApp?.app.id as string,
)
const appId = currApp?.app.id
if (!appId)
return
const { export_data, mode } = await fetchAppDetail(appId)
currentCreateAppModeRef.current = mode
const payload = {
mode: DSLImportMode.YAML_CONTENT,
-164
View File
@@ -1,164 +0,0 @@
import type { Subject } from '@dify/contracts/enterprise/types.gen'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
import type { Banner } from '@/models/app'
import type { App, AppCategory, InstalledApp } from '@/models/explore'
import type { AppMeta } from '@/models/share'
import type { AppModeEnum } from '@/types/app'
import { explore } from '@dify/contracts/api/console/explore/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
type ExploreAppsResponse = {
categories: AppCategory[]
recommended_apps: App[]
}
export type ExploreAppDetailResponse = {
id: string
name: string
icon: string
icon_background: string
mode: AppModeEnum
export_data: string
can_trial?: boolean
}
type InstalledAppsResponse = {
installed_apps: InstalledApp[]
}
type InstalledAppMutationResponse = {
result: string
message: string
}
type AppAccessModeResponse = {
accessMode: AccessMode
}
type UpdateAppAccessModeBody = {
appId: string
accessMode: AccessMode
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
}
export const exploreAppsContract = base
.route({
path: '/explore/apps',
method: 'GET',
})
.input(type<{ query?: { language?: string } }>())
.output(type<ExploreAppsResponse>())
export const learnDifyAppsContract = base
.route({
path: '/explore/apps/learn-dify',
method: 'GET',
})
.input(type<{ query?: { language?: string } }>())
.output(type<ExploreAppsResponse>())
export const exploreAppDetailContract = base
.route({
path: '/explore/apps/{id}',
method: 'GET',
})
.input(type<{ params: { id: string } }>())
.output(type<ExploreAppDetailResponse | null>())
export const exploreInstalledAppsContract = base
.route({
path: '/installed-apps',
method: 'GET',
})
.input(type<{ query?: { app_id?: string } }>())
.output(type<InstalledAppsResponse>())
export const exploreInstalledAppUninstallContract = base
.route({
path: '/installed-apps/{id}',
method: 'DELETE',
})
.input(type<{ params: { id: string } }>())
.output(type<unknown>())
export const exploreInstalledAppPinContract = base
.route({
path: '/installed-apps/{id}',
method: 'PATCH',
})
.input(type<{
params: { id: string }
body: {
is_pinned: boolean
}
}>())
.output(type<InstalledAppMutationResponse>())
export const exploreInstalledAppAccessModeContract = base
.route({
path: '/enterprise/webapp/app/access-mode',
method: 'GET',
})
.input(type<{ query: { appId: string } }>())
.output(type<AppAccessModeResponse>())
export const exploreInstalledAppAccessModeUpdateContract = base
.route({
path: '/enterprise/webapp/app/access-mode',
method: 'POST',
})
.input(type<{ body: UpdateAppAccessModeBody }>())
.output(type<unknown>())
export const exploreInstalledAppParametersContract = base
.route({
path: '/installed-apps/{appId}/parameters',
method: 'GET',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<ChatConfig>())
export const exploreInstalledAppMetaContract = base
.route({
path: '/installed-apps/{appId}/meta',
method: 'GET',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<AppMeta>())
export const exploreBannersContract = base
.route({
path: '/explore/banners',
method: 'GET',
})
.input(type<{ query?: { language?: string } }>())
.output(type<Banner[]>())
export const exploreRouterContract = {
...explore,
apps: exploreAppsContract,
learnDifyApps: learnDifyAppsContract,
appDetail: exploreAppDetailContract,
installedApps: exploreInstalledAppsContract,
uninstallInstalledApp: exploreInstalledAppUninstallContract,
updateInstalledApp: exploreInstalledAppPinContract,
appAccessMode: exploreInstalledAppAccessModeContract,
updateAppAccessMode: exploreInstalledAppAccessModeUpdateContract,
installedAppParameters: exploreInstalledAppParametersContract,
installedAppMeta: exploreInstalledAppMetaContract,
banners: exploreBannersContract,
}
export const exploreConsoleRouterContract = {
explore: exploreRouterContract,
}
-1
View File
@@ -1 +0,0 @@
export const pluginsConsoleRouterContract = {}
-1
View File
@@ -1 +0,0 @@
export const snippetsConsoleRouterContract = {}
-1
View File
@@ -1 +0,0 @@
export const trialAppsConsoleRouterContract = {}
-113
View File
@@ -1,113 +0,0 @@
import { account } from '@dify/contracts/api/console/account/orpc.gen'
import { activate } from '@dify/contracts/api/console/activate/orpc.gen'
import { agent } from '@dify/contracts/api/console/agent/orpc.gen'
import { allWorkspaces } from '@dify/contracts/api/console/all-workspaces/orpc.gen'
import { apiBasedExtension } from '@dify/contracts/api/console/api-based-extension/orpc.gen'
import { apiKeyAuth } from '@dify/contracts/api/console/api-key-auth/orpc.gen'
import { appDslVersion } from '@dify/contracts/api/console/app-dsl-version/orpc.gen'
import { app } from '@dify/contracts/api/console/app/orpc.gen'
import { apps } from '@dify/contracts/api/console/apps/orpc.gen'
import { auth } from '@dify/contracts/api/console/auth/orpc.gen'
import { billing } from '@dify/contracts/api/console/billing/orpc.gen'
import { codeBasedExtension } from '@dify/contracts/api/console/code-based-extension/orpc.gen'
import { compliance } from '@dify/contracts/api/console/compliance/orpc.gen'
import { dataSource } from '@dify/contracts/api/console/data-source/orpc.gen'
import { datasets } from '@dify/contracts/api/console/datasets/orpc.gen'
import { emailCodeLogin } from '@dify/contracts/api/console/email-code-login/orpc.gen'
import { emailRegister } from '@dify/contracts/api/console/email-register/orpc.gen'
import { features } from '@dify/contracts/api/console/features/orpc.gen'
import { files } from '@dify/contracts/api/console/files/orpc.gen'
import { forgotPassword } from '@dify/contracts/api/console/forgot-password/orpc.gen'
import { form } from '@dify/contracts/api/console/form/orpc.gen'
import { info } from '@dify/contracts/api/console/info/orpc.gen'
import { installedApps } from '@dify/contracts/api/console/installed-apps/orpc.gen'
import { instructionGenerate } from '@dify/contracts/api/console/instruction-generate/orpc.gen'
import { login } from '@dify/contracts/api/console/login/orpc.gen'
import { logout } from '@dify/contracts/api/console/logout/orpc.gen'
import { notification } from '@dify/contracts/api/console/notification/orpc.gen'
import { notion } from '@dify/contracts/api/console/notion/orpc.gen'
import { oauth } from '@dify/contracts/api/console/oauth/orpc.gen'
import { rag } from '@dify/contracts/api/console/rag/orpc.gen'
import { refreshToken } from '@dify/contracts/api/console/refresh-token/orpc.gen'
import { remoteFiles } from '@dify/contracts/api/console/remote-files/orpc.gen'
import { resetPassword } from '@dify/contracts/api/console/reset-password/orpc.gen'
import { ruleCodeGenerate } from '@dify/contracts/api/console/rule-code-generate/orpc.gen'
import { ruleGenerate } from '@dify/contracts/api/console/rule-generate/orpc.gen'
import { ruleStructuredOutputGenerate } from '@dify/contracts/api/console/rule-structured-output-generate/orpc.gen'
import { snippets } from '@dify/contracts/api/console/snippets/orpc.gen'
import { spec } from '@dify/contracts/api/console/spec/orpc.gen'
import { systemFeatures } from '@dify/contracts/api/console/system-features/orpc.gen'
import { tagBindings } from '@dify/contracts/api/console/tag-bindings/orpc.gen'
import { tags } from '@dify/contracts/api/console/tags/orpc.gen'
import { test } from '@dify/contracts/api/console/test/orpc.gen'
import { trialApps } from '@dify/contracts/api/console/trial-apps/orpc.gen'
import { trialModels } from '@dify/contracts/api/console/trial-models/orpc.gen'
import { website } from '@dify/contracts/api/console/website/orpc.gen'
import { workflowGenerate } from '@dify/contracts/api/console/workflow-generate/orpc.gen'
import { workflow } from '@dify/contracts/api/console/workflow/orpc.gen'
import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen'
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { exploreConsoleRouterContract } from './console/explore'
import { pluginsConsoleRouterContract } from './console/plugins'
import { snippetsConsoleRouterContract } from './console/snippets'
import { trialAppsConsoleRouterContract } from './console/try-app'
const communityContract = {
account,
activate,
agent,
allWorkspaces,
apiBasedExtension,
apiKeyAuth,
app,
appDslVersion,
apps,
auth,
billing,
codeBasedExtension,
compliance,
dataSource,
datasets,
emailCodeLogin,
emailRegister,
features,
files,
forgotPassword,
form,
info,
installedApps,
instructionGenerate,
login,
logout,
notification,
notion,
oauth,
rag,
refreshToken,
remoteFiles,
resetPassword,
ruleCodeGenerate,
ruleGenerate,
ruleStructuredOutputGenerate,
snippets,
spec,
systemFeatures,
tagBindings,
tags,
test,
trialApps,
trialModels,
website,
workflow,
workflowGenerate,
workspaces,
}
export const consoleRouterContract = {
enterprise: enterpriseContract,
...communityContract,
...exploreConsoleRouterContract,
...pluginsConsoleRouterContract,
...snippetsConsoleRouterContract,
...trialAppsConsoleRouterContract,
}
@@ -39,7 +39,7 @@ Owns the Agent V2 configure runtime used by the Agent App configure page and wor
- context/app-context
- context/i18n
- context/modal-context
- contract/router
- @dify/contracts/api/console/router.gen
- hooks/use-format-time-from-now
- hooks/use-theme
- hooks/use-timestamp
+2 -2
View File
@@ -1,8 +1,8 @@
import type { AppIconType, AppModeEnum } from '@/types/app'
import type { AppIconType } from '@/types/app'
type AppBasicInfo = {
id: string
mode: AppModeEnum
mode: string
icon_type: AppIconType | null
icon: string
icon_background: string
+3 -1
View File
@@ -27,8 +27,10 @@ export type SiteInfo = {
use_icon_as_answer_icon?: boolean
}
export type ToolIcon = string | Record<string, unknown>
export type AppMeta = {
tool_icons: Record<string, string>
tool_icons: Record<string, ToolIcon>
}
export type CustomConfigValueType = string | number | boolean | null | undefined
@@ -2,11 +2,26 @@ import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { AccessMode, SubjectType } from '@/models/access-control'
import { post } from '@/service/base'
import { consoleClient } from '@/service/client'
import { useUpdateAccessMode } from '..'
vi.mock('@/service/base', () => ({
post: vi.fn(),
vi.mock('@/service/client', () => ({
consoleClient: {
enterprise: {
webAppAuth: {
updateWebAppWhitelistSubjects: vi.fn(),
},
},
},
consoleQuery: {
enterprise: {
webAppAuth: {
getWebAppAccessMode: {
key: vi.fn(() => ['enterprise', 'web-app-auth', 'access-mode']),
},
},
},
},
}))
const createWrapper = () => {
@@ -25,7 +40,7 @@ const createWrapper = () => {
describe('access-control service', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(post).mockResolvedValue({})
vi.mocked(consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects).mockResolvedValue({})
})
// Access mode updates keep the legacy webapp whitelist payload contract.
@@ -43,7 +58,7 @@ describe('access-control service', () => {
})
await waitFor(() => {
expect(post).toHaveBeenCalledWith('/enterprise/webapp/app/access-mode', {
expect(consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects).toHaveBeenCalledWith({
body: {
appId: 'app-1',
accessMode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
+7 -2
View File
@@ -1,7 +1,7 @@
import type { AccessControlGroup, AccessMode, Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { post } from '../base'
import { consoleClient, consoleQuery } from '../client'
import {
useAppWhiteListSubjects as useAppWhiteListSubjectsBase,
useGetUserCanAccessApp as useGetUserCanAccessAppBase,
@@ -46,9 +46,14 @@ export const useUpdateAccessMode = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'update-access-mode'],
mutationFn: (params: UpdateAccessModeParams) => {
return post('/enterprise/webapp/app/access-mode', { body: params })
return consoleClient.enterprise.webAppAuth.updateWebAppWhitelistSubjects({
body: params,
})
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.key({ type: 'query' }),
})
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'app-whitelist-subjects'],
})
+1 -17
View File
@@ -1,5 +1,6 @@
import type { AgentAppPagination } from '@dify/contracts/api/console/agent/types.gen'
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { consoleRouterContract } from '@dify/contracts/api/console/router.gen'
import type { TagResponse as Tag, TagType } from '@dify/contracts/api/console/tags/types.gen'
import type {
GetReleaseResponse,
@@ -11,7 +12,6 @@ import type { AnyContractRouter, ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { RouterUtils, TanstackQueryOperationContext } from '@orpc/tanstack-query'
import type { InfiniteData, QueryClient, QueryKey } from '@tanstack/react-query'
import type { consoleRouterContract } from '@/contract/router'
import { createORPCClient, onError } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
@@ -652,22 +652,6 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
},
},
},
explore: {
updateAppAccessMode: {
mutationOptions: {
onSuccess: (_data, _variables, _onMutateResult, context) => {
return Promise.all([
context.client.invalidateQueries({
queryKey: consoleQuery.explore.appAccessMode.key({ type: 'query' }),
}),
context.client.invalidateQueries({
queryKey: ['access-control', 'app-whitelist-subjects'],
}),
])
},
},
},
},
apiBasedExtension: {
post: {
mutationOptions: {
+12 -1
View File
@@ -1,8 +1,11 @@
import { agent } from '@dify/contracts/api/console/agent/orpc.gen'
import { apps } from '@dify/contracts/api/console/apps/orpc.gen'
import { explore } from '@dify/contracts/api/console/explore/orpc.gen'
import { installedApps } from '@dify/contracts/api/console/installed-apps/orpc.gen'
import { notification } from '@dify/contracts/api/console/notification/orpc.gen'
import { tags } from '@dify/contracts/api/console/tags/orpc.gen'
import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen'
import { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { describe, expect, it } from 'vitest'
import { loadConsoleContractForSegment } from './console-router-loader'
@@ -10,12 +13,20 @@ describe('loadConsoleContractForSegment', () => {
it.each([
['agent', 'agent', agent],
['apps', 'apps', apps],
['explore', 'explore', explore],
['installedApps', 'installedApps', installedApps],
['notification', 'notification', notification],
['tags', 'tags', tags],
['workspaces', 'workspaces', workspaces],
] as const)('loads the generated %s contract when generated types are usable', async (segment, contractKey, generatedContract) => {
const contract = await loadConsoleContractForSegment(segment)
expect((contract as Record<string, unknown>)[contractKey]).toBe(generatedContract)
expect(contract).toHaveProperty(contractKey, generatedContract)
})
it('loads the generated enterprise contract independently', async () => {
const contract = await loadConsoleContractForSegment('enterprise')
expect(contract).toHaveProperty('enterprise', enterpriseContract)
})
})
+8 -17
View File
@@ -1,33 +1,24 @@
import type { AnyContractRouter } from '@orpc/contract'
import { contractLoaders } from '@dify/contracts/api/console/orpc.gen'
type ConsoleContractExtension = Record<string, unknown>
const wrapConsoleContract = (segment: string, contract: unknown): ConsoleContractExtension => ({ [segment]: contract })
const generatedConsoleContractLoaders: Partial<Record<string, () => Promise<AnyContractRouter>>> = contractLoaders
async function loadGeneratedConsoleContract(segment: string) {
const loader = contractLoaders[segment as keyof typeof contractLoaders]
const loader = generatedConsoleContractLoaders[segment]
if (!loader)
return null
return loader() as Promise<AnyContractRouter>
return loader()
}
const customConsoleContractLoaders: Record<string, () => Promise<ConsoleContractExtension>> = {
enterprise: () => import('@dify/contracts/enterprise/orpc.gen').then(({ contract }) => wrapConsoleContract('enterprise', contract)),
explore: () => import('@/contract/console/explore').then(({ exploreConsoleRouterContract }) => exploreConsoleRouterContract),
plugins: () => import('@/contract/console/plugins').then(({ pluginsConsoleRouterContract }) => pluginsConsoleRouterContract),
snippets: () => import('@/contract/console/snippets').then(({ snippetsConsoleRouterContract }) => snippetsConsoleRouterContract),
trialApps: () => import('@/contract/console/try-app').then(({ trialAppsConsoleRouterContract }) => trialAppsConsoleRouterContract),
async function loadEnterpriseContract(): Promise<AnyContractRouter> {
const { contract } = await import('@dify/contracts/enterprise/orpc.gen')
return { enterprise: contract }
}
export async function loadConsoleContractForSegment(segment: string) {
const customContractLoader = customConsoleContractLoaders[segment]
if (customContractLoader) {
const customContract = await customContractLoader()
if (Object.keys(customContract).length > 0)
return customContract as AnyContractRouter
}
if (segment === 'enterprise')
return loadEnterpriseContract()
const generatedContract = await loadGeneratedConsoleContract(segment)
if (generatedContract)
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
fetchAppDetail,
fetchAppList,
fetchInstalledAppMeta,
} from './explore'
const mockExploreAppsGet = vi.hoisted(() => vi.fn())
const mockExploreAppDetailGet = vi.hoisted(() => vi.fn())
const mockInstalledAppMetaGet = vi.hoisted(() => vi.fn())
vi.mock('./client', () => ({
consoleClient: {
explore: {
apps: {
get: mockExploreAppsGet,
byAppId: {
get: mockExploreAppDetailGet,
},
},
},
installedApps: {
byInstalledAppId: {
meta: {
get: mockInstalledAppMetaGet,
},
},
},
},
}))
describe('explore service normalizers', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('preserves backend app modes that are not part of the legacy frontend enum', async () => {
mockExploreAppsGet.mockResolvedValue({
categories: [],
recommended_apps: [{
app_id: 'agent-app',
app: {
id: 'agent-app',
name: 'Agent app',
mode: 'agent',
icon: '',
icon_background: '',
},
}],
})
mockExploreAppDetailGet.mockResolvedValue({
id: 'pipeline-app',
name: 'Pipeline app',
icon: '',
icon_background: '',
mode: 'rag-pipeline',
export_data: 'kind: app',
})
await expect(fetchAppList()).resolves.toMatchObject({
recommended_apps: [{
app: {
mode: 'agent',
},
}],
})
await expect(fetchAppDetail('pipeline-app')).resolves.toMatchObject({
mode: 'rag-pipeline',
})
})
it('preserves provider-defined tool icon payload objects', async () => {
const providerIcon = {
type: 'custom',
value: {
content: 'tool',
background: '#fff',
},
}
mockInstalledAppMetaGet.mockResolvedValue({
tool_icons: {
builtin: '/tool.svg',
provider: providerIcon,
},
})
await expect(fetchInstalledAppMeta('installed-app-id')).resolves.toEqual({
tool_icons: {
builtin: '/tool.svg',
provider: providerIcon,
},
})
})
})
+378 -29
View File
@@ -1,53 +1,400 @@
import type {
BannerListResponse,
BannerResponse,
GetExploreAppsLearnDifyResponse,
GetExploreAppsResponse,
RecommendedAppDetailResponse,
RecommendedAppInfoResponse,
RecommendedAppResponse,
} from '@dify/contracts/api/console/explore/types.gen'
import type {
ExploreAppMetaResponse,
InstalledAppInfoResponse,
InstalledAppListResponse,
Parameters as InstalledAppParametersResponse,
InstalledAppResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { ExploreAppDetailResponse } from '@/contract/console/explore'
import type { AppMeta } from '@/models/share'
import type { Banner } from '@/models/app'
import type { App, AppCategory, InstalledApp } from '@/models/explore'
import type { AppMeta, ToolIcon } from '@/models/share'
import type { AppIconType } from '@/types/app'
import { AccessMode } from '@/models/access-control'
import { PromptMode } from '@/models/debug'
import { RETRIEVE_TYPE, TtsAutoPlay } from '@/types/app'
import { consoleClient } from './client'
type ExploreAppsResponse = {
categories: AppCategory[]
recommended_apps: App[]
}
type LearnDifyAppsResponse = {
recommended_apps: App[]
}
type ExploreAppDetailResponse = {
id: string
name: string
icon: string
icon_background: string
mode: string
export_data: string
can_trial?: boolean | null
}
type InstalledAppsResponse = {
installed_apps: InstalledApp[]
}
type AppAccessModeResponse = {
accessMode: AccessMode
}
type InstalledAppParametersViewModel = ChatConfig
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
const getValue = (source: object, key: string): unknown => {
return Reflect.get(source, key)
}
const getStringProperty = (source: object, key: string, fallback = '') => {
const value = getValue(source, key)
return typeof value === 'string' ? value : fallback
}
const getBooleanProperty = (source: object, key: string, fallback = false) => {
const value = getValue(source, key)
return typeof value === 'boolean' ? value : fallback
}
const normalizeAppMode = (value: unknown) => {
return typeof value === 'string' ? value : ''
}
const isAppIconType = (value: unknown): value is AppIconType => {
return value === 'image' || value === 'emoji' || value === 'link'
}
const isAccessMode = (value: unknown): value is AccessMode => {
return value === AccessMode.PUBLIC
|| value === AccessMode.SPECIFIC_GROUPS_MEMBERS
|| value === AccessMode.ORGANIZATION
|| value === AccessMode.EXTERNAL_MEMBERS
}
const normalizeAccessMode = (value: unknown) => {
if (isAccessMode(value))
return value
throw new Error('Web app access mode response returned an unsupported access mode.')
}
const normalizeAppIconType = (value: unknown) => {
return isAppIconType(value) ? value : null
}
const normalizeAppBasicInfo = (
source: RecommendedAppInfoResponse | InstalledAppInfoResponse | null | undefined,
fallbackId: string,
): App['app'] => {
const description = source && 'description' in source && typeof source.description === 'string'
? source.description
: ''
const useIconAsAnswerIcon = source
&& 'use_icon_as_answer_icon' in source
&& typeof source.use_icon_as_answer_icon === 'boolean'
? source.use_icon_as_answer_icon
: false
return {
id: source?.id ?? fallbackId,
mode: normalizeAppMode(source?.mode),
icon_type: normalizeAppIconType(source?.icon_type),
icon: source?.icon ?? '',
icon_background: source?.icon_background ?? '',
icon_url: source?.icon_url ?? '',
name: source?.name ?? '',
description,
use_icon_as_answer_icon: useIconAsAnswerIcon,
}
}
const normalizeRecommendedApp = (app: RecommendedAppResponse): App => {
return {
app: normalizeAppBasicInfo(app.app, app.app_id),
app_id: app.app_id,
description: app.description ?? '',
copyright: app.copyright ?? '',
privacy_policy: app.privacy_policy ?? null,
custom_disclaimer: app.custom_disclaimer ?? null,
categories: app.categories ?? [],
position: app.position ?? 0,
is_listed: app.is_listed ?? false,
install_count: 0,
installed: false,
editable: false,
is_agent: false,
can_trial: app.can_trial ?? false,
}
}
const normalizeExploreAppsResponse = (response: GetExploreAppsResponse): ExploreAppsResponse => {
return {
categories: response.categories,
recommended_apps: response.recommended_apps.map(normalizeRecommendedApp),
}
}
const normalizeLearnDifyAppsResponse = (response: GetExploreAppsLearnDifyResponse): LearnDifyAppsResponse => {
return {
recommended_apps: response.recommended_apps.map(normalizeRecommendedApp),
}
}
const normalizeAppDetail = (response: RecommendedAppDetailResponse): ExploreAppDetailResponse => {
return {
id: response.id,
name: response.name,
icon: response.icon ?? '',
icon_background: response.icon_background ?? '',
mode: normalizeAppMode(response.mode),
export_data: response.export_data,
can_trial: response.can_trial,
}
}
const normalizeInstalledApp = (installedApp: InstalledAppResponse): InstalledApp => {
return {
app: normalizeAppBasicInfo(installedApp.app, installedApp.app.id),
id: installedApp.id,
uninstallable: installedApp.uninstallable,
is_pinned: installedApp.is_pinned,
}
}
const normalizeInstalledAppsResponse = (response: InstalledAppListResponse): InstalledAppsResponse => {
return {
installed_apps: response.installed_apps.map(normalizeInstalledApp),
}
}
const normalizeBannerContent = (content: unknown): Banner['content'] => {
const record = isRecord(content) ? content : {}
return {
'category': getStringProperty(record, 'category'),
'title': getStringProperty(record, 'title'),
'description': getStringProperty(record, 'description'),
'img-src': getStringProperty(record, 'img-src'),
}
}
const normalizeBanner = (banner: BannerResponse): Banner => {
return {
id: banner.id,
content: normalizeBannerContent(banner.content),
link: banner.link ?? '',
sort: banner.sort,
status: banner.status,
created_at: banner.created_at ?? '',
}
}
const normalizeBannersResponse = (response: BannerListResponse): Banner[] => {
return response.map(normalizeBanner)
}
const normalizeToolIcons = (value: unknown) => {
const record = isRecord(value) ? value : {}
const result: Record<string, ToolIcon> = {}
Object.entries(record).forEach(([key, item]) => {
if (typeof item === 'string')
result[key] = item
else if (isRecord(item))
result[key] = item
})
return result
}
const normalizeAppMeta = (response: ExploreAppMetaResponse): AppMeta => {
return {
tool_icons: normalizeToolIcons(response.tool_icons),
}
}
const isTtsAutoPlay = (value: unknown): value is TtsAutoPlay => {
return value === TtsAutoPlay.enabled || value === TtsAutoPlay.disabled
}
const isUserInputFormItem = (value: unknown): value is ChatConfig['user_input_form'][number] => {
if (!isRecord(value))
return false
return [
'text-input',
'select',
'paragraph',
'number',
'checkbox',
'file',
'file-list',
'external_data_tool',
'json_object',
].some(key => isRecord(getValue(value, key)))
}
const isModel = (value: unknown): value is NonNullable<ChatConfig['suggested_questions_after_answer']['model']> => {
return isRecord(value)
}
const isAnnotationReplyConfig = (value: unknown): value is NonNullable<ChatConfig['annotation_reply']> => {
return isRecord(value)
}
const isFileUploadConfig = (value: unknown): value is NonNullable<ChatConfig['file_upload']> => {
return isRecord(value)
}
const defaultDatasetConfigs = (): ChatConfig['dataset_configs'] => ({
retrieval_model: RETRIEVE_TYPE.oneWay,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 4,
score_threshold_enabled: false,
score_threshold: null,
datasets: {
datasets: [],
},
})
const normalizeEnabledConfig = (value: unknown): { enabled: boolean } => {
const record = isRecord(value) ? value : {}
return {
...record,
enabled: getBooleanProperty(record, 'enabled'),
}
}
const normalizeSuggestedQuestionsAfterAnswer = (
value: unknown,
): ChatConfig['suggested_questions_after_answer'] => {
const record = isRecord(value) ? value : {}
const model = getValue(record, 'model')
const prompt = getStringProperty(record, 'prompt')
return {
enabled: getBooleanProperty(record, 'enabled'),
...(isModel(model) ? { model } : {}),
...(prompt ? { prompt } : {}),
}
}
const normalizeTextToSpeech = (value: unknown): ChatConfig['text_to_speech'] => {
const record = isRecord(value) ? value : {}
const autoPlay = getValue(record, 'autoPlay')
const normalizedAutoPlay = isTtsAutoPlay(autoPlay) ? autoPlay : undefined
return {
...record,
enabled: getBooleanProperty(record, 'enabled'),
voice: getStringProperty(record, 'voice') || undefined,
language: getStringProperty(record, 'language') || undefined,
...(normalizedAutoPlay ? { autoPlay: normalizedAutoPlay } : {}),
}
}
const normalizeSystemParameters = (
systemParameters: InstalledAppParametersResponse['system_parameters'],
): ChatConfig['system_parameters'] => {
return {
audio_file_size_limit: systemParameters.audio_file_size_limit,
file_size_limit: systemParameters.file_size_limit,
image_file_size_limit: systemParameters.image_file_size_limit,
video_file_size_limit: systemParameters.video_file_size_limit,
workflow_file_upload_limit: systemParameters.workflow_file_upload_limit,
}
}
const normalizeInstalledAppParametersViewModel = (
response: InstalledAppParametersResponse,
): InstalledAppParametersViewModel => {
return {
opening_statement: response.opening_statement ?? '',
suggested_questions: response.suggested_questions,
pre_prompt: '',
prompt_type: PromptMode.simple,
user_input_form: response.user_input_form.filter(isUserInputFormItem),
more_like_this: normalizeEnabledConfig(response.more_like_this),
suggested_questions_after_answer: normalizeSuggestedQuestionsAfterAnswer(response.suggested_questions_after_answer),
speech_to_text: normalizeEnabledConfig(response.speech_to_text),
text_to_speech: normalizeTextToSpeech(response.text_to_speech),
retriever_resource: normalizeEnabledConfig(response.retriever_resource),
sensitive_word_avoidance: normalizeEnabledConfig(response.sensitive_word_avoidance),
...(isAnnotationReplyConfig(response.annotation_reply) ? { annotation_reply: response.annotation_reply } : {}),
agent_mode: {
enabled: false,
tools: [],
},
dataset_configs: defaultDatasetConfigs(),
...(isFileUploadConfig(response.file_upload) ? { file_upload: response.file_upload } : {}),
system_parameters: normalizeSystemParameters(response.system_parameters),
}
}
export const fetchAppList = (language?: string) => {
if (!language)
return consoleClient.explore.apps({})
return consoleClient.explore.apps.get({}).then(normalizeExploreAppsResponse)
return consoleClient.explore.apps({
return consoleClient.explore.apps.get({
query: { language },
})
}).then(normalizeExploreAppsResponse)
}
export const fetchLearnDifyAppList = (language?: string) => {
if (!language)
return consoleClient.explore.learnDifyApps({})
return consoleClient.explore.apps.learnDify.get({}).then(normalizeLearnDifyAppsResponse)
return consoleClient.explore.learnDifyApps({
return consoleClient.explore.apps.learnDify.get({
query: { language },
})
}).then(normalizeLearnDifyAppsResponse)
}
export const fetchAppDetail = async (id: string): Promise<ExploreAppDetailResponse> => {
const response = await consoleClient.explore.appDetail({
params: { id },
const response = await consoleClient.explore.apps.byAppId.get({
params: { app_id: id },
})
if (!response)
throw new Error('Recommended app not found')
return response
return normalizeAppDetail(response)
}
export const fetchInstalledAppList = (appId?: string | null) => {
if (!appId)
return consoleClient.explore.installedApps({})
return consoleClient.installedApps.get({}).then(normalizeInstalledAppsResponse)
return consoleClient.explore.installedApps({
return consoleClient.installedApps.get({
query: { app_id: appId },
})
}).then(normalizeInstalledAppsResponse)
}
export const uninstallApp = (id: string) => {
return consoleClient.explore.uninstallInstalledApp({
params: { id },
return consoleClient.installedApps.byInstalledAppId.delete({
params: { installed_app_id: id },
})
}
export const updatePinStatus = (id: string, isPinned: boolean) => {
return consoleClient.explore.updateInstalledApp({
params: { id },
return consoleClient.installedApps.byInstalledAppId.patch({
params: { installed_app_id: id },
body: {
is_pinned: isPinned,
},
@@ -55,28 +402,30 @@ export const updatePinStatus = (id: string, isPinned: boolean) => {
}
export const getAppAccessModeByAppId = (appId: string) => {
return consoleClient.explore.appAccessMode({
return consoleClient.enterprise.webAppAuth.getWebAppAccessMode({
query: { appId },
})
}).then((response): AppAccessModeResponse => ({
accessMode: normalizeAccessMode(isRecord(response) ? getValue(response, 'accessMode') : undefined),
}))
}
export const fetchInstalledAppParams = (appId: string) => {
return consoleClient.explore.installedAppParameters({
params: { appId },
}) as Promise<ChatConfig>
return consoleClient.installedApps.byInstalledAppId.parameters.get({
params: { installed_app_id: appId },
}).then(normalizeInstalledAppParametersViewModel)
}
export const fetchInstalledAppMeta = (appId: string) => {
return consoleClient.explore.installedAppMeta({
params: { appId },
}) as Promise<AppMeta>
return consoleClient.installedApps.byInstalledAppId.meta.get({
params: { installed_app_id: appId },
}).then(normalizeAppMeta)
}
export const fetchBanners = (language?: string) => {
if (!language)
return consoleClient.explore.banners({})
return consoleClient.explore.banners.get({}).then(normalizeBannersResponse)
return consoleClient.explore.banners({
return consoleClient.explore.banners.get({
query: { language },
})
}).then(normalizeBannersResponse)
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { consoleRouterContract } from '@dify/contracts/api/console/router.gen'
import type { ClientLink } from '@orpc/client'
import type { AnyContractRouter, ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { consoleRouterContract } from '@/contract/router'
import { createORPCClient, onError } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
+14 -14
View File
@@ -28,7 +28,7 @@ export const useExploreAppList = (options: { enabled?: boolean } = {}) => {
const exploreAppsLanguage = exploreAppsInput?.query?.language
return useQuery<ExploreAppListData>({
queryKey: [...consoleQuery.explore.apps.queryKey({ input: exploreAppsInput }), exploreAppsLanguage],
queryKey: [...consoleQuery.explore.apps.get.queryKey({ input: exploreAppsInput }), exploreAppsLanguage],
queryFn: async () => {
const { categories, recommended_apps } = await fetchAppList(exploreAppsLanguage)
return {
@@ -48,7 +48,7 @@ export const useLearnDifyAppList = () => {
const learnDifyAppsLanguage = learnDifyAppsInput?.query?.language
return useQuery({
queryKey: [...consoleQuery.explore.learnDifyApps.queryKey({ input: learnDifyAppsInput }), learnDifyAppsLanguage],
queryKey: [...consoleQuery.explore.apps.learnDify.get.queryKey({ input: learnDifyAppsInput }), learnDifyAppsLanguage],
queryFn: async () => {
const { recommended_apps } = await fetchLearnDifyAppList(learnDifyAppsLanguage)
return [...recommended_apps].sort((a, b) => a.position - b.position)
@@ -58,7 +58,7 @@ export const useLearnDifyAppList = () => {
export const useGetInstalledApps = () => {
return useQuery({
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
queryFn: () => {
return fetchInstalledAppList()
},
@@ -68,11 +68,11 @@ export const useGetInstalledApps = () => {
export const useUninstallApp = () => {
const client = useQueryClient()
return useMutation({
mutationKey: consoleQuery.explore.uninstallInstalledApp.mutationKey(),
mutationKey: consoleQuery.installedApps.byInstalledAppId.delete.mutationKey(),
mutationFn: (appId: string) => uninstallApp(appId),
onSuccess: () => {
client.invalidateQueries({
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
})
},
})
@@ -81,11 +81,11 @@ export const useUninstallApp = () => {
export const useUpdateAppPinStatus = () => {
const client = useQueryClient()
return useMutation({
mutationKey: consoleQuery.explore.updateInstalledApp.mutationKey(),
mutationKey: consoleQuery.installedApps.byInstalledAppId.patch.mutationKey(),
mutationFn: ({ appId, isPinned }: { appId: string, isPinned: boolean }) => updatePinStatus(appId, isPinned),
onSuccess: () => {
client.invalidateQueries({
queryKey: consoleQuery.explore.installedApps.queryKey({ input: {} }),
queryKey: consoleQuery.installedApps.get.queryKey({ input: {} }),
})
},
})
@@ -102,7 +102,7 @@ export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => {
return useQuery({
queryKey: [
...consoleQuery.explore.appAccessMode.queryKey({ input: appAccessModeInput }),
...consoleQuery.enterprise.webAppAuth.getWebAppAccessMode.queryKey({ input: appAccessModeInput }),
webappAuthEnabled,
installedAppId,
],
@@ -122,11 +122,11 @@ export const useGetInstalledAppAccessModeByAppId = (appId: string | null) => {
}
export const useGetInstalledAppParams = (appId: string | null) => {
const installedAppParamsInput = { params: { appId: appId ?? '' } }
const installedAppId = installedAppParamsInput.params.appId
const installedAppParamsInput = { params: { installed_app_id: appId ?? '' } }
const installedAppId = installedAppParamsInput.params.installed_app_id
return useQuery({
queryKey: [...consoleQuery.explore.installedAppParameters.queryKey({ input: installedAppParamsInput }), installedAppId],
queryKey: [...consoleQuery.installedApps.byInstalledAppId.parameters.get.queryKey({ input: installedAppParamsInput }), installedAppId],
queryFn: () => {
if (!installedAppId)
return Promise.reject(new Error('App ID is required to get app params'))
@@ -137,11 +137,11 @@ export const useGetInstalledAppParams = (appId: string | null) => {
}
export const useGetInstalledAppMeta = (appId: string | null) => {
const installedAppMetaInput = { params: { appId: appId ?? '' } }
const installedAppId = installedAppMetaInput.params.appId
const installedAppMetaInput = { params: { installed_app_id: appId ?? '' } }
const installedAppId = installedAppMetaInput.params.installed_app_id
return useQuery({
queryKey: [...consoleQuery.explore.installedAppMeta.queryKey({ input: installedAppMetaInput }), installedAppId],
queryKey: [...consoleQuery.installedApps.byInstalledAppId.meta.get.queryKey({ input: installedAppMetaInput }), installedAppId],
queryFn: () => {
if (!installedAppId)
return Promise.reject(new Error('App ID is required to get app meta'))
@@ -82,6 +82,17 @@ describe('create-app-tracking', () => {
})
})
it('should map the current backend agent mode into the canonical app mode bucket', () => {
expect(buildCreateAppEventPayload({
source: 'explore_template_list',
appMode: 'agent',
}, null, new Date(2026, 3, 13, 9, 8, 8))).toEqual({
source: 'explore_template_list',
app_mode: 'agent',
time: '04-13-09:08:08',
})
})
it('should fold legacy non-agent modes into chatflow', () => {
expect(buildCreateAppEventPayload({
source: 'studio_blank',
+3 -3
View File
@@ -31,7 +31,7 @@ type CreateAppSource
export type TrackCreateAppParams = {
source: CreateAppSource
appMode: AppModeEnum
appMode: string
templateId?: string
}
@@ -89,11 +89,11 @@ const formatCreateAppTime = (date: Date) => {
return `${padTimeValue(date.getMonth() + 1)}-${padTimeValue(date.getDate())}-${padTimeValue(date.getHours())}:${padTimeValue(date.getMinutes())}:${padTimeValue(date.getSeconds())}`
}
const mapOriginalCreateAppMode = (appMode: AppModeEnum): OriginalCreateAppMode => {
const mapOriginalCreateAppMode = (appMode: string): OriginalCreateAppMode => {
if (appMode === AppModeEnum.WORKFLOW)
return 'workflow'
if (appMode === AppModeEnum.AGENT_CHAT)
if (appMode === AppModeEnum.AGENT_CHAT || appMode === 'agent')
return 'agent'
return 'chatflow'