mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-12 21:07:13 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e913658e03 | |||
| 1277c6da07 | |||
| fa15f48fd7 | |||
| 18c1ed93b8 | |||
| 8b85876a19 | |||
| 18b84566c3 | |||
| 50d544aa6f | |||
| 33b4035140 | |||
| a3509b3626 | |||
| e631da0073 |
+1
-1
@@ -232,4 +232,4 @@ line-ending = "auto"
|
||||
|
||||
[tool.uv.sources]
|
||||
# Development contract: update to the matching SDK release before publishing.
|
||||
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }
|
||||
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "c67e6c85a0cde8ae2b20cbd89e33805a68382563" }
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ metadata:
|
||||
en_US: Deterministic runner fixture that returns stable QA sentinel output.
|
||||
zh_Hans: 返回稳定 QA 哨兵输出的确定性 runner 夹具。
|
||||
spec:
|
||||
usages: [agent]
|
||||
capabilities:
|
||||
streaming: true
|
||||
tool_calling: false
|
||||
|
||||
@@ -55,7 +55,7 @@ class RunnerDescriptor(pydantic.BaseModel):
|
||||
"""Original manifest for reference"""
|
||||
|
||||
component_kind: typing.Literal['Runner'] = 'Runner'
|
||||
usages: list[typing.Literal['agent', 'event']] = pydantic.Field(default_factory=lambda: ['agent'])
|
||||
usages: list[typing.Literal['agent', 'event']] = pydantic.Field(min_length=1)
|
||||
supported_event_patterns: list[str] = pydantic.Field(default_factory=lambda: ['*'])
|
||||
|
||||
model_config = pydantic.ConfigDict(
|
||||
|
||||
@@ -220,6 +220,21 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
return self.http_status(503, -1, str(exc))
|
||||
return self.success(data=model)
|
||||
|
||||
@self.route(
|
||||
'/model-availability',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN,
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
"""Expose Space's latest persisted model probes to the WebUI."""
|
||||
try:
|
||||
models = await self.ap.space_service.get_model_selection()
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Failed to load LangBot Models availability: {exc}')
|
||||
return self.http_status(503, -1, 'Model availability is unavailable')
|
||||
return self.success(data={'models': [model.model_dump(mode='json') for model in models]})
|
||||
|
||||
@self.route(
|
||||
'/tasks',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -242,13 +242,14 @@ class SpaceService:
|
||||
models_data = data.get('data', {}).get('models', [])
|
||||
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
|
||||
|
||||
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
|
||||
async def get_model_selection(self, category: str | None = None) -> typing.List[SpaceModelSelection]:
|
||||
"""Return Space models in the availability-ranked selection order."""
|
||||
space_url = self._get_space_config()['url']
|
||||
session = httpclient.get_session()
|
||||
params = {'category': category} if category else None
|
||||
async with session.get(
|
||||
f'{space_url}/api/v1/models/selection',
|
||||
params={'category': category},
|
||||
params=params,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error = await httpclient.read_text_limited(response)
|
||||
@@ -266,7 +267,17 @@ class SpaceService:
|
||||
models = []
|
||||
for selection in data:
|
||||
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
|
||||
models.append(selection['model'])
|
||||
model = dict(selection['model'])
|
||||
availability = selection.get('availability')
|
||||
if not isinstance(availability, dict):
|
||||
# Accept the short-lived pre-release response shape.
|
||||
availability = {
|
||||
key: selection[key]
|
||||
for key in ('up', 'last_probed_at', 'latency_ms', 'http_code')
|
||||
if key in selection
|
||||
}
|
||||
model['availability'] = availability
|
||||
models.append(model)
|
||||
else:
|
||||
models.append(selection)
|
||||
return [SpaceModelSelection.model_validate(model) for model in models]
|
||||
|
||||
@@ -45,12 +45,27 @@ class SpaceModel(pydantic.BaseModel):
|
||||
is_featured: bool = False
|
||||
featured_order: int = 0
|
||||
status: str
|
||||
listed_at: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class SpaceModelAvailability(pydantic.BaseModel):
|
||||
"""Latest availability probe. ``up`` is None when no probe exists."""
|
||||
|
||||
up: bool | None = None
|
||||
last_probed_at: str | None = None
|
||||
latency_ms: int = 0
|
||||
http_code: int = 0
|
||||
|
||||
|
||||
class SpaceModelSelection(pydantic.BaseModel):
|
||||
"""Minimal model identity returned by the ranked selection endpoint."""
|
||||
"""Model identity, pricing, and latest persisted probe from Space."""
|
||||
|
||||
uuid: str
|
||||
model_id: str
|
||||
category: str | None = None
|
||||
listed_at: str | None = None
|
||||
input_credits: float | None = None
|
||||
output_credits: float | None = None
|
||||
availability: SpaceModelAvailability = pydantic.Field(default_factory=SpaceModelAvailability)
|
||||
|
||||
@@ -32,6 +32,7 @@ def make_descriptor(
|
||||
permissions: dict | None = None,
|
||||
) -> RunnerDescriptor:
|
||||
return RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id='plugin:test/runner/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
|
||||
@@ -91,6 +91,7 @@ class TestContextValidation:
|
||||
def _make_descriptor(self):
|
||||
"""Create a mock runner descriptor."""
|
||||
return RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id='plugin:test/plugin/runner',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
|
||||
@@ -125,6 +125,7 @@ class MockApplication:
|
||||
class FakeRunnerRegistry:
|
||||
async def get(self, context, runner_id, bound_plugins=None):
|
||||
return RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
|
||||
@@ -23,6 +23,7 @@ SOURCE = {'source': 'native', 'source_id': None}
|
||||
|
||||
async def _resources(tool_mgr, rag_mgr, binding):
|
||||
descriptor = RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=LOCAL_RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
|
||||
@@ -164,6 +164,7 @@ class FakeConversation:
|
||||
|
||||
def make_descriptor() -> RunnerDescriptor:
|
||||
return RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
|
||||
@@ -55,6 +55,7 @@ class FakeApplication:
|
||||
'id': 'plugin:langbot-team/LocalAgent/default',
|
||||
'name': 'default',
|
||||
'label': {'en_US': 'Local Agent'},
|
||||
'usages': ['agent'],
|
||||
'capabilities': {'streaming': True},
|
||||
'permissions': {},
|
||||
'config_schema': [],
|
||||
@@ -68,6 +69,7 @@ class FakeApplication:
|
||||
'id': 'plugin:alice/my-agent/custom',
|
||||
'name': 'custom',
|
||||
'label': {'en_US': 'Custom Agent'},
|
||||
'usages': ['agent'],
|
||||
'capabilities': {},
|
||||
'permissions': {},
|
||||
'config_schema': [{'name': 'param1', 'type': 'string'}],
|
||||
@@ -308,6 +310,7 @@ class TestDescriptorValidation:
|
||||
def test_validate_runner_descriptor(self):
|
||||
"""Validate correctly built descriptor."""
|
||||
descriptor = RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id='plugin:test/my-runner/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
@@ -323,6 +326,7 @@ class TestDescriptorValidation:
|
||||
def test_descriptor_capabilities(self):
|
||||
"""Descriptor capability helper methods."""
|
||||
descriptor = RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id='plugin:test/my-runner/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
@@ -365,3 +369,18 @@ async def test_registry_filters_usages_without_splitting_component_identity():
|
||||
assert [item.runner_name for item in processors] == ['events', 'both']
|
||||
assert agents[-1].id == processors[-1].id
|
||||
assert (await registry.get(TEST_CONTEXT, processors[0].id)).usages == ['event']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_rejects_runner_without_usage_declaration():
|
||||
ap = FakeApplication()
|
||||
original = ap.plugin_connector.list_runners
|
||||
|
||||
async def missing_usage(bound_plugins=None):
|
||||
runners = await original(bound_plugins)
|
||||
runners[0]['manifest'].pop('usages')
|
||||
return runners
|
||||
|
||||
ap.plugin_connector.list_runners = missing_usage
|
||||
runners = await RunnerRegistry(ap).list_runners(TEST_CONTEXT, use_cache=False)
|
||||
assert [runner.id for runner in runners] == ['plugin:alice/my-agent/custom']
|
||||
|
||||
@@ -39,6 +39,7 @@ def make_descriptor(
|
||||
permissions: dict | None = None,
|
||||
) -> RunnerDescriptor:
|
||||
return RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
|
||||
@@ -37,6 +37,7 @@ class FakeApplication:
|
||||
def make_descriptor():
|
||||
"""Create a test descriptor."""
|
||||
return RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id='plugin:langbot-team/LocalAgent/default',
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'},
|
||||
|
||||
@@ -25,6 +25,7 @@ from langbot.pkg.agent.runner.state_scope import (
|
||||
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> RunnerDescriptor:
|
||||
"""Create a test descriptor."""
|
||||
return RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label={'en_US': 'Test Runner'},
|
||||
|
||||
@@ -831,6 +831,7 @@ class TestSpaceServiceGetModelSelection:
|
||||
{
|
||||
'uuid': 'best-model',
|
||||
'model_id': 'best-chat-model',
|
||||
'listed_at': '2026-09-09T19:00:00.000929Z',
|
||||
'provider': 'provider-1',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
@@ -847,7 +848,15 @@ class TestSpaceServiceGetModelSelection:
|
||||
data = {'models': models}
|
||||
elif response_shape == 'availability-wrapper':
|
||||
data = [
|
||||
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
||||
{
|
||||
'model': model,
|
||||
'availability': {
|
||||
'up': True,
|
||||
'last_probed_at': '2026-09-11T12:01:18Z',
|
||||
'latency_ms': index + 10,
|
||||
'http_code': 200,
|
||||
},
|
||||
}
|
||||
for index, model in enumerate(models)
|
||||
]
|
||||
else:
|
||||
@@ -870,11 +879,62 @@ class TestSpaceServiceGetModelSelection:
|
||||
result = await service.get_model_selection('chat')
|
||||
|
||||
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||
assert result[0].model_dump()['listed_at'] == '2026-09-09T19:00:00.000929Z'
|
||||
assert result[1].listed_at is None
|
||||
if response_shape == 'availability-wrapper':
|
||||
assert result[0].availability.up is True
|
||||
assert result[0].availability.last_probed_at == '2026-09-11T12:01:18Z'
|
||||
assert result[0].availability.latency_ms == 10
|
||||
session.get.assert_called_once_with(
|
||||
'https://space.langbot.app/api/v1/models/selection',
|
||||
params={'category': 'chat'},
|
||||
)
|
||||
|
||||
async def test_selection_without_category_fetches_all_model_statuses(self):
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||
service = SpaceService(ap)
|
||||
payload = {
|
||||
'code': 0,
|
||||
'data': {
|
||||
'models': [
|
||||
{
|
||||
'model': {
|
||||
'uuid': 'embedding-model',
|
||||
'model_id': 'text-embedding',
|
||||
'category': 'embedding',
|
||||
'input_credits': 20,
|
||||
'output_credits': 40,
|
||||
},
|
||||
'availability': {'up': None, 'last_probed_at': None},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
mock_response = MagicMock(status=200)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||
new=AsyncMock(return_value=payload),
|
||||
),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
get_session.return_value = session
|
||||
|
||||
result = await service.get_model_selection()
|
||||
|
||||
assert result[0].category == 'embedding'
|
||||
assert result[0].input_credits == 20
|
||||
assert result[0].output_credits == 40
|
||||
assert result[0].availability.up is None
|
||||
session.get.assert_called_once_with(
|
||||
'https://space.langbot.app/api/v1/models/selection',
|
||||
params=None,
|
||||
)
|
||||
|
||||
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||
persistence = SimpleNamespace(
|
||||
|
||||
@@ -26,6 +26,7 @@ class FakeRegistry:
|
||||
def make_runner(runner_id: str, config_schema: list[dict]):
|
||||
parts = runner_id.removeprefix('plugin:').split('/')
|
||||
return RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=runner_id,
|
||||
source='plugin',
|
||||
label={'en_US': runner_id},
|
||||
|
||||
@@ -32,6 +32,7 @@ RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
|
||||
|
||||
def attach_runner_descriptor(app):
|
||||
descriptor = RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
|
||||
@@ -17,6 +17,7 @@ def _attach_runner_descriptor(app):
|
||||
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
|
||||
|
||||
descriptor = RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
|
||||
@@ -80,6 +80,7 @@ def _make_app(*, skill_service) -> SimpleNamespace:
|
||||
model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'}))
|
||||
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
|
||||
descriptor = RunnerDescriptor(
|
||||
usages=['agent'],
|
||||
id=_RUNNER_ID,
|
||||
source='plugin',
|
||||
label={'en_US': 'Local Agent'},
|
||||
|
||||
@@ -2119,7 +2119,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" },
|
||||
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=c67e6c85a0cde8ae2b20cbd89e33805a68382563" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2186,7 +2186,7 @@ dev = [
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.5.5"
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28#92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }
|
||||
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=c67e6c85a0cde8ae2b20cbd89e33805a68382563#c67e6c85a0cde8ae2b20cbd89e33805a68382563" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -90,18 +90,18 @@ export default function AgentCreateContent({
|
||||
}
|
||||
|
||||
const typeOptions = [
|
||||
{
|
||||
kind: 'agent' as const,
|
||||
icon: Bot,
|
||||
title: t('agents.agentType'),
|
||||
description: t('agents.agentTypeDescription'),
|
||||
},
|
||||
{
|
||||
kind: 'pipeline' as const,
|
||||
icon: Workflow,
|
||||
title: t('agents.pipelineType'),
|
||||
description: t('agents.pipelineTypeDescription'),
|
||||
},
|
||||
{
|
||||
kind: 'agent' as const,
|
||||
icon: Bot,
|
||||
title: t('agents.agentType'),
|
||||
description: t('agents.agentTypeDescription'),
|
||||
},
|
||||
{
|
||||
kind: 'event_processor' as const,
|
||||
icon: Puzzle,
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function PluginProcessorSettings({
|
||||
<div className="p-2 text-sm text-muted-foreground">
|
||||
{t('agents.eventProcessor.noComponents')}
|
||||
<Button asChild variant="link" className="h-auto px-0">
|
||||
<Link to="/home/plugins">
|
||||
<Link to="/home/add-extension?type=plugin&component=Runner&runner_usage=event">
|
||||
{t('agents.eventProcessor.installPlugin')}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -227,7 +227,7 @@ export default function RunnerSelect({
|
||||
setCatalogLoading(true);
|
||||
setCatalogError(false);
|
||||
try {
|
||||
const catalog = await loadRunnerCatalog();
|
||||
const catalog = await loadRunnerCatalog('agent');
|
||||
setMarketplaceRunners(catalog.marketplaceRunners);
|
||||
setInstalledPluginIds(catalog.installedPluginIds);
|
||||
setInstalledPluginDescriptions(catalog.installedPluginDescriptions);
|
||||
@@ -392,7 +392,7 @@ export default function RunnerSelect({
|
||||
{t('agents.marketplaceRunners')}
|
||||
</span>
|
||||
<a
|
||||
href="https://space.langbot.app/market?type=plugin&component=Runner"
|
||||
href="https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent"
|
||||
|
||||
@@ -3,7 +3,11 @@ import { getCloudServiceClient } from '@/app/infra/http';
|
||||
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
|
||||
import type { PipelineConfigTab } from '@/app/infra/entities/pipeline';
|
||||
import type { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import {
|
||||
supportsRunnerUsage,
|
||||
type PluginV4,
|
||||
type RunnerUsage,
|
||||
} from '@/app/infra/entities/plugin';
|
||||
import type { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
export const RUNNER_COMPONENT_FILTER = 'Runner';
|
||||
@@ -140,7 +144,9 @@ export function subscribePendingRunnerInstall(
|
||||
window.removeEventListener(RUNNER_INSTALL_INTENT_EVENT, handleChange);
|
||||
}
|
||||
|
||||
export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
|
||||
export async function loadRunnerCatalog(
|
||||
usage: RunnerUsage,
|
||||
): Promise<RunnerCatalog> {
|
||||
const cloudClient = await getCloudServiceClient();
|
||||
const [firstSearchResult, recommendationResult, installedResult] =
|
||||
await Promise.all([
|
||||
@@ -150,6 +156,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
|
||||
page_size: RUNNER_CATALOG_PAGE_SIZE,
|
||||
type_filter: 'plugin',
|
||||
component_filter: RUNNER_COMPONENT_FILTER,
|
||||
runner_usage: usage,
|
||||
}),
|
||||
cloudClient.getRecommendationLists().catch(() => ({ lists: [] })),
|
||||
httpClient.getPlugins().catch(() => ({ plugins: [] })),
|
||||
@@ -167,6 +174,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
|
||||
page_size: RUNNER_CATALOG_PAGE_SIZE,
|
||||
type_filter: 'plugin',
|
||||
component_filter: RUNNER_COMPONENT_FILTER,
|
||||
runner_usage: usage,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -189,7 +197,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
|
||||
}
|
||||
|
||||
const marketplaceRunners = catalogPlugins
|
||||
.filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER])
|
||||
.filter((plugin) => supportsRunnerUsage(plugin, usage))
|
||||
.sort((left, right) => {
|
||||
const leftOrder = recommendationOrder.get(marketplacePluginId(left));
|
||||
const rightOrder = recommendationOrder.get(marketplacePluginId(right));
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Wrench,
|
||||
BrainCircuit,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Info,
|
||||
@@ -71,6 +72,13 @@ import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-
|
||||
import ReasoningLevelPicker, {
|
||||
REASONING_LEVELS,
|
||||
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
|
||||
import LangBotModelMetadata from '@/app/home/components/model-availability/LangBotModelMetadata';
|
||||
import { sortModelsByCatalog } from '@/app/home/components/model-availability/sort-models';
|
||||
import { useLangBotModelAvailability } from '@/app/home/components/model-availability/useLangBotModelAvailability';
|
||||
|
||||
const MODEL_SELECT_TRIGGER_CLASS =
|
||||
'w-full min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e] *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:flex-1';
|
||||
const MODEL_SELECT_ITEM_CLASS = '*:[span]:last:min-w-0 *:[span]:last:flex-1';
|
||||
|
||||
function hasUsableUuid<T extends { uuid?: string | null }>(
|
||||
item: T,
|
||||
@@ -153,6 +161,54 @@ export default function DynamicFormItemComponent({
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
const isModelSelector = [
|
||||
DynamicFormItemType.LLM_MODEL_SELECTOR,
|
||||
DynamicFormItemType.EMBEDDING_MODEL_SELECTOR,
|
||||
DynamicFormItemType.RERANK_MODEL_SELECTOR,
|
||||
DynamicFormItemType.MODEL_FALLBACK_SELECTOR,
|
||||
].includes(config.type);
|
||||
const {
|
||||
metadata: langbotModelMetadata,
|
||||
loaded: langbotModelAvailabilityLoaded,
|
||||
} = useLangBotModelAvailability(
|
||||
isModelSelector && !systemInfo.disable_models_service,
|
||||
);
|
||||
|
||||
const renderModelOption = (model: {
|
||||
uuid: string;
|
||||
name: string;
|
||||
abilities?: string[];
|
||||
reasoning_capabilities?: { supported?: boolean };
|
||||
provider?: { requester?: string };
|
||||
}) => (
|
||||
<span className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<span className="truncate">{model.name}</span>
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{(model.reasoning_capabilities?.supported === true ||
|
||||
model.abilities?.includes('reasoning')) && (
|
||||
<BrainCircuit
|
||||
className="h-3 w-3 shrink-0 text-muted-foreground"
|
||||
aria-label={t('models.reasoningAbility')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{model.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER && (
|
||||
<LangBotModelMetadata
|
||||
metadata={
|
||||
langbotModelMetadata[model.uuid] ?? langbotModelMetadata[model.name]
|
||||
}
|
||||
loaded={langbotModelAvailabilityLoaded}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const fetchLlmModels = () => {
|
||||
httpClient
|
||||
@@ -529,8 +585,11 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
||||
// Separate space models from regular models
|
||||
const spaceModels = llmModels.filter(
|
||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
const spaceModels = sortModelsByCatalog(
|
||||
llmModels.filter(
|
||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
),
|
||||
langbotModelMetadata,
|
||||
);
|
||||
const regularModels = llmModels.filter(
|
||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
@@ -573,7 +632,7 @@ export default function DynamicFormItemComponent({
|
||||
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={t('models.selectModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -581,16 +640,12 @@ export default function DynamicFormItemComponent({
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<SelectItem
|
||||
key={model.uuid}
|
||||
value={model.uuid}
|
||||
className={MODEL_SELECT_ITEM_CLASS}
|
||||
>
|
||||
{renderModelOption(model)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -685,16 +740,12 @@ export default function DynamicFormItemComponent({
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<SelectItem
|
||||
key={model.uuid}
|
||||
value={model.uuid}
|
||||
className={MODEL_SELECT_ITEM_CLASS}
|
||||
>
|
||||
{renderModelOption(model)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -731,8 +782,11 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
|
||||
const spaceEmbeddingModels = embeddingModels.filter(
|
||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
const spaceEmbeddingModels = sortModelsByCatalog(
|
||||
embeddingModels.filter(
|
||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
),
|
||||
langbotModelMetadata,
|
||||
);
|
||||
const regularEmbeddingModels = embeddingModels.filter(
|
||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
@@ -771,7 +825,7 @@ export default function DynamicFormItemComponent({
|
||||
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
|
||||
<SelectValue
|
||||
placeholder={t('knowledge.selectEmbeddingModel')}
|
||||
/>
|
||||
@@ -782,8 +836,12 @@ export default function DynamicFormItemComponent({
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
<SelectItem
|
||||
key={model.uuid}
|
||||
value={model.uuid}
|
||||
className={MODEL_SELECT_ITEM_CLASS}
|
||||
>
|
||||
{renderModelOption(model)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -874,8 +932,12 @@ export default function DynamicFormItemComponent({
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
<SelectItem
|
||||
key={model.uuid}
|
||||
value={model.uuid}
|
||||
className={MODEL_SELECT_ITEM_CLASS}
|
||||
>
|
||||
{renderModelOption(model)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -922,6 +984,18 @@ export default function DynamicFormItemComponent({
|
||||
},
|
||||
{} as Record<string, RerankModel[]>,
|
||||
);
|
||||
for (const [providerName, models] of Object.entries(
|
||||
groupedRerankModels,
|
||||
)) {
|
||||
if (
|
||||
models[0]?.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER
|
||||
) {
|
||||
groupedRerankModels[providerName] = sortModelsByCatalog(
|
||||
models,
|
||||
langbotModelMetadata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md min-w-0">
|
||||
@@ -929,7 +1003,7 @@ export default function DynamicFormItemComponent({
|
||||
value={field.value || '__none__'}
|
||||
onValueChange={(v) => field.onChange(v === '__none__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={t('models.rerank')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -939,8 +1013,12 @@ export default function DynamicFormItemComponent({
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
<SelectItem
|
||||
key={model.uuid}
|
||||
value={model.uuid}
|
||||
className={MODEL_SELECT_ITEM_CLASS}
|
||||
>
|
||||
{renderModelOption(model)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -953,8 +1031,11 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
||||
// Separate space models from regular models
|
||||
const fbSpaceModels = llmModels.filter(
|
||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
const fbSpaceModels = sortModelsByCatalog(
|
||||
llmModels.filter(
|
||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
),
|
||||
langbotModelMetadata,
|
||||
);
|
||||
const fbRegularModels = llmModels.filter(
|
||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||
@@ -1048,7 +1129,7 @@ export default function DynamicFormItemComponent({
|
||||
placeholder: string,
|
||||
) => (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1057,16 +1138,12 @@ export default function DynamicFormItemComponent({
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<SelectItem
|
||||
key={model.uuid}
|
||||
value={model.uuid}
|
||||
className={MODEL_SELECT_ITEM_CLASS}
|
||||
>
|
||||
{renderModelOption(model)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -1162,16 +1239,12 @@ export default function DynamicFormItemComponent({
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
<SelectItem
|
||||
key={model.uuid}
|
||||
value={model.uuid}
|
||||
className={MODEL_SELECT_ITEM_CLASS}
|
||||
>
|
||||
{renderModelOption(model)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Coins, TriangleAlert } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ModelAvailabilityIndicator from './ModelAvailabilityIndicator';
|
||||
|
||||
interface LangBotModelMetadataProps {
|
||||
metadata?: LangBotModelAvailabilityItem;
|
||||
loaded: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function formatCredits(value: number, locale: string): string {
|
||||
if (value >= 1000) {
|
||||
const thousands = value / 1000;
|
||||
return `${thousands.toFixed(thousands >= 10 ? 0 : 1).replace(/\.0$/, '')}K`;
|
||||
}
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default function LangBotModelMetadata({
|
||||
metadata,
|
||||
loaded,
|
||||
compact = false,
|
||||
}: LangBotModelMetadataProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (!loaded) return null;
|
||||
|
||||
const inputCredits = metadata?.input_credits;
|
||||
const outputCredits = metadata?.output_credits;
|
||||
const hasPricing = inputCredits != null && outputCredits != null;
|
||||
const input =
|
||||
inputCredits != null ? formatCredits(inputCredits, i18n.language) : '';
|
||||
const output =
|
||||
outputCredits != null ? formatCredits(outputCredits, i18n.language) : '';
|
||||
|
||||
return (
|
||||
<span className="ml-auto inline-flex shrink-0 items-center gap-2 pl-3">
|
||||
{hasPricing ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs tabular-nums text-muted-foreground"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Coins className="size-3" />
|
||||
{compact
|
||||
? t('models.pricing.compact', { input, output })
|
||||
: t('models.pricing.inline', { input, output })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
<div className="space-y-0.5">
|
||||
<p>{t('models.pricing.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('models.pricing.input', {
|
||||
credits: input,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('models.pricing.output', {
|
||||
credits: output,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelAvailabilityIndicator
|
||||
availability={metadata?.availability}
|
||||
show={loaded}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center"
|
||||
aria-label={t('models.pricing.unavailable')}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<TriangleAlert className="size-3.5 text-amber-500" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
<p>{t('models.pricing.unavailable')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { LangBotModelAvailability } from '@/app/infra/entities/api';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ModelAvailabilityIndicatorProps {
|
||||
availability?: LangBotModelAvailability;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export default function ModelAvailabilityIndicator({
|
||||
availability,
|
||||
show,
|
||||
}: ModelAvailabilityIndicatorProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
if (!show) return null;
|
||||
|
||||
const state = availability?.up;
|
||||
const label =
|
||||
state === true
|
||||
? t('models.availability.available')
|
||||
: state === false
|
||||
? t('models.availability.unavailable')
|
||||
: t('models.availability.notChecked');
|
||||
const dotClass =
|
||||
state === true
|
||||
? 'bg-emerald-500'
|
||||
: state === false
|
||||
? 'bg-destructive'
|
||||
: 'bg-muted-foreground/50';
|
||||
const checkedAt = availability?.last_probed_at
|
||||
? new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(availability.last_probed_at))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center"
|
||||
aria-label={label}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full ${dotClass}`} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
<div className="space-y-0.5">
|
||||
<p>{label}</p>
|
||||
{checkedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('models.availability.lastChecked', { time: checkedAt })}
|
||||
{availability && availability.latency_ms > 0
|
||||
? ` · ${availability.latency_ms} ms`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
|
||||
|
||||
type CatalogModel = { uuid: string; name: string };
|
||||
type MetadataMap = Record<string, LangBotModelAvailabilityItem>;
|
||||
|
||||
function listingDay(value?: string | null): number {
|
||||
const timestamp = value ? Date.parse(value) : NaN;
|
||||
// Use UTC calendar days so list order is consistent across user time zones.
|
||||
return Number.isFinite(timestamp) ? Math.floor(timestamp / 86_400_000) : -1;
|
||||
}
|
||||
|
||||
function availabilityRank(up?: boolean | null): number {
|
||||
return up === true ? 0 : up == null ? 1 : 2;
|
||||
}
|
||||
|
||||
function price(value?: number | null): number {
|
||||
return value != null && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: Infinity;
|
||||
}
|
||||
|
||||
/** Sort one LangBot Models group without changing the source array. */
|
||||
export function sortModelsByCatalog<T extends CatalogModel>(
|
||||
models: readonly T[],
|
||||
metadata: MetadataMap,
|
||||
): T[] {
|
||||
// Keep the existing order until catalog metadata is available.
|
||||
if (Object.keys(metadata).length === 0) return [...models];
|
||||
|
||||
return [...models].sort((left, right) => {
|
||||
const a = metadata[left.uuid] ?? metadata[left.name];
|
||||
const b = metadata[right.uuid] ?? metadata[right.name];
|
||||
const dateOrder = listingDay(b?.listed_at) - listingDay(a?.listed_at);
|
||||
if (dateOrder) return dateOrder;
|
||||
|
||||
const statusOrder =
|
||||
availabilityRank(a?.availability?.up) -
|
||||
availabilityRank(b?.availability?.up);
|
||||
if (statusOrder) return statusOrder;
|
||||
|
||||
for (const key of ['input_credits', 'output_credits'] as const) {
|
||||
const aPrice = price(a?.[key]);
|
||||
const bPrice = price(b?.[key]);
|
||||
if (aPrice !== bPrice) return aPrice < bPrice ? -1 : 1;
|
||||
}
|
||||
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
type ModelMetadataMap = Record<string, LangBotModelAvailabilityItem>;
|
||||
|
||||
let cachedMetadata: ModelMetadataMap | null = null;
|
||||
let cacheExpiresAt = 0;
|
||||
let pendingRequest: Promise<ModelMetadataMap> | null = null;
|
||||
|
||||
async function loadMetadata(): Promise<ModelMetadataMap> {
|
||||
if (cachedMetadata && Date.now() < cacheExpiresAt) {
|
||||
return cachedMetadata;
|
||||
}
|
||||
if (pendingRequest) return pendingRequest;
|
||||
|
||||
pendingRequest = httpClient
|
||||
.getLangBotModelAvailability()
|
||||
.then((response) => {
|
||||
const next: ModelMetadataMap = {};
|
||||
for (const item of response.models) {
|
||||
next[item.uuid] = item;
|
||||
next[item.model_id] = item;
|
||||
}
|
||||
cachedMetadata = next;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return next;
|
||||
})
|
||||
.finally(() => {
|
||||
pendingRequest = null;
|
||||
});
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
export function useLangBotModelAvailability(enabled = true) {
|
||||
const [metadata, setMetadata] = useState<ModelMetadataMap>(
|
||||
cachedMetadata ?? {},
|
||||
);
|
||||
const [loaded, setLoaded] = useState(
|
||||
cachedMetadata !== null && Date.now() < cacheExpiresAt,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let active = true;
|
||||
loadMetadata()
|
||||
.then((result) => {
|
||||
if (!active) return;
|
||||
setMetadata(result);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// Catalog metadata is supplementary; model configuration remains usable.
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { metadata, loaded };
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
|
||||
import { useLangBotModelAvailability } from '../model-availability/useLangBotModelAvailability';
|
||||
|
||||
interface ModelsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
@@ -89,6 +90,10 @@ export default function ModelsPanel({
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
|
||||
const {
|
||||
metadata: langbotModelMetadata,
|
||||
loaded: langbotModelAvailabilityLoaded,
|
||||
} = useLangBotModelAvailability(active && !systemInfo.disable_models_service);
|
||||
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
const [spaceBilling, setSpaceBilling] =
|
||||
@@ -554,6 +559,8 @@ export default function ModelsPanel({
|
||||
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
|
||||
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
|
||||
spaceCredits={spaceBilling?.credits ?? null}
|
||||
modelMetadata={langbotModelMetadata}
|
||||
modelAvailabilityLoaded={langbotModelAvailabilityLoaded}
|
||||
addModelPopoverOpen={addModelPopoverOpen}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
LLMModel,
|
||||
EmbeddingModel,
|
||||
LangBotModelAvailabilityItem,
|
||||
ReasoningConfig,
|
||||
} from '@/app/infra/entities/api';
|
||||
import {
|
||||
@@ -24,12 +25,15 @@ import {
|
||||
} from '../types';
|
||||
import ExtraArgsEditor from './ExtraArgsEditor';
|
||||
import { userInfo } from '@/app/infra/http';
|
||||
import LangBotModelMetadata from '../../model-availability/LangBotModelMetadata';
|
||||
|
||||
interface ModelItemProps {
|
||||
model: LLMModel | EmbeddingModel;
|
||||
canManage: boolean;
|
||||
modelType: ModelType;
|
||||
isLangBotModels: boolean;
|
||||
metadata?: LangBotModelAvailabilityItem;
|
||||
availabilityLoaded: boolean;
|
||||
editModelPopoverOpen: string | null;
|
||||
deleteConfirmOpen: string | null;
|
||||
onOpenEditModel: (modelId: string) => void;
|
||||
@@ -86,6 +90,8 @@ export default function ModelItem({
|
||||
canManage,
|
||||
modelType,
|
||||
isLangBotModels,
|
||||
metadata,
|
||||
availabilityLoaded,
|
||||
editModelPopoverOpen,
|
||||
deleteConfirmOpen,
|
||||
onOpenEditModel,
|
||||
@@ -197,7 +203,7 @@ export default function ModelItem({
|
||||
: 'hover:bg-accent cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex min-w-0 items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{model.name}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modelType === 'llm'
|
||||
@@ -219,12 +225,21 @@ export default function ModelItem({
|
||||
</Badge>
|
||||
)}
|
||||
{supportsReasoning && (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs gap-1"
|
||||
aria-label={t('models.reasoningAbility')}
|
||||
>
|
||||
<BrainCircuit className="h-3 w-3" />
|
||||
{t('models.reasoningAbility')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isLangBotModels && (
|
||||
<LangBotModelMetadata
|
||||
metadata={metadata}
|
||||
loaded={availabilityLoaded}
|
||||
/>
|
||||
)}
|
||||
{canManage && !isLangBotModels && (
|
||||
<Popover
|
||||
open={isDeleteOpen}
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
|
||||
import {
|
||||
LangBotModelAvailabilityItem,
|
||||
ModelProvider,
|
||||
ReasoningConfig,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -34,6 +38,7 @@ import {
|
||||
ProviderModels,
|
||||
} from '../types';
|
||||
import ModelItem from './ModelItem';
|
||||
import { sortModelsByCatalog } from '../../model-availability/sort-models';
|
||||
import AddModelPopover from './AddModelPopover';
|
||||
|
||||
interface ProviderCardProps {
|
||||
@@ -47,6 +52,8 @@ interface ProviderCardProps {
|
||||
isWorkspaceOwner: boolean;
|
||||
ownerSpaceBound: boolean;
|
||||
spaceCredits: number | null;
|
||||
modelMetadata: Record<string, LangBotModelAvailabilityItem>;
|
||||
modelAvailabilityLoaded: boolean;
|
||||
// Popover states
|
||||
addModelPopoverOpen: string | null;
|
||||
editModelPopoverOpen: string | null;
|
||||
@@ -115,6 +122,8 @@ export default function ProviderCard({
|
||||
isWorkspaceOwner,
|
||||
ownerSpaceBound,
|
||||
spaceCredits,
|
||||
modelMetadata,
|
||||
modelAvailabilityLoaded,
|
||||
addModelPopoverOpen,
|
||||
editModelPopoverOpen,
|
||||
deleteConfirmOpen,
|
||||
@@ -417,13 +426,20 @@ export default function ProviderCard({
|
||||
</p>
|
||||
) : models ? (
|
||||
<div className="space-y-2">
|
||||
{models.llm.map((model) => (
|
||||
{(isLangBotModels
|
||||
? sortModelsByCatalog(models.llm, modelMetadata)
|
||||
: models.llm
|
||||
).map((model) => (
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="llm"
|
||||
isLangBotModels={isLangBotModels}
|
||||
metadata={
|
||||
modelMetadata[model.uuid] ?? modelMetadata[model.name]
|
||||
}
|
||||
availabilityLoaded={modelAvailabilityLoaded}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
onOpenEditModel={onOpenEditModel}
|
||||
@@ -468,13 +484,20 @@ export default function ProviderCard({
|
||||
onResetTestResult={onResetTestResult}
|
||||
/>
|
||||
))}
|
||||
{models.embedding.map((model) => (
|
||||
{(isLangBotModels
|
||||
? sortModelsByCatalog(models.embedding, modelMetadata)
|
||||
: models.embedding
|
||||
).map((model) => (
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="embedding"
|
||||
isLangBotModels={isLangBotModels}
|
||||
metadata={
|
||||
modelMetadata[model.uuid] ?? modelMetadata[model.name]
|
||||
}
|
||||
availabilityLoaded={modelAvailabilityLoaded}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
onOpenEditModel={onOpenEditModel}
|
||||
@@ -517,13 +540,20 @@ export default function ProviderCard({
|
||||
onResetTestResult={onResetTestResult}
|
||||
/>
|
||||
))}
|
||||
{models.rerank.map((model) => (
|
||||
{(isLangBotModels
|
||||
? sortModelsByCatalog(models.rerank, modelMetadata)
|
||||
: models.rerank
|
||||
).map((model) => (
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="rerank"
|
||||
isLangBotModels={isLangBotModels}
|
||||
metadata={
|
||||
modelMetadata[model.uuid] ?? modelMetadata[model.name]
|
||||
}
|
||||
availabilityLoaded={modelAvailabilityLoaded}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
onOpenEditModel={onOpenEditModel}
|
||||
|
||||
@@ -126,6 +126,14 @@ function MarketPageContent({
|
||||
loadMarketFilters().componentFilter ??
|
||||
'all',
|
||||
);
|
||||
const [runnerUsage, setRunnerUsage] = useState(() => {
|
||||
const value = searchParams.get('runner_usage');
|
||||
return value === 'agent' || value === 'event' ? value : 'all';
|
||||
});
|
||||
const activeRunnerUsage =
|
||||
componentFilter === 'Runner' && runnerUsage !== 'all'
|
||||
? (runnerUsage as 'agent' | 'event')
|
||||
: undefined;
|
||||
const [typeFilter, setTypeFilter] = useState<string>(() => {
|
||||
if (getComponentFilterFromQuery(searchParams)) {
|
||||
return 'plugin';
|
||||
@@ -138,7 +146,9 @@ function MarketPageContent({
|
||||
return saved && MARKET_TYPE_VALUES.includes(saved) ? saved : 'all';
|
||||
});
|
||||
const activeAdvancedFilters =
|
||||
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
|
||||
(typeFilter === 'all' ? 0 : 1) +
|
||||
(componentFilter === 'all' ? 0 : 1) +
|
||||
(activeRunnerUsage ? 1 : 0);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(
|
||||
() => loadMarketFilters().selectedTags ?? [],
|
||||
);
|
||||
@@ -313,6 +323,7 @@ function MarketPageContent({
|
||||
type_filter: typeFilter === 'all' ? undefined : typeFilter,
|
||||
component_filter:
|
||||
componentFilter === 'all' ? undefined : componentFilter,
|
||||
runner_usage: activeRunnerUsage,
|
||||
tags_filter: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
});
|
||||
|
||||
@@ -344,6 +355,7 @@ function MarketPageContent({
|
||||
[
|
||||
searchQuery,
|
||||
componentFilter,
|
||||
activeRunnerUsage,
|
||||
selectedTags,
|
||||
pageSize,
|
||||
transformToVO,
|
||||
@@ -502,6 +514,10 @@ function MarketPageContent({
|
||||
setPlugins([]);
|
||||
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value !== 'Runner') {
|
||||
setRunnerUsage('all');
|
||||
params.delete('runner_usage');
|
||||
}
|
||||
if (value === 'all') {
|
||||
params.delete('component');
|
||||
} else {
|
||||
@@ -517,7 +533,7 @@ function MarketPageContent({
|
||||
// 当排序选项或组件筛选或类型筛选变化时重新加载数据
|
||||
useEffect(() => {
|
||||
fetchPlugins(1, !!searchQuery.trim(), true);
|
||||
}, [sortOption, componentFilter, typeFilter]);
|
||||
}, [sortOption, componentFilter, typeFilter, activeRunnerUsage]);
|
||||
|
||||
// Tags 筛选变化时重新搜索
|
||||
useEffect(() => {
|
||||
@@ -826,6 +842,38 @@ function MarketPageContent({
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{componentFilter === 'Runner' && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t('market.runnerUsage')}
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="sm"
|
||||
value={runnerUsage}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setRunnerUsage(value);
|
||||
setCurrentPage(1);
|
||||
setPlugins([]);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (value === 'all') params.delete('runner_usage');
|
||||
else params.set('runner_usage', value);
|
||||
setSearchParams(params, { replace: true });
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="all">
|
||||
{t('market.runnerUsageAll')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="agent">
|
||||
{t('market.runnerUsageAgent')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="event">
|
||||
{t('market.runnerUsageEvent')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
|
||||
@@ -158,6 +158,27 @@ export interface RerankModel {
|
||||
extra_args?: object;
|
||||
}
|
||||
|
||||
export interface LangBotModelAvailability {
|
||||
up: boolean | null;
|
||||
last_probed_at: string | null;
|
||||
latency_ms: number;
|
||||
http_code: number;
|
||||
}
|
||||
|
||||
export interface LangBotModelAvailabilityItem {
|
||||
uuid: string;
|
||||
model_id: string;
|
||||
category: string | null;
|
||||
listed_at?: string | null;
|
||||
input_credits: number | null;
|
||||
output_credits: number | null;
|
||||
availability: LangBotModelAvailability;
|
||||
}
|
||||
|
||||
export interface ApiRespLangBotModelAvailability {
|
||||
models: LangBotModelAvailabilityItem[];
|
||||
}
|
||||
|
||||
export interface ApiRespPipelines {
|
||||
pipelines: Pipeline[];
|
||||
}
|
||||
|
||||
@@ -52,8 +52,23 @@ export interface PluginV4 {
|
||||
hot_score?: number;
|
||||
latest_version: string;
|
||||
components: Record<string, number>;
|
||||
runner_usages?: RunnerUsage[];
|
||||
status: PluginV4Status;
|
||||
type?: 'plugin' | 'mcp' | 'skill';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type RunnerUsage = 'agent' | 'event';
|
||||
|
||||
/** Unknown usage metadata must not become an install recommendation. */
|
||||
export function supportsRunnerUsage(
|
||||
plugin: PluginV4,
|
||||
usage: RunnerUsage,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
plugin.components?.Runner &&
|
||||
Array.isArray(plugin.runner_usages) &&
|
||||
plugin.runner_usages.includes(usage),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
BotRouteDryRunRequest,
|
||||
BotRouteDryRunResult,
|
||||
BotEventRouteStatusResponse,
|
||||
ApiRespLangBotModelAvailability,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||
@@ -1250,6 +1251,10 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get('/api/v1/system/wizard/recommended-model');
|
||||
}
|
||||
|
||||
public getLangBotModelAvailability(): Promise<ApiRespLangBotModelAvailability> {
|
||||
return this.get('/api/v1/system/model-availability');
|
||||
}
|
||||
|
||||
public getAsyncTasks(params?: {
|
||||
type?: string;
|
||||
kind?: string;
|
||||
|
||||
@@ -3,7 +3,11 @@ import {
|
||||
ApiRespMarketplacePluginDetail,
|
||||
ApiRespMarketplacePlugins,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import {
|
||||
PluginV4,
|
||||
RunnerUsage,
|
||||
supportsRunnerUsage,
|
||||
} from '@/app/infra/entities/plugin';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
/**
|
||||
@@ -39,6 +43,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
component_filter?: string,
|
||||
tags_filter?: string[],
|
||||
type_filter?: string,
|
||||
runner_usage?: RunnerUsage,
|
||||
): Promise<ApiRespMarketplacePlugins> {
|
||||
// Use different endpoints based on type_filter
|
||||
if (type_filter === 'mcp') {
|
||||
@@ -90,6 +95,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
sort_by,
|
||||
sort_order,
|
||||
component_filter,
|
||||
runner_usage,
|
||||
tags_filter,
|
||||
type_filter,
|
||||
},
|
||||
@@ -104,6 +110,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
sort_order?: string;
|
||||
type_filter?: string;
|
||||
component_filter?: string;
|
||||
runner_usage?: RunnerUsage;
|
||||
tags_filter?: string[];
|
||||
}): Promise<ApiRespMarketplacePlugins> {
|
||||
return this.post<{ extensions: PluginV4[]; total: number }>(
|
||||
@@ -125,7 +132,15 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
total: resp?.total || 0,
|
||||
};
|
||||
})
|
||||
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
|
||||
.catch(() => this.searchMarketplaceExtensionsLegacy(data))
|
||||
.then((result) => ({
|
||||
...result,
|
||||
plugins: data.runner_usage
|
||||
? result.plugins.filter((plugin) =>
|
||||
supportsRunnerUsage(plugin, data.runner_usage!),
|
||||
)
|
||||
: result.plugins,
|
||||
}));
|
||||
}
|
||||
|
||||
public getMarketplaceLikedExtensions(
|
||||
@@ -162,6 +177,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
sort_order?: string;
|
||||
type_filter?: string;
|
||||
component_filter?: string;
|
||||
runner_usage?: RunnerUsage;
|
||||
tags_filter?: string[];
|
||||
}): Promise<ApiRespMarketplacePlugins> {
|
||||
const query = data.query || '';
|
||||
@@ -183,6 +199,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
data.component_filter,
|
||||
data.tags_filter,
|
||||
data.component_filter ? 'plugin' : data.type_filter,
|
||||
data.runner_usage,
|
||||
).catch((error) => {
|
||||
if (data.type_filter === 'mcp' || data.type_filter === 'skill') {
|
||||
return { plugins: [], total: 0 };
|
||||
|
||||
@@ -225,7 +225,7 @@ export default function WizardPage() {
|
||||
setIsRunnerCatalogLoading(true);
|
||||
setRunnerCatalogError(false);
|
||||
try {
|
||||
const catalog = await fetchRunnerCatalog();
|
||||
const catalog = await fetchRunnerCatalog('agent');
|
||||
setMarketplaceRunners(catalog.marketplaceRunners);
|
||||
setInstalledPluginIds(catalog.installedPluginIds);
|
||||
} catch (error) {
|
||||
@@ -2087,7 +2087,7 @@ function StepAIEngine({
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/home/extensions?type=plugin&component=Runner">
|
||||
<Link to="/home/extensions?type=plugin&component=Runner&runner_usage=agent">
|
||||
{t('wizard.aiEngine.browseRunners')}
|
||||
<ExternalLink className="size-4" />
|
||||
</Link>
|
||||
|
||||
@@ -304,6 +304,20 @@ const enUS = {
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||
noModels: 'No models configured',
|
||||
availability: {
|
||||
available: 'Available at last check',
|
||||
unavailable: 'Unavailable at last check',
|
||||
notChecked: 'No check result',
|
||||
lastChecked: 'Checked {{time}}',
|
||||
},
|
||||
pricing: {
|
||||
compact: '{{input}} / {{output}}',
|
||||
inline: '{{input}} input · {{output}} output',
|
||||
title: 'Credits per 1M tokens',
|
||||
input: 'Input: {{credits}} credits',
|
||||
output: 'Output: {{credits}} credits',
|
||||
unavailable: 'No price found. The model may have been removed.',
|
||||
},
|
||||
langbotModels: 'LangBot Models',
|
||||
spaceTrialTooltip:
|
||||
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
|
||||
@@ -727,7 +741,7 @@ const enUS = {
|
||||
create: 'Create plugin processor',
|
||||
type: 'Plugin processor',
|
||||
description:
|
||||
'Handle events with code and processing logic provided by a plugin.',
|
||||
'Handle specific, declared event types through logic programmed in a plugin.',
|
||||
component: 'Plugin processor',
|
||||
selectComponent: 'Select a plugin processor',
|
||||
unavailable: 'Component unavailable',
|
||||
@@ -792,14 +806,14 @@ const enUS = {
|
||||
selectFromSidebar: 'Select a processor from the sidebar',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Use a runner to handle messages, group members, friends, feedback, and other platform events. Best for scenarios that need autonomous decisions, tool use, or non-message events.',
|
||||
'Describe how to handle different events in natural language and let AI act, or connect an external Agent platform to process them.',
|
||||
pipelineType: 'Pipeline',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: 'Pipeline',
|
||||
groupByKind: 'Group by type',
|
||||
groupByKindShort: 'Group',
|
||||
pipelineTypeDescription:
|
||||
'Follow a fixed flow: receive a message, call AI, and reply to the user, with configurable knowledge bases and plugins. Handles message events only, for tasks with clear steps and control over processing.',
|
||||
'Handle message events only, with AI generating replies directly and practical features such as knowledge bases and plugins.',
|
||||
allEvents: 'Supports all events',
|
||||
messageEventsOnly: 'Message events only',
|
||||
chooseType: 'Choose how it works',
|
||||
@@ -1215,6 +1229,11 @@ const enUS = {
|
||||
},
|
||||
},
|
||||
market: {
|
||||
runnerUsage: 'Runner usage',
|
||||
runnerUsageAll: 'All',
|
||||
runnerUsageAgent: 'Agent / Pipeline',
|
||||
runnerUsageEvent: 'Plugin processor',
|
||||
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
searchPlaceholderCount:
|
||||
'Search {{count}} extensions, capabilities, or use cases...',
|
||||
@@ -2529,9 +2548,9 @@ const enUS = {
|
||||
catalogUnavailable: 'Runner catalog is unavailable',
|
||||
catalogUnavailableDescription:
|
||||
'Installed runners are still available. Retry the catalog or browse Extensions.',
|
||||
noMarketplaceRunners: 'No Runner extensions are published yet',
|
||||
noMarketplaceRunners: 'No Runner plugins match this usage',
|
||||
noMarketplaceRunnersDescription:
|
||||
'Retry after runner extensions are published to the configured Marketplace.',
|
||||
'Use an installed Runner or try again later.',
|
||||
browseRunners: 'Browse Runner Extensions',
|
||||
installAndContinue: 'Install & Continue',
|
||||
installing: 'Installing...',
|
||||
|
||||
@@ -306,6 +306,21 @@ const esES = {
|
||||
loginToUseModels:
|
||||
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
|
||||
noModels: 'No hay modelos configurados',
|
||||
availability: {
|
||||
available: 'Disponible en la última comprobación',
|
||||
unavailable: 'No disponible en la última comprobación',
|
||||
notChecked: 'Sin resultado de comprobación',
|
||||
lastChecked: 'Comprobado {{time}}',
|
||||
},
|
||||
pricing: {
|
||||
compact: '{{input}} / {{output}}',
|
||||
inline: 'Entrada {{input}} · salida {{output}}',
|
||||
title: 'Créditos por 1 M de tokens',
|
||||
input: 'Entrada: {{credits}} créditos',
|
||||
output: 'Salida: {{credits}} créditos',
|
||||
unavailable:
|
||||
'No se encontró el precio. Es posible que el modelo haya sido retirado.',
|
||||
},
|
||||
langbotModels: 'Modelos LangBot',
|
||||
spaceTrialTooltip:
|
||||
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.',
|
||||
|
||||
@@ -309,6 +309,20 @@ const jaJP = {
|
||||
usesOwnerSpaceBilling:
|
||||
'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
|
||||
noModels: 'モデルがありません',
|
||||
availability: {
|
||||
available: '前回のチェックで利用可能',
|
||||
unavailable: '前回のチェックで利用不可',
|
||||
notChecked: 'チェック結果なし',
|
||||
lastChecked: '{{time}} にチェック',
|
||||
},
|
||||
pricing: {
|
||||
compact: '{{input}} / {{output}}',
|
||||
inline: '入力 {{input}} · 出力 {{output}}',
|
||||
title: '100万トークンあたりのクレジット',
|
||||
input: '入力:{{credits}} クレジット',
|
||||
output: '出力:{{credits}} クレジット',
|
||||
unavailable: '価格が見つかりません。モデルが削除された可能性があります。',
|
||||
},
|
||||
langbotModels: 'LangBot モデル',
|
||||
spaceTrialTooltip:
|
||||
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
|
||||
@@ -740,7 +754,7 @@ const jaJP = {
|
||||
create: 'プラグインプロセッサーを作成',
|
||||
type: 'プラグインプロセッサー',
|
||||
description:
|
||||
'プラグインが提供するコードと処理ロジックでイベントを処理します。',
|
||||
'事前に宣言された特定のイベントを、プラグインに実装されたロジックに従って処理します。',
|
||||
component: 'プラグインプロセッサー',
|
||||
selectComponent: 'プラグインプロセッサーを選択',
|
||||
unavailable: 'コンポーネントを利用できません',
|
||||
@@ -832,14 +846,14 @@ const jaJP = {
|
||||
selectFromSidebar: 'サイドバーからプロセッサーを選択',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'Runner を使ってメッセージ、グループメンバー、友だち、フィードバックなどのプラットフォームイベントを処理します。自律的な判断、ツール利用、メッセージ以外のイベント対応が必要な場合に適しています。',
|
||||
'さまざまなイベントの処理方法を自然言語で指定して AI に実行させるか、外部の Agent プラットフォームに接続して処理します。',
|
||||
pipelineType: 'パイプライン',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: 'パイプライン',
|
||||
groupByKind: 'タイプ別にグループ化',
|
||||
groupByKindShort: 'グループ',
|
||||
pipelineTypeDescription:
|
||||
'「メッセージ受信、AI呼び出し、ユーザーへの返信」の固定フローで動作し、ナレッジベースやプラグインを設定できます。メッセージイベントのみを処理し、手順が明確で処理の制御が必要な用途に適しています。',
|
||||
'メッセージイベントのみを処理し、AI が直接返信を生成します。ナレッジベースやプラグインなどの便利な機能も利用できます。',
|
||||
allEvents: 'すべてのイベントに対応',
|
||||
messageEventsOnly: 'メッセージイベントのみ',
|
||||
chooseType: '処理方法を選択',
|
||||
@@ -1135,6 +1149,11 @@ const jaJP = {
|
||||
uploadPluginOnly: '.lbpkg プラグインパッケージのみ対応しています',
|
||||
},
|
||||
market: {
|
||||
runnerUsage: 'ランナーの用途',
|
||||
runnerUsageAll: 'すべて',
|
||||
runnerUsageAgent: 'Agent / パイプライン',
|
||||
runnerUsageEvent: 'プラグインプロセッサー',
|
||||
|
||||
searchPlaceholder: 'プラグインを検索...',
|
||||
searchPlaceholderCount:
|
||||
'{{count}} 個の拡張機能・機能・ユースケースを検索...',
|
||||
@@ -2309,9 +2328,9 @@ const jaJP = {
|
||||
catalogUnavailable: 'Runner カタログを読み込めません',
|
||||
catalogUnavailableDescription:
|
||||
'インストール済みの Runner は引き続き使用できます。再試行するか、拡張機能を確認してください。',
|
||||
noMarketplaceRunners: 'Runner 拡張機能はまだ公開されていません',
|
||||
noMarketplaceRunners: 'この用途に対応するランナープラグインはありません',
|
||||
noMarketplaceRunnersDescription:
|
||||
'設定済みのマーケットプレイスに Runner 拡張機能が公開された後、再試行してください。',
|
||||
'インストール済みのランナーを使うか、後でもう一度お試しください。',
|
||||
browseRunners: 'Runner 拡張機能を見る',
|
||||
installAndContinue: 'インストールして続行',
|
||||
installing: 'インストール中...',
|
||||
|
||||
@@ -303,6 +303,20 @@ const ruRU = {
|
||||
loginToUseModels:
|
||||
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
|
||||
noModels: 'Модели не настроены',
|
||||
availability: {
|
||||
available: 'Доступна при последней проверке',
|
||||
unavailable: 'Недоступна при последней проверке',
|
||||
notChecked: 'Нет результата проверки',
|
||||
lastChecked: 'Проверено {{time}}',
|
||||
},
|
||||
pricing: {
|
||||
compact: '{{input}} / {{output}}',
|
||||
inline: 'Ввод {{input}} · вывод {{output}}',
|
||||
title: 'Кредиты за 1 млн токенов',
|
||||
input: 'Ввод: {{credits}} кредитов',
|
||||
output: 'Вывод: {{credits}} кредитов',
|
||||
unavailable: 'Цена не найдена. Возможно, модель была удалена.',
|
||||
},
|
||||
langbotModels: 'Модели LangBot',
|
||||
spaceTrialTooltip:
|
||||
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
|
||||
|
||||
@@ -292,6 +292,20 @@ const thTH = {
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
|
||||
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
|
||||
availability: {
|
||||
available: 'พร้อมใช้งานในการตรวจสอบล่าสุด',
|
||||
unavailable: 'ไม่พร้อมใช้งานในการตรวจสอบล่าสุด',
|
||||
notChecked: 'ไม่มีผลการตรวจสอบ',
|
||||
lastChecked: 'ตรวจสอบเมื่อ {{time}}',
|
||||
},
|
||||
pricing: {
|
||||
compact: '{{input}} / {{output}}',
|
||||
inline: 'อินพุต {{input}} · เอาต์พุต {{output}}',
|
||||
title: 'เครดิตต่อ 1 ล้านโทเค็น',
|
||||
input: 'อินพุต: {{credits}} เครดิต',
|
||||
output: 'เอาต์พุต: {{credits}} เครดิต',
|
||||
unavailable: 'ไม่พบราคา โมเดลอาจถูกนำออกแล้ว',
|
||||
},
|
||||
langbotModels: 'โมเดล LangBot',
|
||||
spaceTrialTooltip:
|
||||
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
|
||||
|
||||
@@ -300,6 +300,20 @@ const viVN = {
|
||||
loginToUseModels:
|
||||
'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
|
||||
noModels: 'Chưa cấu hình mô hình nào',
|
||||
availability: {
|
||||
available: 'Khả dụng ở lần kiểm tra gần nhất',
|
||||
unavailable: 'Không khả dụng ở lần kiểm tra gần nhất',
|
||||
notChecked: 'Chưa có kết quả kiểm tra',
|
||||
lastChecked: 'Đã kiểm tra {{time}}',
|
||||
},
|
||||
pricing: {
|
||||
compact: '{{input}} / {{output}}',
|
||||
inline: 'Đầu vào {{input}} · đầu ra {{output}}',
|
||||
title: 'Tín dụng trên 1 triệu token',
|
||||
input: 'Đầu vào: {{credits}} tín dụng',
|
||||
output: 'Đầu ra: {{credits}} tín dụng',
|
||||
unavailable: 'Không tìm thấy giá. Mô hình có thể đã bị gỡ.',
|
||||
},
|
||||
langbotModels: 'Mô hình LangBot',
|
||||
spaceTrialTooltip:
|
||||
'Có tín dụng dùng thử miễn phí! Đăng nhập bằng tài khoản LangBot để truy cập mô hình đám mây không cần cấu hình.',
|
||||
|
||||
@@ -290,6 +290,20 @@ const zhHans = {
|
||||
'工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。',
|
||||
usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。',
|
||||
noModels: '暂无模型',
|
||||
availability: {
|
||||
available: '上次检测可用',
|
||||
unavailable: '上次检测不可用',
|
||||
notChecked: '暂无检测结果',
|
||||
lastChecked: '检测于 {{time}}',
|
||||
},
|
||||
pricing: {
|
||||
compact: '{{input}} / {{output}}',
|
||||
inline: '输入 {{input}} · 输出 {{output}}',
|
||||
title: '每 1M tokens 消耗积分',
|
||||
input: '输入:{{credits}} 积分',
|
||||
output: '输出:{{credits}} 积分',
|
||||
unavailable: '未查询到价格,模型可能已被下架',
|
||||
},
|
||||
langbotModels: 'LangBot 模型',
|
||||
spaceTrialTooltip:
|
||||
'免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
|
||||
@@ -689,7 +703,7 @@ const zhHans = {
|
||||
|
||||
create: '创建插件处理器',
|
||||
type: '插件处理器',
|
||||
description: '由插件中的代码处理事件,处理逻辑由插件实现。',
|
||||
description: '由插件代码处理预先声明的特定事件,按插件编写的逻辑执行。',
|
||||
component: '插件处理器',
|
||||
selectComponent: '选择插件处理器',
|
||||
unavailable: '组件不可用',
|
||||
@@ -754,14 +768,14 @@ const zhHans = {
|
||||
selectFromSidebar: '从侧边栏选择一个处理器',
|
||||
agentType: 'Agent',
|
||||
agentTypeDescription:
|
||||
'通过运行器处理消息、群成员、好友、反馈等平台事件。适合需要自主判断、调用工具或响应非消息事件的场景。',
|
||||
'用自然语言描述多种事件的处理方式,让 AI 执行;也可接入外部 Agent 平台处理事件。',
|
||||
pipelineType: '流水线',
|
||||
kindBadgeAgent: 'Agent',
|
||||
kindBadgePipeline: '流水线',
|
||||
groupByKind: '按类型分组',
|
||||
groupByKindShort: '分组',
|
||||
pipelineTypeDescription:
|
||||
'按“接收消息、调用 AI、回复用户”的固定流程运行,可配置知识库和插件扩展。仅处理消息事件,适合步骤明确、需要控制处理过程的场景。',
|
||||
'只处理消息事件,由 AI 直接生成回复,并提供知识库、插件等实用功能。',
|
||||
allEvents: '支持全部事件',
|
||||
messageEventsOnly: '仅支持消息事件',
|
||||
chooseType: '选择处理方式',
|
||||
@@ -1152,6 +1166,11 @@ const zhHans = {
|
||||
},
|
||||
},
|
||||
market: {
|
||||
runnerUsage: '运行器用途',
|
||||
runnerUsageAll: '全部',
|
||||
runnerUsageAgent: 'Agent / 流水线',
|
||||
runnerUsageEvent: '插件处理器',
|
||||
|
||||
searchPlaceholder: '搜索插件...',
|
||||
searchPlaceholderCount: '搜索 {{count}} 个扩展、能力或场景...',
|
||||
searchResults: '搜索到 {{count}} 个扩展',
|
||||
@@ -2390,9 +2409,8 @@ const zhHans = {
|
||||
catalogUnavailable: '无法加载运行器目录',
|
||||
catalogUnavailableDescription:
|
||||
'已安装的运行器仍可使用。你可以重试,或前往扩展页面查看。',
|
||||
noMarketplaceRunners: '市场暂未发布运行器扩展',
|
||||
noMarketplaceRunnersDescription:
|
||||
'请在运行器扩展发布到当前配置的市场后重试。',
|
||||
noMarketplaceRunners: '暂无适用于此用途的运行器插件',
|
||||
noMarketplaceRunnersDescription: '可以使用已安装的运行器,或稍后重试。',
|
||||
browseRunners: '浏览运行器扩展',
|
||||
installAndContinue: '安装并继续',
|
||||
installing: '正在安装...',
|
||||
|
||||
@@ -281,6 +281,20 @@ const zhHant = {
|
||||
loginWithSpace: '使用 LangBot 帳號登入',
|
||||
loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型',
|
||||
noModels: '暫無模型',
|
||||
availability: {
|
||||
available: '上次檢測可用',
|
||||
unavailable: '上次檢測不可用',
|
||||
notChecked: '暫無檢測結果',
|
||||
lastChecked: '檢測於 {{time}}',
|
||||
},
|
||||
pricing: {
|
||||
compact: '{{input}} / {{output}}',
|
||||
inline: '輸入 {{input}} · 輸出 {{output}}',
|
||||
title: '每 1M tokens 消耗積分',
|
||||
input: '輸入:{{credits}} 積分',
|
||||
output: '輸出:{{credits}} 積分',
|
||||
unavailable: '未查詢到價格,模型可能已被下架',
|
||||
},
|
||||
langbotModels: 'LangBot 模型',
|
||||
spaceTrialTooltip:
|
||||
'免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',
|
||||
|
||||
@@ -89,7 +89,7 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
await expect(
|
||||
page.locator('[data-processor-kind="pipeline"]'),
|
||||
).toContainText(
|
||||
'流水线按“接收消息、调用 AI、回复用户”的固定流程运行,可配置知识库和插件扩展。仅处理消息事件,适合步骤明确、需要控制处理过程的场景。',
|
||||
'流水线只处理消息事件,由 AI 直接生成回复,并提供知识库、插件等实用功能。',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
const source = fs.readFileSync(
|
||||
new URL(
|
||||
'../../src/app/home/components/model-availability/sort-models.ts',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const exports = {};
|
||||
new Function(
|
||||
'exports',
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText,
|
||||
)(exports);
|
||||
const { sortModelsByCatalog } = exports;
|
||||
const model = (name) => ({ uuid: name, name });
|
||||
const metadata = (listed_at, up, input_credits = 10, output_credits = 20) => ({
|
||||
listed_at,
|
||||
availability: { up },
|
||||
input_credits,
|
||||
output_credits,
|
||||
});
|
||||
const names = (models) => models.map((m) => m.name);
|
||||
|
||||
test('newest UTC day precedes availability; same-day time is ignored', () => {
|
||||
const items = ['old-up', 'new-down', 'new-up'].map(model);
|
||||
const catalog = {
|
||||
'old-up': metadata('2026-09-08T23:59:59Z', true),
|
||||
'new-down': metadata('2026-09-09T23:59:59Z', false),
|
||||
'new-up': metadata('2026-09-10T01:00:00+08:00', true),
|
||||
};
|
||||
assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [
|
||||
'new-up',
|
||||
'new-down',
|
||||
'old-up',
|
||||
]);
|
||||
assert.deepEqual(names(items), ['old-up', 'new-down', 'new-up']);
|
||||
});
|
||||
|
||||
test('same-day availability ranks up, unknown, down before prices', () => {
|
||||
const catalog = {
|
||||
down: metadata('2026-09-09', false, 0, 0),
|
||||
unknown: metadata('2026-09-09', null, 1, 1),
|
||||
up: metadata('2026-09-09', true, 100, 100),
|
||||
};
|
||||
assert.deepEqual(
|
||||
names(sortModelsByCatalog(Object.keys(catalog).map(model), catalog)),
|
||||
['up', 'unknown', 'down'],
|
||||
);
|
||||
});
|
||||
|
||||
test('prices compare input then output; free prices remain valid', () => {
|
||||
const catalog = {
|
||||
expensive: metadata('2026-09-09', true, 20, 1),
|
||||
'output-high': metadata('2026-09-09', true, 10, 50),
|
||||
'output-low': metadata('2026-09-09', true, 10, 20),
|
||||
free: metadata('2026-09-09', true, 0, 0),
|
||||
missing: metadata('2026-09-09', true, null, null),
|
||||
invalid: metadata('2026-09-09', true, NaN, -1),
|
||||
};
|
||||
assert.deepEqual(
|
||||
names(sortModelsByCatalog(Object.keys(catalog).map(model), catalog)),
|
||||
['free', 'output-low', 'output-high', 'expensive', 'invalid', 'missing'],
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown dates sort after known dates and missing catalog entries are safe', () => {
|
||||
const catalog = {
|
||||
known: metadata('2026-01-01', false, 100, 100),
|
||||
invalid: metadata('invalid', true),
|
||||
missing: metadata(null, true),
|
||||
};
|
||||
assert.deepEqual(
|
||||
names(
|
||||
sortModelsByCatalog(
|
||||
['missing', 'absent', 'invalid', 'known'].map(model),
|
||||
catalog,
|
||||
),
|
||||
),
|
||||
['known', 'invalid', 'missing', 'absent'],
|
||||
);
|
||||
});
|
||||
|
||||
test('UUID lookup takes precedence, name lookup works, empty metadata preserves order', () => {
|
||||
const items = [{ uuid: 'local-id', name: 'alias' }, model('other')];
|
||||
const catalog = {
|
||||
alias: metadata('2026-09-09', true),
|
||||
other: metadata('2026-09-08', true),
|
||||
};
|
||||
assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [
|
||||
'alias',
|
||||
'other',
|
||||
]);
|
||||
catalog['local-id'] = metadata('2026-09-07', true);
|
||||
assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [
|
||||
'other',
|
||||
'alias',
|
||||
]);
|
||||
assert.deepEqual(sortModelsByCatalog(items, {}), items);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
function load(path, imports = {}) {
|
||||
const exports = {};
|
||||
const source = fs.readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const js = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText;
|
||||
new Function('exports', 'require', js)(exports, (name) => {
|
||||
if (!(name in imports)) throw new Error(`Unexpected import: ${name}`);
|
||||
return imports[name];
|
||||
});
|
||||
return exports;
|
||||
}
|
||||
const entities = load('../../src/app/infra/entities/plugin/index.ts');
|
||||
const plugin = (name, runner_usages) => ({
|
||||
name,
|
||||
author: 'test',
|
||||
components: { Runner: 1 },
|
||||
runner_usages,
|
||||
install_count: 0,
|
||||
latest_version: '1',
|
||||
});
|
||||
const plugins = [
|
||||
plugin('agent', ['agent']),
|
||||
plugin('event', ['event']),
|
||||
plugin('both', ['agent', 'event']),
|
||||
plugin('unknown', undefined),
|
||||
];
|
||||
test('recommendations require explicit usage and Runner component', () => {
|
||||
assert.deepEqual(
|
||||
plugins
|
||||
.filter((p) => entities.supportsRunnerUsage(p, 'agent'))
|
||||
.map((p) => p.name),
|
||||
['agent', 'both'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
plugins
|
||||
.filter((p) => entities.supportsRunnerUsage(p, 'event'))
|
||||
.map((p) => p.name),
|
||||
['event', 'both'],
|
||||
);
|
||||
assert.equal(
|
||||
entities.supportsRunnerUsage({ ...plugins[0], components: {} }, 'agent'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
test('catalog filters every page and recommendations cannot reintroduce incompatible plugins', async () => {
|
||||
const requests = [];
|
||||
const catalog = load('../../src/app/home/agents/runner-marketplace.ts', {
|
||||
'@/app/infra/entities/plugin': entities,
|
||||
'@/app/infra/http/HttpClient': {
|
||||
httpClient: { getPlugins: async () => ({ plugins: [] }) },
|
||||
},
|
||||
'@/app/infra/http': {
|
||||
getCloudServiceClient: async () => ({
|
||||
searchMarketplaceExtensions: async (request) => {
|
||||
requests.push(request);
|
||||
return {
|
||||
total: 101,
|
||||
plugins:
|
||||
request.page === 1 ? plugins.slice(0, 2) : plugins.slice(2),
|
||||
};
|
||||
},
|
||||
getRecommendationLists: async () => ({
|
||||
lists: [{ plugins: [plugins[1], plugins[2]] }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
'@/app/infra/http/workspaceContext': {},
|
||||
});
|
||||
const result = await catalog.loadRunnerCatalog('agent');
|
||||
assert.deepEqual(
|
||||
result.marketplaceRunners.map((p) => p.name),
|
||||
['both', 'agent'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requests.map((r) => r.runner_usage),
|
||||
['agent', 'agent'],
|
||||
);
|
||||
});
|
||||
test('legacy API fallback preserves usage and rejects missing usage metadata', async () => {
|
||||
const requests = [];
|
||||
class BaseHttpClient {
|
||||
async post(url, data) {
|
||||
requests.push({ url, data });
|
||||
if (url.endsWith('/extensions/search')) throw new Error('old endpoint');
|
||||
return { plugins, total: plugins.length };
|
||||
}
|
||||
}
|
||||
const { CloudServiceClient } = load(
|
||||
'../../src/app/infra/http/CloudServiceClient.ts',
|
||||
{
|
||||
'./BaseHttpClient': { BaseHttpClient },
|
||||
'@/app/infra/entities/plugin': entities,
|
||||
},
|
||||
);
|
||||
const result = await new CloudServiceClient().searchMarketplaceExtensions({
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
type_filter: 'plugin',
|
||||
component_filter: 'Runner',
|
||||
runner_usage: 'event',
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.plugins.map((p) => p.name),
|
||||
['event', 'both'],
|
||||
);
|
||||
assert.equal(requests[1].data.runner_usage, 'event');
|
||||
});
|
||||
Reference in New Issue
Block a user