Compare commits

..

6 Commits

70 changed files with 983 additions and 918 deletions

View File

@ -16,10 +16,11 @@ import logging
import time
from collections.abc import Mapping, Sequence
from mimetypes import guess_type
from typing import ClassVar
from typing import Any, ClassVar
from pydantic import BaseModel, TypeAdapter, ValidationError
from redis import RedisError
from redis.exceptions import LockError
from sqlalchemy import delete, select, update
from sqlalchemy.orm import Session
from yarl import URL
@ -82,6 +83,10 @@ class PluginService:
REDIS_TTL = 60 * 5 # 5 minutes
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX = "plugin_model_providers_generation:tenant_id:"
PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX = "plugin_model_providers_refresh_lock:tenant_id:"
PLUGIN_MODEL_PROVIDERS_LOCK_TTL = 30
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT = 2.0
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL = 0.05
PLUGIN_INSTALL_TASK_TERMINAL_STATUSES = (PluginInstallTaskStatus.Success, PluginInstallTaskStatus.Failed)
# Mirror the detail-panel endpoint query size so list reconciliation and
# the visible endpoint drawer exercise the same daemon pagination path.
@ -98,6 +103,10 @@ class PluginService:
def _get_plugin_model_providers_generation_cache_key(cls, tenant_id: str) -> str:
return f"{cls.PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX}{tenant_id}"
@classmethod
def _get_plugin_model_providers_lock_key(cls, tenant_id: str, generation: int) -> str:
return f"{cls.PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX}{tenant_id}:generation:{generation}"
@staticmethod
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
"""
@ -197,32 +206,41 @@ class PluginService:
cls, tenant_id: str, *, client: PluginModelClient | None = None
) -> tuple[ProviderEntity, ...] | None:
generation = cls._load_plugin_model_providers_generation(tenant_id)
cached_providers, _ = cls._load_cached_plugin_model_providers_for_generation(tenant_id, generation)
return cached_providers
@classmethod
def _load_cached_plugin_model_providers_for_generation(
cls, tenant_id: str, generation: int | None
) -> tuple[tuple[ProviderEntity, ...] | None, bool]:
if generation is not None:
in_memory_cached_providers = cls._load_in_memory_plugin_model_providers(tenant_id, generation)
if in_memory_cached_providers is not None:
return in_memory_cached_providers
return in_memory_cached_providers, True
if generation is None:
return None, False
cache_keys = []
if generation is not None:
cache_keys.append(cls._get_plugin_model_providers_cache_key(tenant_id, generation))
if generation == 0:
cache_keys.append(cls._get_plugin_model_providers_cache_key(tenant_id))
cache_keys.append(cls._get_plugin_model_providers_cache_key(tenant_id, generation))
if generation == 0:
cache_keys.append(cls._get_plugin_model_providers_cache_key(tenant_id))
if not cache_keys:
return None
return None, True
try:
cached_provider_entries = redis_client.mget(cache_keys)
except (RedisError, RuntimeError):
except (LockError, RedisError, RuntimeError):
logger.warning("Failed to read cached plugin model providers for tenant %s.", tenant_id, exc_info=True)
return None
return None, False
if len(cached_provider_entries) != len(cache_keys):
logger.warning(
"Unexpected cached plugin model providers response size for tenant %s.",
tenant_id,
)
return None
return None, False
for cache_key, cached_providers in zip(cache_keys, cached_provider_entries):
if not cached_providers:
@ -232,7 +250,7 @@ class PluginService:
providers = tuple(_provider_entities_adapter.validate_json(cached_providers))
if generation is not None:
cls._store_in_memory_plugin_model_providers(tenant_id, generation, providers)
return providers
return providers, True
except (TypeError, ValueError, ValidationError):
logger.warning(
"Invalid cached plugin model providers for tenant %s; deleting cache key %s.",
@ -249,7 +267,7 @@ class PluginService:
exc_info=True,
)
return None
return None, True
@classmethod
def _store_cached_plugin_model_providers(
@ -262,6 +280,49 @@ class PluginService:
except (RedisError, RuntimeError):
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
@classmethod
def _try_acquire_plugin_model_providers_lock(cls, tenant_id: str, generation: int) -> tuple[Any | None, bool]:
lock_key = cls._get_plugin_model_providers_lock_key(tenant_id, generation)
try:
lock = redis_client.lock(lock_key, timeout=cls.PLUGIN_MODEL_PROVIDERS_LOCK_TTL, blocking=False)
acquired = lock.acquire(blocking=False)
except (RedisError, RuntimeError):
logger.warning(
"Failed to acquire plugin model providers refresh lock for tenant %s.",
tenant_id,
exc_info=True,
)
return None, False
if not acquired:
return None, True
return lock, True
@classmethod
def _release_plugin_model_providers_lock(cls, tenant_id: str, lock: Any) -> None:
try:
lock.release()
except (LockError, RedisError, RuntimeError):
logger.warning(
"Failed to release plugin model providers refresh lock for tenant %s.",
tenant_id,
exc_info=True,
)
@classmethod
def _wait_for_plugin_model_providers_refresh(
cls, tenant_id: str, *, client: PluginModelClient | None = None
) -> tuple[ProviderEntity, ...] | None:
deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT
while time.monotonic() < deadline:
time.sleep(cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL)
cached_providers = cls._load_cached_plugin_model_providers(tenant_id, client=client)
if cached_providers is not None:
return cached_providers
return None
@classmethod
def invalidate_plugin_model_providers_cache(cls, tenant_id: str) -> None:
"""Invalidate tenant-scoped provider metadata across Redis and worker-local mirrors."""
@ -287,21 +348,38 @@ class PluginService:
are intentionally owned by this service so tenant isolation and cache
expiry are handled in one place.
"""
cached_providers = cls._load_cached_plugin_model_providers(tenant_id, client=client)
generation = cls._load_plugin_model_providers_generation(tenant_id)
cached_providers, cache_available = cls._load_cached_plugin_model_providers_for_generation(
tenant_id, generation
)
if cached_providers is not None:
return cached_providers
refresh_lock: Any | None = None
refresh_generation = generation
if generation is not None and cache_available:
lock_wait_deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_TTL
while time.monotonic() < lock_wait_deadline:
refresh_lock, lock_available = cls._try_acquire_plugin_model_providers_lock(tenant_id, generation)
if refresh_lock is not None or not lock_available:
break
refreshed_providers = cls._wait_for_plugin_model_providers_refresh(tenant_id, client=client)
if refreshed_providers is not None:
return refreshed_providers
model_client = client or PluginModelClient()
providers = tuple(
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
)
if not providers:
try:
providers = tuple(
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
)
generation = cls._load_plugin_model_providers_generation(tenant_id)
if generation is not None and generation == refresh_generation:
cls._store_in_memory_plugin_model_providers(tenant_id, generation, providers)
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
return providers
generation = cls._load_plugin_model_providers_generation(tenant_id)
if generation is not None:
cls._store_in_memory_plugin_model_providers(tenant_id, generation, providers)
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
return providers
finally:
if refresh_lock is not None:
cls._release_plugin_model_providers_lock(tenant_id, refresh_lock)
@staticmethod
def fetch_latest_plugin_version(plugin_ids: Sequence[str]) -> Mapping[str, LatestPluginCache | None]:

View File

@ -701,16 +701,29 @@ def _delete_records(query_sql: str, params: dict[str, Any], delete_func: Callabl
if not rows:
break
success_count = 0
for i in rows:
record_id = str(i.id)
try:
delete_func(session, record_id)
logger.info(click.style(f"Deleted {name} {record_id}", fg="green"))
session.commit()
success_count += 1
except Exception:
logger.exception("Error occurred while deleting %s %s", name, record_id)
# continue with next record even if one deletion fails
session.rollback()
break
session.commit()
continue
rs.close()
# If we couldn't delete ANY records in this batch, we must break out of the while loop
# to prevent an infinite loop where we keep fetching the same failing records.
if success_count == 0:
logger.warning(
click.style(
f"Failed to delete any {name} in the current batch. Stopping to prevent infinite loop.",
fg="yellow",
)
)
break

View File

@ -44,6 +44,29 @@ class _FakeRedis:
def delete(self, key: str) -> None:
self._values.pop(key, None)
def lock(self, key: str, *, timeout: int, blocking: bool) -> "_FakeRedisLock":
return _FakeRedisLock(self, key)
class _FakeRedisLock:
def __init__(self, redis: _FakeRedis, key: str) -> None:
self._redis = redis
self._key = key
self._acquired = False
def acquire(self, *, blocking: bool) -> bool:
if self._key in self._redis._values:
return False
self._redis._values[self._key] = "locked"
self._acquired = True
return True
def release(self) -> None:
if self._acquired:
self._redis.delete(self._key)
self._acquired = False
@pytest.fixture(autouse=True)
def clear_plugin_model_provider_memory_cache() -> None:
@ -416,9 +439,10 @@ class TestPluginModelRuntime:
mget=Mock(return_value=[None, None]),
delete=Mock(),
setex=Mock(),
lock=Mock(return_value=SimpleNamespace(acquire=Mock(return_value=True), release=Mock())),
),
)
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300)
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 0)
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
runtime.fetch_model_providers()

View File

@ -235,6 +235,172 @@ class TestPluginModelProviderCache:
client.fetch_model_providers.assert_called_once_with("tenant-1")
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
def test_fetch_plugin_model_providers_waits_for_concurrent_refresh_cache_fill(self) -> None:
"""A cache miss waits for the active tenant refresh instead of stampeding the daemon."""
cached_provider = _build_provider_entity()
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]).decode("utf-8")
cache_key = _provider_cache_key("tenant-1", 0)
legacy_cache_key = _provider_cache_key("tenant-1")
with (
patch(f"{MODULE}.redis_client") as redis_client,
patch(f"{MODULE}.time.sleep") as sleep,
):
redis_client.get.return_value = None
redis_client.mget.side_effect = [[None, None], [cached_payload, None]]
redis_client.lock.return_value.acquire.return_value = False
client = Mock()
client.fetch_model_providers.return_value = [_build_plugin_model_provider(provider="anthropic")]
from core.plugin.plugin_service import PluginService
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
redis_client.lock.assert_called_once_with(
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
blocking=False,
)
redis_client.lock.return_value.acquire.assert_called_once_with(blocking=False)
assert redis_client.mget.call_args_list == [
call([cache_key, legacy_cache_key]),
call([cache_key, legacy_cache_key]),
]
sleep.assert_called()
client.fetch_model_providers.assert_not_called()
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
def test_fetch_plugin_model_providers_retries_lock_after_wait_timeout(self) -> None:
"""Only a lock owner should refresh the daemon when the first refresh takes too long."""
with (
patch(f"{MODULE}.redis_client") as redis_client,
patch(f"{MODULE}.time.sleep"),
patch(f"{MODULE}.PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT", 0),
):
redis_client.get.return_value = None
redis_client.mget.return_value = [None, None]
redis_client.lock.return_value.acquire.side_effect = [False, True]
client = Mock()
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
from core.plugin.plugin_service import PluginService
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
assert redis_client.lock.return_value.acquire.call_args_list == [
call(blocking=False),
call(blocking=False),
]
client.fetch_model_providers.assert_called_once_with("tenant-1")
redis_client.lock.return_value.release.assert_called_once_with()
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
def test_fetch_plugin_model_providers_releases_owned_refresh_lock_after_store(self) -> None:
"""The refresh owner releases only its token after storing provider metadata."""
cache_key = _provider_cache_key("tenant-1", 0)
legacy_cache_key = _provider_cache_key("tenant-1")
with patch(f"{MODULE}.redis_client") as redis_client:
redis_client.get.return_value = None
redis_client.mget.return_value = [None, None]
redis_client.lock.return_value.acquire.return_value = True
client = Mock()
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
from core.plugin.plugin_service import PluginService
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
redis_client.lock.assert_called_once_with(
PluginService._get_plugin_model_providers_lock_key("tenant-1", 0),
timeout=PluginService.PLUGIN_MODEL_PROVIDERS_LOCK_TTL,
blocking=False,
)
redis_client.lock.return_value.acquire.assert_called_once_with(blocking=False)
redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key])
redis_client.lock.return_value.release.assert_called_once_with()
redis_client.eval.assert_not_called()
client.fetch_model_providers.assert_called_once_with("tenant-1")
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
def test_fetch_plugin_model_providers_skips_wait_when_refresh_lock_fails(self) -> None:
"""Lock API failures should fall back directly instead of adding timeout latency."""
with (
patch(f"{MODULE}.redis_client") as redis_client,
patch(f"{MODULE}.time.sleep") as sleep,
):
redis_client.get.return_value = None
redis_client.mget.return_value = [None, None]
redis_client.lock.side_effect = RedisError("redis unavailable")
redis_client.set.side_effect = AssertionError("raw redis set must not be used for refresh locks")
client = Mock()
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
from core.plugin.plugin_service import PluginService
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
sleep.assert_not_called()
redis_client.lock.assert_called_once()
client.fetch_model_providers.assert_called_once_with("tenant-1")
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
def test_fetch_plugin_model_providers_caches_empty_provider_list(self) -> None:
"""An empty provider list is still a valid refresh result for single-flight waiters."""
cache_key = _provider_cache_key("tenant-1", 0)
with patch(f"{MODULE}.redis_client") as redis_client:
redis_client.get.return_value = None
redis_client.mget.return_value = [None, None]
redis_client.lock.return_value.acquire.return_value = True
client = Mock()
client.fetch_model_providers.return_value = []
from core.plugin.plugin_service import PluginService
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
assert result == ()
redis_client.setex.assert_called_once()
assert redis_client.setex.call_args.args[0] == cache_key
redis_client.lock.return_value.release.assert_called_once_with()
def test_fetch_plugin_model_providers_skips_cache_write_when_generation_changes_during_refresh(self) -> None:
"""A refresh that started before invalidation must not populate the newer generation cache."""
with patch(f"{MODULE}.redis_client") as redis_client:
redis_client.get.side_effect = [None, "1"]
redis_client.mget.return_value = [None, None]
redis_client.lock.return_value.acquire.return_value = True
client = Mock()
client.fetch_model_providers.return_value = []
from core.plugin.plugin_service import PluginService
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
assert result == ()
client.fetch_model_providers.assert_called_once_with("tenant-1")
redis_client.setex.assert_not_called()
redis_client.lock.return_value.release.assert_called_once_with()
def test_fetch_plugin_model_providers_reuses_cached_empty_provider_list(self) -> None:
"""A cached empty list should prevent repeated daemon fetches for tenants without plugin models."""
empty_payload = TypeAdapter(list[ProviderEntity]).dump_json([]).decode("utf-8")
cache_key = _provider_cache_key("tenant-1", 0)
legacy_cache_key = _provider_cache_key("tenant-1")
with patch(f"{MODULE}.redis_client") as redis_client:
redis_client.get.return_value = None
redis_client.mget.return_value = [empty_payload, None]
client = Mock()
from core.plugin.plugin_service import PluginService
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
assert result == ()
redis_client.mget.assert_called_once_with([cache_key, legacy_cache_key])
client.fetch_model_providers.assert_not_called()
def test_fetch_plugin_model_providers_creates_default_client_on_cache_miss(self) -> None:
"""The service owns plugin daemon access when no runtime-provided client is injected."""
with (

View File

@ -1,26 +0,0 @@
@agent-v2 @authenticated @access-point @core
Feature: Agent v2 Access Point
Scenario: Access Point shows the available Agent v2 access surfaces
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
When I open the Agent v2 Access Point page
Then I should see the Agent v2 Access Point overview
Scenario: Backend service API supports endpoint copy, key creation, and API reference navigation
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And Agent v2 Backend service API access has been enabled via API
When I open the Agent v2 configure page from the Agent Roster
And I switch to the Agent v2 Access Point section
Then I should see the Agent v2 Backend service API endpoint
When I copy the Agent v2 Backend service API endpoint
Then the Agent v2 Backend service API endpoint should show it was copied
When I open Agent v2 API key management
Then Agent v2 API keys should not expose a secret by default
When I create a new Agent v2 API key
Then I should see the newly generated Agent v2 API key once
When I close the newly generated Agent v2 API key
Then the Agent v2 API key list should not expose the full generated secret
When I close Agent v2 API key management
And I open the Agent v2 API Reference
Then the Agent v2 API Reference should open in a new tab

View File

@ -1,16 +0,0 @@
@agent-v2 @authenticated @build @core
Feature: Agent v2 build draft
Scenario: Discarding a Build draft keeps the original Agent configuration
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And the Agent v2 composer draft uses the normal E2E prompt
And an Agent v2 Build draft uses the updated E2E prompt
When I open the Agent v2 configure page
Then I should see the Agent v2 Build draft pending changes
And I should see the updated E2E prompt in the Agent v2 prompt editor
When I discard the Agent v2 Build draft
Then I should see the normal E2E prompt in the Agent v2 prompt editor
And the Agent v2 Build draft should no longer be active
When I refresh the current page
Then I should see the normal E2E prompt in the Agent v2 prompt editor
And the Agent v2 Build draft should no longer be active

View File

@ -1,10 +0,0 @@
@agent-v2 @authenticated @core
Feature: Agent v2 configure persistence
Scenario: Persisted Agent v2 instructions remain visible after refresh
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And the Agent v2 composer draft uses the normal E2E prompt
When I open the Agent v2 configure page
Then I should see the normal E2E prompt in the Agent v2 prompt editor
When I refresh the current page
Then I should see the normal E2E prompt in the Agent v2 prompt editor

View File

@ -1,9 +0,0 @@
@agent-v2 @authenticated @core
Feature: Agent v2 configure validation
Scenario: Preview is unavailable until a required model is configured
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And the Agent v2 composer draft uses the normal E2E prompt
When I open the Agent v2 configure page
Then Agent v2 Preview should be unavailable until a model is configured
And I should see the normal E2E prompt in the Agent v2 prompt editor

View File

@ -1,9 +0,0 @@
@agent-v2 @authenticated @publish @core
Feature: Agent v2 publish
Scenario: Publish a configured Agent v2 draft
Given I am signed in as the default E2E admin
And an Agent v2 test agent has been created via API
And the Agent v2 composer draft uses the normal E2E prompt
When I open the Agent v2 configure page
And I publish the Agent v2 draft
Then the Agent v2 draft should be published and up to date

View File

@ -1,175 +0,0 @@
import type { DifyWorld } from '../../support/world'
import { Given, Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
import { getAgentAccessPath, setAgentApiAccess } from '../../../support/agent'
const getCurrentAgentId = (world: DifyWorld) => {
const agentId = world.createdAgentIds.at(-1)
if (!agentId)
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
return agentId
}
Given(
'Agent v2 Backend service API access has been enabled via API',
async function (this: DifyWorld) {
const apiAccess = await setAgentApiAccess(getCurrentAgentId(this), true)
this.lastAgentServiceApiBaseURL = apiAccess.service_api_base_url
},
)
When('I open the Agent v2 Access Point page', async function (this: DifyWorld) {
await this.getPage().goto(getAgentAccessPath(getCurrentAgentId(this)))
})
When('I switch to the Agent v2 Access Point section', async function (this: DifyWorld) {
const page = this.getPage()
const agentId = getCurrentAgentId(this)
await page.getByRole('link', { name: 'Access Point' }).click()
await expect(page).toHaveURL(new RegExp(`/roster/agent/${agentId}/access(?:\\?.*)?$`))
await expect(page.getByRole('region', { name: 'Access Point' })).toBeVisible()
})
Then('I should see the Agent v2 Access Point overview', async function (this: DifyWorld) {
const page = this.getPage()
const accessRegion = page.getByRole('region', { name: 'Access Point' })
await expect(accessRegion).toBeVisible({ timeout: 30_000 })
await expect(accessRegion.getByRole('heading', { name: 'Access Point' })).toBeVisible()
await expect(accessRegion.getByRole('heading', { name: 'Web app' })).toBeVisible()
await expect(accessRegion.getByRole('heading', { name: 'Backend service API' })).toBeVisible()
await expect(accessRegion.getByRole('heading', { name: 'Workflow access' })).toBeVisible()
await expect(accessRegion.getByRole('columnheader', { name: 'Name' })).toBeVisible()
await expect(accessRegion.getByRole('columnheader', { name: 'Version' })).toBeVisible()
await expect(accessRegion.getByRole('columnheader', { name: 'Nodes' })).toBeVisible()
await expect(accessRegion.getByRole('columnheader', { name: 'Last updated' })).toBeVisible()
await expect(accessRegion.getByRole('columnheader', { name: 'Actions' })).toBeVisible()
await expect(accessRegion.getByText('No workflow references yet.')).toBeVisible()
})
Then('I should see the Agent v2 Backend service API endpoint', async function (this: DifyWorld) {
const page = this.getPage()
if (!this.lastAgentServiceApiBaseURL)
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
await expect(page.getByRole('heading', { name: 'Backend service API' })).toBeVisible({
timeout: 30_000,
})
await expect(page.getByText('Service API Endpoint')).toBeVisible()
await expect(page.getByText(this.lastAgentServiceApiBaseURL)).toBeVisible()
await expect(page.getByLabel('Copy service API endpoint')).toBeEnabled()
})
When('I copy the Agent v2 Backend service API endpoint', async function (this: DifyWorld) {
await this.getPage().getByLabel('Copy service API endpoint').click()
})
Then(
'the Agent v2 Backend service API endpoint should show it was copied',
async function (this: DifyWorld) {
await expect(this.getPage().getByLabel('Copied')).toBeVisible()
},
)
When('I open Agent v2 API key management', async function (this: DifyWorld) {
await this.getPage()
.getByRole('button', { name: /^API Key\b/ })
.click()
})
Then('Agent v2 API keys should not expose a secret by default', async function (this: DifyWorld) {
const page = this.getPage()
const dialog = page.getByRole('dialog', { name: /API Secret key/i })
await expect(dialog).toBeVisible()
await expect(dialog.getByText('Secret Key', { exact: true })).toBeVisible()
await expect(dialog.getByText('CREATED', { exact: true })).toBeVisible()
await expect(dialog.getByText('LAST USED', { exact: true })).toBeVisible()
await expect(dialog.getByText('No data', { exact: true })).toBeVisible()
await expect(dialog.getByRole('button', { name: 'Create new Secret key' })).toBeVisible()
await expect(dialog.getByText(/^app-/)).not.toBeVisible()
await expect(page.getByRole('dialog', { name: 'Internal Server Error' })).not.toBeVisible()
})
When('I create a new Agent v2 API key', async function (this: DifyWorld) {
const dialog = this.getPage().getByRole('dialog', { name: /API Secret key/i })
await dialog.getByRole('button', { name: 'Create new Secret key' }).click()
})
Then('I should see the newly generated Agent v2 API key once', async function (this: DifyWorld) {
const generatedKeyDialog = this.getPage()
.getByRole('dialog', { name: /API Secret key/i })
.last()
const generatedKey = generatedKeyDialog.getByText(/^app-/)
await expect(generatedKeyDialog).toBeVisible()
await expect(
generatedKeyDialog.getByText('Keep this key in a secure and accessible place.'),
).toBeVisible()
await expect(generatedKey).toBeVisible()
await expect(generatedKeyDialog.getByLabel('Copy')).toBeVisible()
this.lastGeneratedAgentApiKey = (await generatedKey.textContent())?.trim()
if (!this.lastGeneratedAgentApiKey)
throw new Error('Generated Agent v2 API key was empty.')
})
When('I close the newly generated Agent v2 API key', async function (this: DifyWorld) {
const page = this.getPage()
const generatedKeyDialog = page.getByRole('dialog', { name: /API Secret key/i }).last()
await generatedKeyDialog.getByRole('button', { name: 'OK' }).click()
await expect(page.getByText('Keep this key in a secure and accessible place.')).not.toBeVisible()
})
Then(
'the Agent v2 API key list should not expose the full generated secret',
async function (this: DifyWorld) {
const fullSecret = this.lastGeneratedAgentApiKey
if (!fullSecret)
throw new Error('No generated Agent v2 API key found.')
const apiKeyDialog = this.getPage().getByRole('dialog', { name: /API Secret key/i })
await expect(apiKeyDialog).toBeVisible()
await expect(apiKeyDialog.getByText(fullSecret, { exact: true })).not.toBeVisible()
await expect(apiKeyDialog.getByText(/^app-/)).not.toBeVisible()
await expect(apiKeyDialog.getByLabel('Copy')).toBeVisible()
},
)
When('I close Agent v2 API key management', async function (this: DifyWorld) {
const apiKeyDialog = this.getPage().getByRole('dialog', { name: /API Secret key/i })
await apiKeyDialog.getByLabel('Close').click()
await expect(apiKeyDialog).not.toBeVisible()
})
When('I open the Agent v2 API Reference', async function (this: DifyWorld) {
const page = this.getPage()
const apiReferenceLink = page.getByRole('link', { name: 'API Reference' })
await expect(apiReferenceLink).toBeVisible()
const [apiReferencePage] = await Promise.all([
page.waitForEvent('popup'),
apiReferenceLink.click(),
])
this.lastAgentApiReferencePage = apiReferencePage
})
Then('the Agent v2 API Reference should open in a new tab', async function (this: DifyWorld) {
const apiReferencePage = this.lastAgentApiReferencePage
if (!apiReferencePage)
throw new Error('No Agent v2 API Reference page was opened.')
await expect(apiReferencePage).toHaveURL(/developing-with-apis/)
await apiReferencePage.close()
this.lastAgentApiReferencePage = undefined
})

View File

@ -4,23 +4,9 @@ import { expect } from '@playwright/test'
import {
createTestAgent,
getAgentConfigurePath,
getTestAgent,
normalAgentPrompt,
normalAgentSoulConfig,
saveAgentBuildDraft,
saveAgentComposerDraft,
updatedAgentPrompt,
updatedAgentSoulConfig,
} from '../../../support/agent'
const getCurrentAgentId = (world: DifyWorld) => {
const agentId = world.createdAgentIds.at(-1)
if (!agentId)
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
return agentId
}
Given('an Agent v2 test agent has been created via API', async function (this: DifyWorld) {
const agent = await createTestAgent()
this.createdAgentIds.push(agent.id)
@ -29,53 +15,25 @@ Given('an Agent v2 test agent has been created via API', async function (this: D
})
Given('a minimal Agent v2 composer draft has been synced', async function (this: DifyWorld) {
const agentId = getCurrentAgentId(this)
const agentId = this.createdAgentIds.at(-1)
if (!agentId)
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
await saveAgentComposerDraft(agentId)
})
Given('the Agent v2 composer draft uses the normal E2E prompt', async function (this: DifyWorld) {
await saveAgentComposerDraft(getCurrentAgentId(this), normalAgentSoulConfig)
})
Given('an Agent v2 Build draft uses the updated E2E prompt', async function (this: DifyWorld) {
await saveAgentBuildDraft(getCurrentAgentId(this), updatedAgentSoulConfig)
})
When('I open the Agent v2 configure page', async function (this: DifyWorld) {
await this.getPage().goto(getAgentConfigurePath(getCurrentAgentId(this)))
})
const agentId = this.createdAgentIds.at(-1)
if (!agentId)
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
When(
'I open the Agent v2 configure page from the Agent Roster',
async function (this: DifyWorld) {
const page = this.getPage()
const agentId = getCurrentAgentId(this)
const agentName = this.lastCreatedAgentName
if (!agentName)
throw new Error('No Agent v2 name found. Create an Agent v2 test agent first.')
await page.goto('/roster')
await page.getByRole('link', { name: agentName }).click()
await expect(page).toHaveURL(new RegExp(`/roster/agent/${agentId}/configure(?:\\?.*)?$`))
await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible({ timeout: 30_000 })
},
)
When('I discard the Agent v2 Build draft', async function (this: DifyWorld) {
await this.getPage().getByRole('button', { name: 'Discard' }).click()
})
When('I publish the Agent v2 draft', async function (this: DifyWorld) {
const page = this.getPage()
const publishButton = page.getByRole('button', { name: /^Publish(?: update)?$/ })
await expect(publishButton).toBeEnabled({ timeout: 30_000 })
await publishButton.click()
await this.getPage().goto(getAgentConfigurePath(agentId))
})
Then('I should be on the Agent v2 configure page', async function (this: DifyWorld) {
const agentId = getCurrentAgentId(this)
const agentId = this.createdAgentIds.at(-1)
if (!agentId)
throw new Error('No Agent v2 ID found. Create an Agent v2 test agent first.')
await expect(this.getPage()).toHaveURL(
new RegExp(`/roster/agent/${agentId}/configure(?:\\?.*)?$`),
@ -89,58 +47,3 @@ Then('I should see the Agent v2 configure workspace', async function (this: Dify
await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible()
await expect(page.getByText(this.lastCreatedAgentName!)).toBeVisible()
})
Then(
'I should see the normal E2E prompt in the Agent v2 prompt editor',
async function (this: DifyWorld) {
const page = this.getPage()
await expect(page.getByRole('heading', { name: 'Prompt' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByText(normalAgentPrompt)).toBeVisible()
},
)
Then(
'I should see the updated E2E prompt in the Agent v2 prompt editor',
async function (this: DifyWorld) {
const page = this.getPage()
await expect(page.getByRole('heading', { name: 'Prompt' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByText(updatedAgentPrompt)).toBeVisible()
},
)
Then(
'Agent v2 Preview should be unavailable until a model is configured',
async function (this: DifyWorld) {
const page = this.getPage()
await expect(page.getByRole('heading', { name: 'Configure' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('button', { name: /^Preview$/i })).toBeDisabled()
},
)
Then('I should see the Agent v2 Build draft pending changes', async function (this: DifyWorld) {
const page = this.getPage()
await expect(page.getByText('Build draft')).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Apply' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Discard' })).toBeVisible()
})
Then('the Agent v2 Build draft should no longer be active', async function (this: DifyWorld) {
const page = this.getPage()
await expect(page.getByText('Build draft')).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Apply' })).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Discard' })).not.toBeVisible()
})
Then('the Agent v2 draft should be published and up to date', async function (this: DifyWorld) {
const page = this.getPage()
const agentId = getCurrentAgentId(this)
await expect(page.getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 })
await expect(page.getByText('Up to date')).toBeVisible()
await expect.poll(async () => (await getTestAgent(agentId)).active_config_is_published).toBe(true)
})

View File

@ -7,10 +7,6 @@ When('I open the apps console', async function (this: DifyWorld) {
await this.getPage().goto('/apps')
})
When('I refresh the current page', async function (this: DifyWorld) {
await this.getPage().reload()
})
Then('I should stay on the apps console', async function (this: DifyWorld) {
await waitForAppsConsole(this.getPage())
})

View File

@ -15,9 +15,6 @@ export class DifyWorld extends World {
lastCreatedAppName: string | undefined
lastCreatedAgentName: string | undefined
lastCreatedAgentRole: string | undefined
lastAgentServiceApiBaseURL: string | undefined
lastGeneratedAgentApiKey: string | undefined
lastAgentApiReferencePage: Page | undefined
createdAppIds: string[] = []
createdAgentIds: string[] = []
capturedDownloads: Download[] = []
@ -34,9 +31,6 @@ export class DifyWorld extends World {
this.lastCreatedAppName = undefined
this.lastCreatedAgentName = undefined
this.lastCreatedAgentRole = undefined
this.lastAgentServiceApiBaseURL = undefined
this.lastGeneratedAgentApiKey = undefined
this.lastAgentApiReferencePage = undefined
this.createdAppIds = []
this.createdAgentIds = []
this.capturedDownloads = []

View File

@ -1,7 +1,6 @@
import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from './api'
export type AgentSeed = {
active_config_is_published?: boolean
app_id?: string
backing_app_id?: string
description?: string
@ -30,9 +29,10 @@ export type AgentBuildDraftResponse = {
export type AgentApiAccess = {
api_key_count: number
api_reference_url: string
endpoint: string
enabled: boolean
files_upload_endpoint: string
service_api_base_url: string
}
export type AgentApiKey = {
@ -46,24 +46,6 @@ export const defaultAgentSoulConfig: AgentSoulConfig = {
},
}
export const normalAgentPrompt
= 'You are a Dify Agent E2E test assistant. Reply briefly to every user message, and always include AGENT_E2E_PASS in your response.'
export const updatedAgentPrompt
= 'You are a Dify Agent E2E test assistant. Every response must start with E2E_AGENT_UPDATED.'
export const normalAgentSoulConfig: AgentSoulConfig = {
prompt: {
system_prompt: normalAgentPrompt,
},
}
export const updatedAgentSoulConfig: AgentSoulConfig = {
prompt: {
system_prompt: updatedAgentPrompt,
},
}
export const getAgentConfigurePath = (agentId: string) => `/roster/agent/${agentId}/configure`
export const getAgentAccessPath = (agentId: string) => `/roster/agent/${agentId}/access`
@ -154,27 +136,6 @@ export async function checkoutAgentBuildDraft(agentId: string): Promise<AgentBui
}
}
export async function saveAgentBuildDraft(
agentId: string,
agentSoul: AgentSoulConfig,
): Promise<AgentBuildDraftResponse> {
const ctx = await createApiContext()
try {
const response = await ctx.put(`/console/api/agent/${agentId}/build-draft`, {
data: {
agent_soul: agentSoul,
save_strategy: 'save_to_current_version',
variant: 'agent_app',
},
})
await expectApiResponseOK(response, `Save Agent v2 build draft for ${agentId}`)
return (await response.json()) as AgentBuildDraftResponse
}
finally {
await ctx.dispose()
}
}
export async function discardAgentBuildDraft(agentId: string): Promise<void> {
const ctx = await createApiContext()
try {

View File

@ -1,103 +1,54 @@
// This file is auto-generated by @hey-api/openapi-ts
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'
export const contract = {
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 contractLoaders = {
account: () => import('./account/orpc.gen').then(({ account }) => ({ account })),
activate: () => import('./activate/orpc.gen').then(({ activate }) => ({ activate })),
agent: () => import('./agent/orpc.gen').then(({ agent }) => ({ agent })),
allWorkspaces: () => import('./all-workspaces/orpc.gen').then(({ allWorkspaces }) => ({ allWorkspaces })),
apiBasedExtension: () => import('./api-based-extension/orpc.gen').then(({ apiBasedExtension }) => ({ apiBasedExtension })),
apiKeyAuth: () => import('./api-key-auth/orpc.gen').then(({ apiKeyAuth }) => ({ apiKeyAuth })),
app: () => import('./app/orpc.gen').then(({ app }) => ({ app })),
appDslVersion: () => import('./app-dsl-version/orpc.gen').then(({ appDslVersion }) => ({ appDslVersion })),
apps: () => import('./apps/orpc.gen').then(({ apps }) => ({ apps })),
auth: () => import('./auth/orpc.gen').then(({ auth }) => ({ auth })),
billing: () => import('./billing/orpc.gen').then(({ billing }) => ({ billing })),
codeBasedExtension: () => import('./code-based-extension/orpc.gen').then(({ codeBasedExtension }) => ({ codeBasedExtension })),
compliance: () => import('./compliance/orpc.gen').then(({ compliance }) => ({ compliance })),
dataSource: () => import('./data-source/orpc.gen').then(({ dataSource }) => ({ dataSource })),
datasets: () => import('./datasets/orpc.gen').then(({ datasets }) => ({ datasets })),
emailCodeLogin: () => import('./email-code-login/orpc.gen').then(({ emailCodeLogin }) => ({ emailCodeLogin })),
emailRegister: () => import('./email-register/orpc.gen').then(({ emailRegister }) => ({ emailRegister })),
explore: () => import('./explore/orpc.gen').then(({ explore }) => ({ explore })),
features: () => import('./features/orpc.gen').then(({ features }) => ({ features })),
files: () => import('./files/orpc.gen').then(({ files }) => ({ files })),
forgotPassword: () => import('./forgot-password/orpc.gen').then(({ forgotPassword }) => ({ forgotPassword })),
form: () => import('./form/orpc.gen').then(({ form }) => ({ form })),
info: () => import('./info/orpc.gen').then(({ info }) => ({ info })),
installedApps: () => import('./installed-apps/orpc.gen').then(({ installedApps }) => ({ installedApps })),
instructionGenerate: () => import('./instruction-generate/orpc.gen').then(({ instructionGenerate }) => ({ instructionGenerate })),
login: () => import('./login/orpc.gen').then(({ login }) => ({ login })),
logout: () => import('./logout/orpc.gen').then(({ logout }) => ({ logout })),
notification: () => import('./notification/orpc.gen').then(({ notification }) => ({ notification })),
notion: () => import('./notion/orpc.gen').then(({ notion }) => ({ notion })),
oauth: () => import('./oauth/orpc.gen').then(({ oauth }) => ({ oauth })),
rag: () => import('./rag/orpc.gen').then(({ rag }) => ({ rag })),
refreshToken: () => import('./refresh-token/orpc.gen').then(({ refreshToken }) => ({ refreshToken })),
remoteFiles: () => import('./remote-files/orpc.gen').then(({ remoteFiles }) => ({ remoteFiles })),
resetPassword: () => import('./reset-password/orpc.gen').then(({ resetPassword }) => ({ resetPassword })),
ruleCodeGenerate: () => import('./rule-code-generate/orpc.gen').then(({ ruleCodeGenerate }) => ({ ruleCodeGenerate })),
ruleGenerate: () => import('./rule-generate/orpc.gen').then(({ ruleGenerate }) => ({ ruleGenerate })),
ruleStructuredOutputGenerate: () =>
import('./rule-structured-output-generate/orpc.gen').then(({ ruleStructuredOutputGenerate }) => ({ ruleStructuredOutputGenerate })),
snippets: () => import('./snippets/orpc.gen').then(({ snippets }) => ({ snippets })),
spec: () => import('./spec/orpc.gen').then(({ spec }) => ({ spec })),
systemFeatures: () => import('./system-features/orpc.gen').then(({ systemFeatures }) => ({ systemFeatures })),
tagBindings: () => import('./tag-bindings/orpc.gen').then(({ tagBindings }) => ({ tagBindings })),
tags: () => import('./tags/orpc.gen').then(({ tags }) => ({ tags })),
test: () => import('./test/orpc.gen').then(({ test }) => ({ test })),
trialApps: () => import('./trial-apps/orpc.gen').then(({ trialApps }) => ({ trialApps })),
trialModels: () => import('./trial-models/orpc.gen').then(({ trialModels }) => ({ trialModels })),
website: () => import('./website/orpc.gen').then(({ website }) => ({ website })),
workflow: () => import('./workflow/orpc.gen').then(({ workflow }) => ({ workflow })),
workflowGenerate: () => import('./workflow-generate/orpc.gen').then(({ workflowGenerate }) => ({ workflowGenerate })),
workspaces: () => import('./workspaces/orpc.gen').then(({ workspaces }) => ({ workspaces })),
}

View File

@ -344,16 +344,13 @@ const consoleContractEntryContent = (segments: string[]) => {
}
})
const imports = contracts
.map(contract => `import { ${contract.name} } from './${contract.importPath}/orpc.gen'`)
const contractEntries = contracts
.map(contract => ` ${contract.name}: () => import('./${contract.importPath}/orpc.gen').then(({ ${contract.name} }) => ({ ${contract.name} })),`)
.join('\n')
const contractEntries = contracts.map(contract => ` ${contract.name},`).join('\n')
return `// This file is auto-generated by @hey-api/openapi-ts
${imports}
export const contract = {
export const contractLoaders = {
${contractEntries}
}
`

View File

@ -0,0 +1,21 @@
'use client'
import dynamic from '@/next/dynamic'
const InSiteMessageNotification = dynamic(() => import('@/app/components/app/in-site-message/notification'), { ssr: false })
const PartnerStack = dynamic(() => import('@/app/components/billing/partner-stack'), { ssr: false })
const ReadmePanel = dynamic(() => import('@/app/components/plugins/readme-panel'), { ssr: false })
const WorkflowGeneratorMount = dynamic(() => import('@/app/components/workflow/workflow-generator/mount'), { ssr: false })
const GotoAnything = dynamic(() => import('@/app/components/goto-anything').then(mod => mod.GotoAnything), { ssr: false })
export function CommonLayoutGlobalMounts() {
return (
<>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
</>
)
}

View File

@ -1,20 +1,16 @@
import type { ReactNode } from 'react'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { GotoAnything } from '@/app/components/goto-anything'
import MainNavLayout from '@/app/components/main-nav/layout'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import WorkflowGeneratorMount from '@/app/components/workflow/workflow-generator/mount'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import PartnerStack from '../components/billing/partner-stack'
import { CommonLayoutGlobalMounts } from './global-mounts'
import { CommonLayoutHydrationBoundary } from './hydration-boundary'
export default async function Layout({ children }: { children: ReactNode }) {
@ -33,11 +29,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
<MainNavLayout>
{children}
</MainNavLayout>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<WorkflowGeneratorMount />
<CommonLayoutGlobalMounts />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>

View File

@ -495,7 +495,7 @@ describe('MainNav', () => {
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
})
it('aligns the global navigation spacing with the main sidebar design', () => {
it('aligns the global navigation spacing with the main sidebar design', async () => {
mockInstalledApps = [createInstalledApp()]
renderMainNav()
@ -508,7 +508,7 @@ describe('MainNav', () => {
expect(homeLink.closest('nav')).toHaveClass('isolate', 'flex', 'flex-col', 'gap-px', 'p-2')
expect(homeLink).toHaveClass('h-8', 'w-full', 'rounded-[10px]', 'px-2', 'py-1.5')
const webAppsButton = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
const webAppsButton = await screen.findByRole('button', { name: 'explore.sidebar.webApps' })
expect(webAppsButton.parentElement).toHaveClass('py-1', 'pr-2', 'pl-2')
const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })
@ -551,7 +551,7 @@ describe('MainNav', () => {
expect(container.querySelector('.relative.z-30')).not.toBeInTheDocument()
})
it('hides the environment tag when app detail navigation is collapsed', () => {
it('hides the environment tag when app detail navigation is collapsed', async () => {
mockPathname = '/app/app-1/overview'
;(useAppContext as Mock).mockReturnValue({
...appContextValue,
@ -562,7 +562,7 @@ describe('MainNav', () => {
})
const { container } = renderMainNav()
fireEvent.click(screen.getByTestId('app-detail-toggle'))
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
expect(screen.queryByText('common.environment.testing')).not.toBeInTheDocument()
expect(container.querySelector('.relative.z-30')).not.toBeInTheDocument()
@ -659,7 +659,7 @@ describe('MainNav', () => {
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
})
it('replaces global navigation with snippet detail navigation on snippet routes', () => {
it('replaces global navigation with snippet detail navigation on snippet routes', async () => {
mockPathname = '/snippets/snippet-1/orchestrate'
snippetDraftState.inputFields = snippetFields
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
@ -671,8 +671,8 @@ describe('MainNav', () => {
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
expect(screen.getByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('snippet-sidebar-content')).toHaveAttribute('data-readonly', 'false')
expect(await screen.findByTestId('snippet-detail-top')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByTestId('snippet-sidebar-content')).toHaveAttribute('data-readonly', 'false')
expect(screen.getByText(snippet.name)).toBeInTheDocument()
expect(screen.getByText('query')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'change snippet fields' }))
@ -686,14 +686,14 @@ describe('MainNav', () => {
expect(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })).toBeInTheDocument()
})
it('collapses snippet detail navigation from the top-right toggle', () => {
it('collapses snippet detail navigation from the top-right toggle', async () => {
mockPathname = '/snippets/snippet-1/orchestrate'
snippetDraftState.inputFields = snippetFields
snippetNavigationState.onFieldsChange = mockSnippetFieldsChange
snippetNavigationState.snippet = snippet
renderMainNav()
fireEvent.click(screen.getByTestId('snippet-detail-toggle'))
fireEvent.click(await screen.findByTestId('snippet-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
@ -704,13 +704,13 @@ describe('MainNav', () => {
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('replaces global navigation with app detail navigation on app routes', () => {
it('replaces global navigation with app detail navigation on app routes', async () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
expect(screen.getByTestId('app-detail-top')).toBeInTheDocument()
expect(screen.getByTestId('app-detail-section')).toBeInTheDocument()
expect(await screen.findByTestId('app-detail-top')).toBeInTheDocument()
expect(await screen.findByTestId('app-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
@ -742,56 +742,56 @@ describe('MainNav', () => {
})
})
it('collapses app detail navigation from the top-right toggle', () => {
it('collapses app detail navigation from the top-right toggle', async () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
fireEvent.click(screen.getByTestId('app-detail-toggle'))
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).not.toHaveClass('transition-none')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'false')
expect(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'false')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('shows app detail navigation as a floating preview when hovering the collapsed top toggle', () => {
it('shows app detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
fireEvent.click(screen.getByTestId('app-detail-toggle'))
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('app-detail-top').parentElement!)
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
expect(screen.getAllByTestId('app-detail-top')).toHaveLength(1)
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
})
it('persists expanded app detail navigation without width animation when clicking the hovered toggle', () => {
it('persists expanded app detail navigation without width animation when clicking the hovered toggle', async () => {
mockPathname = '/app/app-1/overview'
renderMainNav()
fireEvent.click(screen.getByTestId('app-detail-toggle'))
fireEvent.click(await screen.findByTestId('app-detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('app-detail-top').parentElement!)
fireEvent.click(screen.getByTestId('app-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-62', 'transition-none')
expect(screen.getByRole('complementary')).not.toHaveClass('overflow-visible')
expect(screen.getByTestId('app-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByTestId('app-detail-section')).toHaveAttribute('data-expand', 'true')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('expand')
})
it('replaces global navigation with dataset detail navigation on dataset routes', () => {
it('replaces global navigation with dataset detail navigation on dataset routes', async () => {
mockPathname = '/datasets/dataset-1/documents'
renderMainNav()
expect(screen.getByTestId('dataset-detail-top')).toBeInTheDocument()
expect(screen.getByTestId('dataset-detail-section')).toBeInTheDocument()
expect(await screen.findByTestId('dataset-detail-top')).toBeInTheDocument()
expect(await screen.findByTestId('dataset-detail-section')).toBeInTheDocument()
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
@ -802,42 +802,44 @@ describe('MainNav', () => {
expect(screen.queryByRole('link', { name: /common.menus.datasets/ })).not.toBeInTheDocument()
})
it('collapses dataset detail navigation from the top-right toggle', () => {
it('collapses dataset detail navigation from the top-right toggle', async () => {
mockPathname = '/datasets/dataset-1/documents'
renderMainNav()
fireEvent.click(screen.getByTestId('dataset-detail-toggle'))
fireEvent.click(await screen.findByTestId('dataset-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.getByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'false')
expect(await screen.findByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'false')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
it('shows dataset detail navigation as a floating preview when hovering the collapsed top toggle', () => {
it('shows dataset detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
mockPathname = '/datasets/dataset-1/documents'
renderMainNav()
fireEvent.click(screen.getByTestId('dataset-detail-toggle'))
fireEvent.click(await screen.findByTestId('dataset-detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('dataset-detail-top').parentElement!)
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
expect(screen.getAllByTestId('dataset-detail-top')).toHaveLength(1)
expect(screen.getByTestId('dataset-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByTestId('dataset-detail-section')).toHaveAttribute('data-expand', 'true')
})
it('replaces global navigation with agent detail navigation on roster detail routes', () => {
it('replaces global navigation with agent detail navigation on roster detail routes', async () => {
mockPathname = '/roster/agent/agent-1/configure'
renderMainNav()
expect(screen.getByTestId('agent-detail-top')).toBeInTheDocument()
expect(screen.getByTestId('agent-detail-section')).toBeInTheDocument()
expect(await screen.findByTestId('agent-detail-top')).toBeInTheDocument()
const agentDetailNavigation = await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })
expect(agentDetailNavigation).toBeInTheDocument()
expect(screen.getByRole('link', { name: /agentV2.agentDetail.sections.configure/ })).toHaveAttribute('href', '/roster/agent/agent-1/configure')
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('agent-detail-section')).toHaveAttribute('data-expand', 'true')
expect(agentDetailNavigation).toHaveClass('px-1')
expect(screen.getByRole('complementary')).toHaveClass('w-62')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByRole('complementary')).toHaveClass('bg-background-body')
@ -873,16 +875,16 @@ describe('MainNav', () => {
expect(screen.queryByRole('link', { name: /common.menus.deployments/ })).not.toBeInTheDocument()
})
it('collapses agent detail navigation from the top-right toggle', () => {
it('collapses agent detail navigation from the top-right toggle', async () => {
mockPathname = '/roster/agent/agent-1/configure'
renderMainNav()
fireEvent.click(screen.getByTestId('agent-detail-toggle'))
fireEvent.click(await screen.findByTestId('agent-detail-toggle'))
expect(screen.getByRole('complementary')).toHaveClass('w-16')
expect(screen.getByRole('complementary')).toHaveClass('p-1')
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'false')
expect(screen.getByTestId('agent-detail-section')).toHaveAttribute('data-expand', 'false')
expect(await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })).toHaveClass('px-3')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
})
@ -923,18 +925,18 @@ describe('MainNav', () => {
)
})
it('shows agent detail navigation as a floating preview when hovering the collapsed top toggle', () => {
it('shows agent detail navigation as a floating preview when hovering the collapsed top toggle', async () => {
mockPathname = '/roster/agent/agent-1/configure'
renderMainNav()
fireEvent.click(screen.getByTestId('agent-detail-toggle'))
fireEvent.click(await screen.findByTestId('agent-detail-toggle'))
fireEvent.mouseEnter(screen.getByTestId('agent-detail-top').parentElement!)
expect(screen.getByRole('complementary')).toHaveClass('w-16', 'overflow-visible')
expect(localStorage.getItem(DETAIL_SIDEBAR_STORAGE_KEY)).toBe('collapse')
expect(screen.getAllByTestId('agent-detail-top')).toHaveLength(1)
expect(screen.getByTestId('agent-detail-top')).toHaveAttribute('data-expand', 'true')
expect(screen.getByTestId('agent-detail-section')).toHaveAttribute('data-expand', 'true')
expect(await screen.findByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })).toHaveClass('px-1')
})
it.each([
@ -1258,12 +1260,12 @@ describe('MainNav', () => {
}
})
it('collapses and expands installed web apps from the section arrow', () => {
it('collapses and expands installed web apps from the section arrow', async () => {
mockInstalledApps = [createInstalledApp()]
renderMainNav()
const webAppsButton = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
const webAppsButton = await screen.findByRole('button', { name: 'explore.sidebar.webApps' })
expect(webAppsButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Alpha App')).toBeInTheDocument()

View File

@ -0,0 +1,35 @@
'use client'
import { useShallow } from 'zustand/react/shallow'
import { SnippetCollapsedPreview } from '@/app/components/snippets/components/snippet-collapsed-preview'
import { SnippetSidebarContent } from '@/app/components/snippets/components/snippet-sidebar'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
import { useSnippetDetailStore } from '@/app/components/snippets/store'
type SnippetDetailSectionProps = {
expand: boolean
}
export function SnippetDetailSection({ expand }: SnippetDetailSectionProps) {
const snippetNavigation = useSnippetDetailStore(useShallow(state => ({
onFieldsChange: state.onFieldsChange,
readonly: state.readonly,
snippet: state.snippet,
})))
const snippetInputFields = useSnippetDraftStore(state => state.inputFields)
if (!expand)
return <SnippetCollapsedPreview inputFieldCount={snippetInputFields.length} />
if (!snippetNavigation.snippet || !snippetNavigation.onFieldsChange)
return null
return (
<SnippetSidebarContent
snippet={snippetNavigation.snippet}
fields={snippetInputFields}
readonly={snippetNavigation.readonly}
onFieldsChange={snippetNavigation.onFieldsChange}
/>
)
}

View File

@ -7,31 +7,21 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import AppDetailSection from '@/app/components/app-sidebar/app-detail-section'
import AppDetailTop from '@/app/components/app-sidebar/app-detail-top'
import DatasetDetailSection from '@/app/components/app-sidebar/dataset-detail-section'
import DatasetDetailTop from '@/app/components/app-sidebar/dataset-detail-top'
import { useStore as useAppStore } from '@/app/components/app/store'
import Badge from '@/app/components/base/badge'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import EnvNav from '@/app/components/header/env-nav'
import { SnippetCollapsedPreview } from '@/app/components/snippets/components/snippet-collapsed-preview'
import { SnippetSidebarContent } from '@/app/components/snippets/components/snippet-sidebar'
import { useSnippetDraftStore } from '@/app/components/snippets/draft-store'
import { useSnippetDetailStore } from '@/app/components/snippets/store'
import { useAppContext } from '@/context/app-context'
import { AgentDetailSection, AgentDetailTop } from '@/features/agent-v2/agent-detail/navigation'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { DeploymentDetailSection, DeploymentDetailTop } from '@/features/deployments/detail/deployment-sidebar'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import AccountSection from './components/account-section'
import HelpMenu from './components/help-menu'
import MainNavLink from './components/nav-link'
import { MainNavSearchButton } from './components/search-button'
import SnippetDetailTop from './components/snippet-detail-top'
import WebAppsSection from './components/web-apps-section'
import { WorkspaceCard } from './components/workspace-card'
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
import { useDetailSidebarMode } from './storage'
@ -41,6 +31,16 @@ const DATASET_DOCUMENT_CREATION_ROUTES = new Set(['create', 'create-from-pipelin
const DEPLOYMENT_COLLECTION_ROUTES = new Set(['create'])
const secondarySidebarHelpTriggerIcon = <span aria-hidden className="i-ri-question-line size-4 shrink-0" />
const AppDetailSection = dynamic(() => import('@/app/components/app-sidebar/app-detail-section'), { ssr: false })
const AppDetailTop = dynamic(() => import('@/app/components/app-sidebar/app-detail-top'), { ssr: false })
const DatasetDetailSection = dynamic(() => import('@/app/components/app-sidebar/dataset-detail-section'), { ssr: false })
const DatasetDetailTop = dynamic(() => import('@/app/components/app-sidebar/dataset-detail-top'), { ssr: false })
const AgentDetailSection = dynamic(() => import('@/features/agent-v2/agent-detail/navigation').then(mod => mod.AgentDetailSection), { ssr: false })
const AgentDetailTop = dynamic(() => import('@/features/agent-v2/agent-detail/navigation').then(mod => mod.AgentDetailTop), { ssr: false })
const SnippetDetailTop = dynamic(() => import('./components/snippet-detail-top'), { ssr: false })
const SnippetDetailSection = dynamic(() => import('./components/snippet-detail-section').then(mod => mod.SnippetDetailSection), { ssr: false })
const WebAppsSection = dynamic(() => import('./components/web-apps-section'), { ssr: false })
function SecondarySidebarHelpMenu({
triggerClassName,
}: {
@ -103,12 +103,6 @@ export function MainNav({
const showDeploymentDetailNavigation = canUseAppDeploy && !isCurrentWorkspaceDatasetOperator && isDeploymentDetailPathname(pathname)
const showSnippetDetailNavigation = isSnippetDetailPathname(pathname)
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation || showAgentDetailNavigation || showDeploymentDetailNavigation || showSnippetDetailNavigation
const snippetNavigation = useSnippetDetailStore(useShallow(state => ({
onFieldsChange: state.onFieldsChange,
readonly: state.readonly,
snippet: state.snippet,
})))
const snippetInputFields = useSnippetDraftStore(state => state.inputFields)
const { hasAppDetail, setAppDetail } = useAppStore(useShallow(state => ({
hasAppDetail: !!state.appDetail,
setAppDetail: state.setAppDetail,
@ -307,18 +301,7 @@ export function MainNav({
? <AgentDetailSection expand={detailNavigationVisibleExpanded} />
: showDeploymentDetailNavigation
? <DeploymentDetailSection expand={detailNavigationVisibleExpanded} />
: detailNavigationVisibleExpanded
? snippetNavigation.snippet && snippetNavigation.onFieldsChange
? (
<SnippetSidebarContent
snippet={snippetNavigation.snippet}
fields={snippetInputFields}
readonly={snippetNavigation.readonly}
onFieldsChange={snippetNavigation.onFieldsChange}
/>
)
: null
: <SnippetCollapsedPreview inputFieldCount={snippetInputFields.length} />
: <SnippetDetailSection expand={detailNavigationVisibleExpanded} />
: (
<>
<nav className="isolate flex flex-col gap-px p-2">

View File

@ -1,5 +1,5 @@
import type { PluginsSearchParams } from './types'
import type { MarketPlaceInputs } from '@/contract/router'
import type { MarketPlaceInputs } from '@/contract/marketplace'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { marketplaceQuery } from '@/service/client'
import { getMarketplaceCollectionsAndPlugins, getMarketplacePlugins } from './utils'

View File

@ -0,0 +1,13 @@
import { agent } from '@dify/contracts/api/console/agent/orpc.gen'
import { agentDriveContracts } from './agent-drive'
export const agentRouterContract = {
...agent,
byAgentId: {
...agent.byAgentId,
drive: {
...agent.byAgentId.drive,
...agentDriveContracts.byAgentId.drive,
},
},
}

View File

@ -1,8 +1,10 @@
import type { AppListResponse, WorkflowOnlineUsersResponse } from '@/models/app'
import type { CommonResponse } from '@/models/common'
import type { AppModeEnum } from '@/types/app'
import { apps } from '@dify/contracts/api/console/apps/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
import { agentDriveContracts } from './agent-drive'
export type AppListSortBy = 'last_modified' | 'recently_created' | 'earliest_created'
type AppListMode = AppModeEnum | 'agent' | 'channel' | 'all'
@ -85,3 +87,24 @@ export const workflowOnlineUsersContract = base
}
}>())
.output(type<WorkflowOnlineUsersResponse>())
export const appsRouterContract = {
...apps,
list: appListContract,
deleteApp: appDeleteContract,
starredList: appStarredListContract,
star: appStarContract,
unstar: appUnstarContract,
workflowOnlineUsers: workflowOnlineUsersContract,
byAppId: {
...apps.byAppId,
agent: {
...apps.byAppId.agent,
...agentDriveContracts.byAppId.agent,
drive: {
...apps.byAppId.agent.drive,
...agentDriveContracts.byAppId.agent.drive,
},
},
},
}

View File

@ -1,3 +1,4 @@
import { billing } from '@dify/contracts/api/console/billing/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
@ -23,3 +24,9 @@ export const bindPartnerStackContract = base
}
}>())
.output(type<unknown>())
export const billingRouterContract = {
...billing,
invoices: invoicesContract,
bindPartnerStack: bindPartnerStackContract,
}

View File

@ -5,6 +5,7 @@ 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'
@ -142,3 +143,18 @@ export const exploreBannersContract = base
})
.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,
}

View File

@ -1,4 +1,5 @@
import type { PostFilesUploadResponse } from '@dify/contracts/api/console/files/types.gen'
import { files } from '@dify/contracts/api/console/files/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
@ -14,3 +15,11 @@ export const fileUploadContract = base
}
}>())
.output(type<PostFilesUploadResponse>())
export const filesRouterContract = {
...files,
upload: {
...files.upload,
post: fileUploadContract,
},
}

View File

@ -31,3 +31,8 @@ export const changePreferredProviderTypeContract = base
}
}>())
.output(type<CommonResponse>())
export const modelProvidersRouterContract = {
models: modelProvidersModelsContract,
changePreferredProviderType: changePreferredProviderTypeContract,
}

View File

@ -25,3 +25,8 @@ export const pluginLatestVersionsContract = base
}
}>())
.output(type<InstalledLatestVersionResponse>())
export const pluginsRouterContract = {
checkInstalled: pluginCheckInstalledContract,
latestVersions: pluginLatestVersionsContract,
}

View File

@ -342,3 +342,31 @@ export const stopSnippetWorkflowTaskContract = base
}
}>())
.output(type<unknown>())
export const snippetsRouterContract = {
list: listCustomizedSnippetsContract,
create: createCustomizedSnippetContract,
detail: getCustomizedSnippetContract,
update: updateCustomizedSnippetContract,
delete: deleteCustomizedSnippetContract,
export: exportCustomizedSnippetContract,
import: importCustomizedSnippetContract,
confirmImport: confirmSnippetImportContract,
checkDependencies: checkSnippetDependenciesContract,
incrementUseCount: incrementSnippetUseCountContract,
draftWorkflow: getSnippetDraftWorkflowContract,
syncDraftWorkflow: syncSnippetDraftWorkflowContract,
draftConfig: getSnippetDraftConfigContract,
publishedWorkflow: getSnippetPublishedWorkflowContract,
publishWorkflow: publishSnippetWorkflowContract,
defaultBlockConfigs: getSnippetDefaultBlockConfigsContract,
workflowRuns: listSnippetWorkflowRunsContract,
workflowRunDetail: getSnippetWorkflowRunDetailContract,
workflowRunNodeExecutions: listSnippetWorkflowRunNodeExecutionsContract,
runDraftNode: runSnippetDraftNodeContract,
lastDraftNodeRun: getSnippetDraftNodeLastRunContract,
runDraftIterationNode: runSnippetDraftIterationNodeContract,
runDraftLoopNode: runSnippetDraftLoopNodeContract,
runDraftWorkflow: runSnippetDraftWorkflowContract,
stopWorkflowTask: stopSnippetWorkflowTaskContract,
}

View File

@ -1,3 +1,4 @@
import { tags } from '@dify/contracts/api/console/tags/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
@ -89,3 +90,13 @@ export const tagBindingRemoveContract = base
}
}>())
.output(type<unknown>())
export const tagsRouterContract = {
...tags,
list: tagListContract,
create: tagCreateContract,
update: tagUpdateContract,
delete: tagDeleteContract,
bind: tagBindingCreateContract,
unbind: tagBindingRemoveContract,
}

View File

@ -117,3 +117,21 @@ export const triggerOAuthInitiateContract = base
.route({ path: '/workspaces/current/trigger-provider/{provider}/subscriptions/oauth/authorize', method: 'GET' })
.input(type<{ params: { provider: string } }>())
.output(type<{ authorization_url: string, subscription_builder: TriggerSubscriptionBuilder }>())
export const triggersRouterContract = {
list: triggersContract,
providerInfo: triggerProviderInfoContract,
subscriptions: triggerSubscriptionsContract,
subscriptionBuilderCreate: triggerSubscriptionBuilderCreateContract,
subscriptionBuilderUpdate: triggerSubscriptionBuilderUpdateContract,
subscriptionBuilderVerifyUpdate: triggerSubscriptionBuilderVerifyUpdateContract,
subscriptionVerify: triggerSubscriptionVerifyContract,
subscriptionBuild: triggerSubscriptionBuildContract,
subscriptionDelete: triggerSubscriptionDeleteContract,
subscriptionUpdate: triggerSubscriptionUpdateContract,
subscriptionBuilderLogs: triggerSubscriptionBuilderLogsContract,
oauthConfig: triggerOAuthConfigContract,
oauthConfigure: triggerOAuthConfigureContract,
oauthDelete: triggerOAuthDeleteContract,
oauthInitiate: triggerOAuthInitiateContract,
}

View File

@ -1,6 +1,7 @@
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { DataSetListResponse } from '@/models/datasets'
import type { TryAppFlowPreview, TryAppInfo } from '@/models/try-app'
import { trialApps } from '@dify/contracts/api/console/trial-apps/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
@ -54,3 +55,11 @@ export const trialAppParametersContract = base
}
}>())
.output(type<ChatConfig>())
export const trialAppsRouterContract = {
...trialApps,
info: trialAppInfoContract,
datasets: trialAppDatasetsContract,
parameters: trialAppParametersContract,
workflows: trialAppWorkflowsContract,
}

View File

@ -78,3 +78,10 @@ export const workflowDraftUpdateFeaturesContract = base
}
}>())
.output(type<CommonResponse>())
export const workflowDraftRouterContract = {
environmentVariables: workflowDraftEnvironmentVariablesContract,
updateEnvironmentVariables: workflowDraftUpdateEnvironmentVariablesContract,
updateConversationVariables: workflowDraftUpdateConversationVariablesContract,
updateFeatures: workflowDraftUpdateFeaturesContract,
}

View File

@ -1,3 +1,4 @@
import { workspaces } from '@dify/contracts/api/console/workspaces/orpc.gen'
import { type } from '@orpc/contract'
import { base } from '../base'
@ -54,3 +55,11 @@ export const workspaceSwitchContract = base
body: SwitchWorkspaceRequest
}>())
.output(type<SwitchWorkspaceResponse>())
export const workspacesRouterContract = {
...workspaces,
get: workspacesGetContract,
switch: {
post: workspaceSwitchContract,
},
}

View File

@ -1,10 +1,11 @@
import type { InferContractRouterInputs } from '@orpc/contract'
import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types'
import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
import type { MarketplaceTemplate } from '@/types/marketplace-template'
import { type } from '@orpc/contract'
import { base } from './base'
export const collectionsContract = base
const collectionsContract = base
.route({
path: '/collections',
method: 'GET',
@ -22,7 +23,7 @@ export const collectionsContract = base
}>(),
)
export const collectionPluginsContract = base
const collectionPluginsContract = base
.route({
path: '/collections/{collectionId}/plugins',
method: 'POST',
@ -43,7 +44,7 @@ export const collectionPluginsContract = base
}>(),
)
export const searchAdvancedContract = base
const searchAdvancedContract = base
.route({
path: '/{kind}/search/advanced',
method: 'POST',
@ -56,7 +57,7 @@ export const searchAdvancedContract = base
}>())
.output(type<{ data: PluginsFromMarketplaceResponse }>())
export const templateDetailContract = base
const templateDetailContract = base
.route({
path: '/templates/{templateId}',
method: 'GET',
@ -68,7 +69,7 @@ export const templateDetailContract = base
}>())
.output(type<{ data: MarketplaceTemplate }>())
export const downloadPluginContract = base
const downloadPluginContract = base
.route({
path: '/plugins/{organization}/{pluginName}/{version}/download',
method: 'GET',
@ -81,3 +82,13 @@ export const downloadPluginContract = base
}
}>())
.output(type<Blob>())
export const marketplaceRouterContract = {
collections: collectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
templateDetail: templateDetailContract,
downloadPlugin: downloadPluginContract,
}
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>

View File

@ -1,249 +1,120 @@
import type { InferContractRouterInputs } from '@orpc/contract'
import { contract as communityContract } from '@dify/contracts/api/console/orpc.gen'
import { account } from '@dify/contracts/api/console/account/orpc.gen'
import { activate } from '@dify/contracts/api/console/activate/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 { auth } from '@dify/contracts/api/console/auth/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 { 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 { 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 { 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 { test } from '@dify/contracts/api/console/test/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 { contract as enterpriseContract } from '@dify/contracts/enterprise/orpc.gen'
import { rbacAccessConfigContract } from './console/access-control'
import { agentDriveContracts } from './console/agent-drive'
import {
appDeleteContract,
appListContract,
appStarContract,
appStarredListContract,
appUnstarContract,
workflowOnlineUsersContract,
} from './console/apps'
import { bindPartnerStackContract, invoicesContract } from './console/billing'
import {
exploreAppDetailContract,
exploreAppsContract,
exploreBannersContract,
exploreInstalledAppAccessModeContract,
exploreInstalledAppAccessModeUpdateContract,
exploreInstalledAppMetaContract,
exploreInstalledAppParametersContract,
exploreInstalledAppPinContract,
exploreInstalledAppsContract,
exploreInstalledAppUninstallContract,
learnDifyAppsContract,
} from './console/explore'
import { fileUploadContract } from './console/files'
import { changePreferredProviderTypeContract, modelProvidersModelsContract } from './console/model-providers'
import { agentRouterContract } from './console/agent'
import { appsRouterContract } from './console/apps'
import { billingRouterContract } from './console/billing'
import { exploreRouterContract } from './console/explore'
import { filesRouterContract } from './console/files'
import { modelProvidersRouterContract } from './console/model-providers'
import { notificationContract, notificationDismissContract } from './console/notification'
import { pluginCheckInstalledContract, pluginLatestVersionsContract } from './console/plugins'
import {
checkSnippetDependenciesContract,
confirmSnippetImportContract,
createCustomizedSnippetContract,
deleteCustomizedSnippetContract,
exportCustomizedSnippetContract,
getCustomizedSnippetContract,
getSnippetDefaultBlockConfigsContract,
getSnippetDraftConfigContract,
getSnippetDraftNodeLastRunContract,
getSnippetDraftWorkflowContract,
getSnippetPublishedWorkflowContract,
getSnippetWorkflowRunDetailContract,
importCustomizedSnippetContract,
incrementSnippetUseCountContract,
listCustomizedSnippetsContract,
listSnippetWorkflowRunNodeExecutionsContract,
listSnippetWorkflowRunsContract,
publishSnippetWorkflowContract,
runSnippetDraftIterationNodeContract,
runSnippetDraftLoopNodeContract,
runSnippetDraftNodeContract,
runSnippetDraftWorkflowContract,
stopSnippetWorkflowTaskContract,
syncSnippetDraftWorkflowContract,
updateCustomizedSnippetContract,
} from './console/snippets'
import {
tagBindingCreateContract,
tagBindingRemoveContract,
tagCreateContract,
tagDeleteContract,
tagListContract,
tagUpdateContract,
} from './console/tags'
import {
triggerOAuthConfigContract,
triggerOAuthConfigureContract,
triggerOAuthDeleteContract,
triggerOAuthInitiateContract,
triggerProviderInfoContract,
triggersContract,
triggerSubscriptionBuildContract,
triggerSubscriptionBuilderCreateContract,
triggerSubscriptionBuilderLogsContract,
triggerSubscriptionBuilderUpdateContract,
triggerSubscriptionBuilderVerifyUpdateContract,
triggerSubscriptionDeleteContract,
triggerSubscriptionsContract,
triggerSubscriptionUpdateContract,
triggerSubscriptionVerifyContract,
} from './console/trigger'
import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app'
import {
workflowDraftEnvironmentVariablesContract,
workflowDraftUpdateConversationVariablesContract,
workflowDraftUpdateEnvironmentVariablesContract,
workflowDraftUpdateFeaturesContract,
} from './console/workflow'
import { pluginsRouterContract } from './console/plugins'
import { snippetsRouterContract } from './console/snippets'
import { tagsRouterContract } from './console/tags'
import { triggersRouterContract } from './console/trigger'
import { trialAppsRouterContract } from './console/try-app'
import { workflowDraftRouterContract } from './console/workflow'
import { workflowCommentContracts } from './console/workflow-comment'
import { workspacesGetContract, workspaceSwitchContract } from './console/workspaces'
import { collectionPluginsContract, collectionsContract, downloadPluginContract, searchAdvancedContract, templateDetailContract } from './marketplace'
import { workspacesRouterContract } from './console/workspaces'
export const marketplaceRouterContract = {
collections: collectionsContract,
collectionPlugins: collectionPluginsContract,
searchAdvanced: searchAdvancedContract,
templateDetail: templateDetailContract,
downloadPlugin: downloadPluginContract,
const communityContract = {
account,
activate,
allWorkspaces,
apiBasedExtension,
apiKeyAuth,
app,
appDslVersion,
auth,
codeBasedExtension,
compliance,
dataSource,
datasets,
emailCodeLogin,
emailRegister,
features,
forgotPassword,
form,
info,
installedApps,
instructionGenerate,
login,
logout,
notion,
oauth,
rag,
refreshToken,
remoteFiles,
resetPassword,
ruleCodeGenerate,
ruleGenerate,
ruleStructuredOutputGenerate,
spec,
systemFeatures,
tagBindings,
test,
trialModels,
website,
workflow,
workflowGenerate,
}
export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRouterContract>
export const consoleRouterContract = {
enterprise: enterpriseContract,
...communityContract,
apps: {
...communityContract.apps,
list: appListContract,
deleteApp: appDeleteContract,
starredList: appStarredListContract,
star: appStarContract,
unstar: appUnstarContract,
workflowOnlineUsers: workflowOnlineUsersContract,
byAppId: {
...communityContract.apps.byAppId,
agent: {
...communityContract.apps.byAppId.agent,
...agentDriveContracts.byAppId.agent,
drive: {
...communityContract.apps.byAppId.agent.drive,
...agentDriveContracts.byAppId.agent.drive,
},
},
},
},
agent: {
...communityContract.agent,
byAgentId: {
...communityContract.agent.byAgentId,
drive: {
...communityContract.agent.byAgentId.drive,
...agentDriveContracts.byAgentId.drive,
},
},
},
explore: {
...communityContract.explore,
apps: exploreAppsContract,
learnDifyApps: learnDifyAppsContract,
appDetail: exploreAppDetailContract,
installedApps: exploreInstalledAppsContract,
uninstallInstalledApp: exploreInstalledAppUninstallContract,
updateInstalledApp: exploreInstalledAppPinContract,
appAccessMode: exploreInstalledAppAccessModeContract,
updateAppAccessMode: exploreInstalledAppAccessModeUpdateContract,
installedAppParameters: exploreInstalledAppParametersContract,
installedAppMeta: exploreInstalledAppMetaContract,
banners: exploreBannersContract,
},
trialApps: {
...communityContract.trialApps,
info: trialAppInfoContract,
datasets: trialAppDatasetsContract,
parameters: trialAppParametersContract,
workflows: trialAppWorkflowsContract,
},
files: {
...communityContract.files,
upload: {
...communityContract.files.upload,
post: fileUploadContract,
},
},
modelProviders: {
models: modelProvidersModelsContract,
changePreferredProviderType: changePreferredProviderTypeContract,
},
plugins: {
checkInstalled: pluginCheckInstalledContract,
latestVersions: pluginLatestVersionsContract,
},
rbacAccessConfig: rbacAccessConfigContract,
snippets: {
list: listCustomizedSnippetsContract,
create: createCustomizedSnippetContract,
detail: getCustomizedSnippetContract,
update: updateCustomizedSnippetContract,
delete: deleteCustomizedSnippetContract,
export: exportCustomizedSnippetContract,
import: importCustomizedSnippetContract,
confirmImport: confirmSnippetImportContract,
checkDependencies: checkSnippetDependenciesContract,
incrementUseCount: incrementSnippetUseCountContract,
draftWorkflow: getSnippetDraftWorkflowContract,
syncDraftWorkflow: syncSnippetDraftWorkflowContract,
draftConfig: getSnippetDraftConfigContract,
publishedWorkflow: getSnippetPublishedWorkflowContract,
publishWorkflow: publishSnippetWorkflowContract,
defaultBlockConfigs: getSnippetDefaultBlockConfigsContract,
workflowRuns: listSnippetWorkflowRunsContract,
workflowRunDetail: getSnippetWorkflowRunDetailContract,
workflowRunNodeExecutions: listSnippetWorkflowRunNodeExecutionsContract,
runDraftNode: runSnippetDraftNodeContract,
lastDraftNodeRun: getSnippetDraftNodeLastRunContract,
runDraftIterationNode: runSnippetDraftIterationNodeContract,
runDraftLoopNode: runSnippetDraftLoopNodeContract,
runDraftWorkflow: runSnippetDraftWorkflowContract,
stopWorkflowTask: stopSnippetWorkflowTaskContract,
},
billing: {
...communityContract.billing,
invoices: invoicesContract,
bindPartnerStack: bindPartnerStackContract,
},
workflowDraft: {
environmentVariables: workflowDraftEnvironmentVariablesContract,
updateEnvironmentVariables: workflowDraftUpdateEnvironmentVariablesContract,
updateConversationVariables: workflowDraftUpdateConversationVariablesContract,
updateFeatures: workflowDraftUpdateFeaturesContract,
},
workflowComments: workflowCommentContracts,
agent: agentRouterContract,
apps: appsRouterContract,
billing: billingRouterContract,
explore: exploreRouterContract,
files: filesRouterContract,
modelProviders: modelProvidersRouterContract,
notification: notificationContract,
notificationDismiss: notificationDismissContract,
tags: {
...communityContract.tags,
list: tagListContract,
create: tagCreateContract,
update: tagUpdateContract,
delete: tagDeleteContract,
bind: tagBindingCreateContract,
unbind: tagBindingRemoveContract,
},
triggers: {
list: triggersContract,
providerInfo: triggerProviderInfoContract,
subscriptions: triggerSubscriptionsContract,
subscriptionBuilderCreate: triggerSubscriptionBuilderCreateContract,
subscriptionBuilderUpdate: triggerSubscriptionBuilderUpdateContract,
subscriptionBuilderVerifyUpdate: triggerSubscriptionBuilderVerifyUpdateContract,
subscriptionVerify: triggerSubscriptionVerifyContract,
subscriptionBuild: triggerSubscriptionBuildContract,
subscriptionDelete: triggerSubscriptionDeleteContract,
subscriptionUpdate: triggerSubscriptionUpdateContract,
subscriptionBuilderLogs: triggerSubscriptionBuilderLogsContract,
oauthConfig: triggerOAuthConfigContract,
oauthConfigure: triggerOAuthConfigureContract,
oauthDelete: triggerOAuthDeleteContract,
oauthInitiate: triggerOAuthInitiateContract,
},
workspaces: {
...communityContract.workspaces,
get: workspacesGetContract,
switch: {
post: workspaceSwitchContract,
},
},
plugins: pluginsRouterContract,
rbacAccessConfig: rbacAccessConfigContract,
snippets: snippetsRouterContract,
tags: tagsRouterContract,
triggers: triggersRouterContract,
trialApps: trialAppsRouterContract,
workflowComments: workflowCommentContracts,
workflowDraft: workflowDraftRouterContract,
workspaces: workspacesRouterContract,
}

View File

@ -2,20 +2,21 @@
import type { ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { ConsoleRouterContract } from '@/service/console-link'
import { createORPCClient } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
import { API_PREFIX } from '@/config'
import { consoleRouterContract } from '@/contract/router'
// eslint-disable-next-line no-restricted-imports
import { request } from '@/service/base'
import { getBaseURL } from '@/service/client'
import { createConsoleDynamicLink } from '@/service/console-link'
type AgentConfigureConsoleClientContext = {
silent?: boolean
}
const agentConfigureConsoleLink = new OpenAPILink<AgentConfigureConsoleClientContext>(consoleRouterContract, {
const agentConfigureConsoleLink = createConsoleDynamicLink<AgentConfigureConsoleClientContext>(contract => new OpenAPILink<AgentConfigureConsoleClientContext>(contract, {
url: getBaseURL(API_PREFIX),
fetch: (input, init, options) => {
return request(
@ -28,9 +29,9 @@ const agentConfigureConsoleLink = new OpenAPILink<AgentConfigureConsoleClientCon
},
)
},
})
}))
const agentConfigureConsoleClient: JsonifiedClient<ContractRouterClient<typeof consoleRouterContract, AgentConfigureConsoleClientContext>>
const agentConfigureConsoleClient: JsonifiedClient<ContractRouterClient<ConsoleRouterContract, AgentConfigureConsoleClientContext>>
= createORPCClient(agentConfigureConsoleLink)
export const agentConfigureConsoleQuery = createTanstackQueryUtils(agentConfigureConsoleClient, {

View File

@ -26,6 +26,7 @@ type TabDef = {
key: InstanceDetailTabKey
icon: NavIcon
selectedIcon: NavIcon
hidden?: boolean
}
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
@ -68,7 +69,7 @@ const DEPLOYMENT_TABS: TabDef[] = [
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
{ key: 'instances', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
{ key: 'releases', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon },
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon, hidden: true },
{ key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
]
@ -296,7 +297,7 @@ export function DeploymentDetailSection({
</div>
<nav className={cn('flex flex-col gap-y-0.5 py-1', expand ? 'px-1' : 'px-3')}>
{DEPLOYMENT_TABS.map(tab => (
{DEPLOYMENT_TABS.filter(tab => !tab.hidden).map(tab => (
<NavLink
key={tab.key}
mode={expand ? 'expand' : 'collapse'}

View File

@ -2,10 +2,10 @@
import type { Resource } from 'i18next'
import type { Locale } from '.'
import type { Namespace, NamespaceInFileName } from './resources'
import { kebabCase } from 'es-toolkit/string'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { getI18n, initReactI18next } from 'react-i18next'
import { loadI18nResource } from './load-resource'
import { getInitOptions } from './settings'
export function createI18nextInstance(lng: Locale, resources: Resource) {
@ -15,10 +15,7 @@ export function createI18nextInstance(lng: Locale, resources: Resource) {
.use(resourcesToBackend((
language: Locale,
namespace: NamespaceInFileName | Namespace,
) => {
const namespaceKebab = kebabCase(namespace)
return import(`../i18n/${language}/${namespaceKebab}.json`)
}))
) => loadI18nResource(language, namespace)))
.init({
...getInitOptions(),
lng,

View File

@ -0,0 +1,35 @@
import type { ResourceKey } from 'i18next'
import type { Locale } from './language'
import type { Namespace, NamespaceInFileName } from './resources'
import { kebabCase } from 'es-toolkit/string'
import { LanguagesSupported } from './language'
type LocaleResourceModule = {
loadResource: (fileNamespace: string) => Promise<{ default: ResourceKey }>
}
const legacyLocaleMap: Partial<Record<Locale, Locale>> = {
en_US: 'en-US',
ja_JP: 'ja-JP',
zh_Hans: 'zh-Hans',
}
const defaultLocale = 'en-US' satisfies Locale
const normalizeLocale = (locale: Locale): Locale => {
const normalized = legacyLocaleMap[locale] ?? locale
if (LanguagesSupported.includes(normalized))
return normalized
return defaultLocale
}
const loadLocaleResources = (locale: Locale): Promise<LocaleResourceModule> => {
const normalized = normalizeLocale(locale)
return import(`./locale-resources/${normalized}.ts`)
}
export const loadI18nResource = async (locale: Locale, namespace: Namespace | NamespaceInFileName) => {
const { loadResource } = await loadLocaleResources(locale)
return loadResource(kebabCase(namespace))
}

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/ar-TN/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/de-DE/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/en-US/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/es-ES/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/fa-IR/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/fr-FR/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/hi-IN/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/id-ID/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/it-IT/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/ja-JP/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/ko-KR/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/nl-NL/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/pl-PL/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/pt-BR/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/ro-RO/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/ru-RU/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/sl-SI/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/th-TH/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/tr-TR/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/uk-UA/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/vi-VN/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/zh-Hans/${fileNamespace}.json`)

View File

@ -0,0 +1 @@
export const loadResource = (fileNamespace: string) => import(`../../i18n/zh-Hant/${fileNamespace}.json`)

View File

@ -2,7 +2,6 @@ import type { i18n as I18nInstance, Resource, ResourceLanguage } from 'i18next'
import type { Locale } from '.'
import type { Namespace, NamespaceInFileName } from './resources'
import { match } from '@formatjs/intl-localematcher'
import { kebabCase } from 'es-toolkit/compat'
import { camelCase } from 'es-toolkit/string'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
@ -12,6 +11,7 @@ import { initReactI18next } from 'react-i18next/initReactI18next'
import { cookies, headers } from '@/next/headers'
import { serverOnlyContext } from '@/utils/server-only-context'
import { i18n } from '.'
import { loadI18nResource } from './load-resource'
import { namespacesInFileName } from './resources'
import { getInitOptions } from './settings'
@ -26,10 +26,7 @@ const getOrCreateI18next = async (lng: Locale) => {
instance = createInstance()
await instance
.use(initReactI18next)
.use(resourcesToBackend((language: Locale, namespace: Namespace | NamespaceInFileName) => {
const fileNamespace = kebabCase(namespace)
return import(`../i18n/${language}/${fileNamespace}.json`)
}))
.use(resourcesToBackend((language: Locale, namespace: Namespace | NamespaceInFileName) => loadI18nResource(language, namespace)))
.init({
...getInitOptions(),
lng,
@ -85,7 +82,7 @@ export const getResources = cache(async (lng: Locale): Promise<Resource> => {
await Promise.all(
(namespacesInFileName).map(async (ns) => {
const mod = await import(`../i18n/${lng}/${ns}.json`)
const mod = await loadI18nResource(lng, ns)
messages[camelCase(ns)] = mod.default
}),
)

View File

@ -35,9 +35,9 @@ const config: KnipConfig = {
ignoreFiles: [
'features/agent-v2/agent-detail/configure/components/orchestrate/memory.tsx',
'features/agent-v2/agent-detail/configure/components/orchestrate/prompt-editor/option-menu.tsx',
'i18n-config/locale-resources/*.ts',
],
ignoreBinaries: [
'only-allow',
'pbcopy',
'which',
],

View File

@ -5,11 +5,13 @@ import type {
ListReleasesResponse,
PrecheckReleaseRequest,
} from '@dify/contracts/enterprise/types.gen'
import type { ContractRouterClient } from '@orpc/contract'
import type { ClientLink } from '@orpc/client'
import type { AnyContractRouter, ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { RouterUtils } from '@orpc/tanstack-query'
import type { InfiniteData, QueryClient, QueryKey } from '@tanstack/react-query'
import type { Tag } from '@/contract/console/tags'
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'
@ -19,13 +21,11 @@ import {
IS_MARKETPLACE,
MARKETPLACE_API_PREFIX,
} from '@/config'
import {
consoleRouterContract,
marketplaceRouterContract,
} from '@/contract/router'
import { marketplaceRouterContract } from '@/contract/marketplace'
import { isClient } from '@/utils/client'
// eslint-disable-next-line no-restricted-imports
import { request } from './base'
import { createConsoleDynamicLink } from './console-link'
function getMarketplaceHeaders() {
return new Headers({
@ -58,6 +58,30 @@ export function getBaseURL(path: string) {
return url
}
type ConsoleClientContext = Record<never, never>
type ConsoleClientLink = ClientLink<ConsoleClientContext>
function createConsoleOpenAPILink(contract: AnyContractRouter): ConsoleClientLink {
return new OpenAPILink<ConsoleClientContext>(contract, {
url: getBaseURL(API_PREFIX),
fetch: (input, init) => {
return request(
input.url,
init,
{
fetchCompat: true,
request: input,
},
)
},
interceptors: [
onError((error) => {
console.error(error)
}),
],
})
}
const marketplaceLink = new OpenAPILink(marketplaceRouterContract, {
url: MARKETPLACE_API_PREFIX,
headers: () => (getMarketplaceHeaders()),
@ -323,24 +347,7 @@ async function invalidateReleaseMutationQueries(
])
}
const consoleLink = new OpenAPILink(consoleRouterContract, {
url: getBaseURL(API_PREFIX),
fetch: (input, init) => {
return request(
input.url,
init,
{
fetchCompat: true,
request: input,
},
)
},
interceptors: [
onError((error) => {
console.error(error)
}),
],
})
const consoleLink = createConsoleDynamicLink<ConsoleClientContext>(createConsoleOpenAPILink)
export const consoleClient: JsonifiedClient<ContractRouterClient<typeof consoleRouterContract>> = createORPCClient(consoleLink)

View File

@ -0,0 +1,32 @@
import type { ClientContext, ClientLink } from '@orpc/client'
import type { AnyContractRouter } from '@orpc/contract'
import type { consoleRouterContract } from '@/contract/router'
import { DynamicLink } from '@orpc/client'
import { loadConsoleContractForSegment } from './console-router-loader'
export type ConsoleRouterContract = typeof consoleRouterContract
export function createConsoleDynamicLink<TContext extends ClientContext>(
createLink: (contract: AnyContractRouter) => ClientLink<TContext>,
) {
const routerLinkPromises = new Map<string, Promise<ClientLink<TContext>>>()
function getRouterLink(path: readonly string[]) {
const segment = path[0]
if (!segment)
throw new Error('Console contract path is empty.')
let routerLinkPromise = routerLinkPromises.get(segment)
if (!routerLinkPromise) {
routerLinkPromise = loadConsoleContractForSegment(segment).then(createLink).catch((error) => {
routerLinkPromises.delete(segment)
throw error
})
routerLinkPromises.set(segment, routerLinkPromise)
}
return routerLinkPromise
}
return new DynamicLink<TContext>((_options, path) => getRouterLink(path))
}

View File

@ -0,0 +1,51 @@
import type { AnyContractRouter } from '@orpc/contract'
import { contractLoaders } from '@dify/contracts/api/console/orpc.gen'
const wrapConsoleContract = (segment: string, contract: unknown) => ({ [segment]: contract }) as AnyContractRouter
async function loadGeneratedConsoleContract(segment: string) {
const loader = contractLoaders[segment as keyof typeof contractLoaders]
if (!loader)
return null
return loader() as Promise<AnyContractRouter>
}
const customConsoleContractLoaders: Record<string, () => Promise<AnyContractRouter>> = {
agent: () => import('@/contract/console/agent').then(({ agentRouterContract }) => wrapConsoleContract('agent', agentRouterContract)),
apps: () => import('@/contract/console/apps').then(({ appsRouterContract }) => wrapConsoleContract('apps', appsRouterContract)),
billing: () => import('@/contract/console/billing').then(({ billingRouterContract }) => wrapConsoleContract('billing', billingRouterContract)),
enterprise: () => import('@dify/contracts/enterprise/orpc.gen').then(({ contract }) => wrapConsoleContract('enterprise', contract)),
explore: () => import('@/contract/console/explore').then(({ exploreRouterContract }) => wrapConsoleContract('explore', exploreRouterContract)),
files: () => import('@/contract/console/files').then(({ filesRouterContract }) => wrapConsoleContract('files', filesRouterContract)),
modelProviders: () =>
import('@/contract/console/model-providers').then(({ modelProvidersRouterContract }) => wrapConsoleContract('modelProviders', modelProvidersRouterContract)),
notification: () =>
import('@/contract/console/notification').then(({ notificationContract }) => wrapConsoleContract('notification', notificationContract)),
notificationDismiss: () =>
import('@/contract/console/notification').then(({ notificationDismissContract }) => wrapConsoleContract('notificationDismiss', notificationDismissContract)),
plugins: () => import('@/contract/console/plugins').then(({ pluginsRouterContract }) => wrapConsoleContract('plugins', pluginsRouterContract)),
rbacAccessConfig: () =>
import('@/contract/console/access-control').then(({ rbacAccessConfigContract }) => wrapConsoleContract('rbacAccessConfig', rbacAccessConfigContract)),
snippets: () => import('@/contract/console/snippets').then(({ snippetsRouterContract }) => wrapConsoleContract('snippets', snippetsRouterContract)),
tags: () => import('@/contract/console/tags').then(({ tagsRouterContract }) => wrapConsoleContract('tags', tagsRouterContract)),
triggers: () => import('@/contract/console/trigger').then(({ triggersRouterContract }) => wrapConsoleContract('triggers', triggersRouterContract)),
trialApps: () => import('@/contract/console/try-app').then(({ trialAppsRouterContract }) => wrapConsoleContract('trialApps', trialAppsRouterContract)),
workflowComments: () =>
import('@/contract/console/workflow-comment').then(({ workflowCommentContracts }) => wrapConsoleContract('workflowComments', workflowCommentContracts)),
workflowDraft: () =>
import('@/contract/console/workflow').then(({ workflowDraftRouterContract }) => wrapConsoleContract('workflowDraft', workflowDraftRouterContract)),
workspaces: () => import('@/contract/console/workspaces').then(({ workspacesRouterContract }) => wrapConsoleContract('workspaces', workspacesRouterContract)),
}
export async function loadConsoleContractForSegment(segment: string) {
const customContractLoader = customConsoleContractLoaders[segment]
if (customContractLoader)
return customContractLoader()
const generatedContract = await loadGeneratedConsoleContract(segment)
if (generatedContract)
return generatedContract
throw new Error(`Console contract segment "${segment}" is not configured.`)
}

View File

@ -1,5 +1,7 @@
import type { ContractRouterClient } from '@orpc/contract'
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'
@ -9,7 +11,7 @@ import {
CSRF_HEADER_NAME,
} from '@/config'
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
import { consoleRouterContract } from '@/contract/router'
import { createConsoleDynamicLink } from './console-link'
import 'server-only'
@ -68,6 +70,29 @@ const createServerConsoleRequestHeaders = (context: ServerConsoleClientContext |
return requestHeaders
}
type ServerConsoleClientLink = ClientLink<ServerConsoleClientContext>
function createServerConsoleOpenAPILink(contract: AnyContractRouter): ServerConsoleClientLink {
return new OpenAPILink<ServerConsoleClientContext>(contract, {
url: getServerConsoleApiPrefix,
headers: ({ context }) => createServerConsoleRequestHeaders(context),
fetch: (request, init) => {
if (request.body && !request.headers.has('content-type'))
request.headers.set('Content-Type', 'application/json')
return globalThis.fetch(request, {
...init,
cache: 'no-store',
})
},
interceptors: [
onError((error) => {
console.error(error)
}),
],
})
}
export const getServerConsoleClientContext = async (): Promise<ServerConsoleClientContext> => {
const { cookies, headers } = await import('@/next/headers')
const requestHeaders = await headers()
@ -82,24 +107,7 @@ export const getServerConsoleClientContext = async (): Promise<ServerConsoleClie
export const getServerConsoleRequestHeaders = async () =>
createServerConsoleRequestHeaders(await getServerConsoleClientContext())
const serverConsoleLink = new OpenAPILink<ServerConsoleClientContext>(consoleRouterContract, {
url: getServerConsoleApiPrefix,
headers: ({ context }) => createServerConsoleRequestHeaders(context),
fetch: (request, init) => {
if (request.body && !request.headers.has('content-type'))
request.headers.set('Content-Type', 'application/json')
return globalThis.fetch(request, {
...init,
cache: 'no-store',
})
},
interceptors: [
onError((error) => {
console.error(error)
}),
],
})
const serverConsoleLink = createConsoleDynamicLink<ServerConsoleClientContext>(createServerConsoleOpenAPILink)
export const serverConsoleClient: JsonifiedClient<ContractRouterClient<typeof consoleRouterContract, ServerConsoleClientContext>> = createORPCClient(serverConsoleLink)