Compare commits

..

10 Commits

Author SHA1 Message Date
RockChinQ e913658e03 fix(processors): clarify processing modes and reorder choices 2026-09-13 00:18:36 +08:00
RockChinQ 1277c6da07 feat(runner): filter marketplace recommendations by explicit usage 2026-09-12 12:51:16 +08:00
RockChinQ fa15f48fd7 feat(models): sort catalog by listing date availability and price 2026-09-12 02:07:33 +08:00
RockChinQ 18c1ed93b8 fix(models): show reasoning icon in model selectors 2026-09-12 01:23:06 +08:00
RockChinQ 8b85876a19 fix(models): simplify capability badges 2026-09-12 01:09:12 +08:00
RockChinQ 18b84566c3 fix(models): flag models without pricing 2026-09-12 01:04:03 +08:00
RockChinQ 50d544aa6f fix(models): align selector metadata 2026-09-12 00:57:55 +08:00
RockChinQ 33b4035140 feat(models): show LangBot Models pricing 2026-09-12 00:53:47 +08:00
RockChinQ a3509b3626 feat(models): show LangBot Models availability 2026-09-11 21:17:20 +08:00
Hyu e631da0073 fix(runner): align SDK pin and complete real runtime verification (#2525)
* fix(runner): align SDK pin and workspace-aware integration fixtures

* fix(ci): format sources and resolve current migration head

* test(persistence): align standalone migration fixtures with current models

* test(web): align smoke fixtures with current processor UI

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 12:57:29 +08:00
50 changed files with 1105 additions and 107 deletions
+1 -1
View File
@@ -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" }
@@ -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
+1 -1
View File
@@ -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'],
+14 -3
View File
@@ -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]
+16 -1
View File
@@ -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'},
+19
View File
@@ -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'},
+1
View File
@@ -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'},
Generated
+2 -2
View File
@@ -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"
+11 -3
View File
@@ -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>
+21
View File
@@ -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),
);
}
+5
View File
@@ -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;
+19 -2
View File
@@ -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 };
+2 -2
View File
@@ -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>
+24 -5
View File
@@ -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...',
+15
View File
@@ -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.',
+24 -5
View File
@@ -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: 'インストール中...',
+14
View File
@@ -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, чтобы получить доступ к облачным моделям без настройки.',
+14
View File
@@ -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 เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
+14
View File
@@ -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.',
+24 -6
View File
@@ -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: '正在安装...',
+14
View File
@@ -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 帳號登入即可零設定使用雲端模型。',
+1 -1
View File
@@ -89,7 +89,7 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(
page.locator('[data-processor-kind="pipeline"]'),
).toContainText(
'流水线按“接收消息、调用 AI、回复用户”的固定流程运行,可配置知识库插件扩展。仅处理消息事件,适合步骤明确、需要控制处理过程的场景。',
'流水线只处理消息事件,由 AI 直接生成回复,并提供知识库插件等实用功能。',
);
});
+108
View File
@@ -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');
});