mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(runner): filter marketplace recommendations by explicit usage
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1229,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...',
|
||||
@@ -2543,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...',
|
||||
|
||||
@@ -1149,6 +1149,11 @@ const jaJP = {
|
||||
uploadPluginOnly: '.lbpkg プラグインパッケージのみ対応しています',
|
||||
},
|
||||
market: {
|
||||
runnerUsage: 'ランナーの用途',
|
||||
runnerUsageAll: 'すべて',
|
||||
runnerUsageAgent: 'Agent / パイプライン',
|
||||
runnerUsageEvent: 'プラグインプロセッサー',
|
||||
|
||||
searchPlaceholder: 'プラグインを検索...',
|
||||
searchPlaceholderCount:
|
||||
'{{count}} 個の拡張機能・機能・ユースケースを検索...',
|
||||
@@ -2323,9 +2328,9 @@ const jaJP = {
|
||||
catalogUnavailable: 'Runner カタログを読み込めません',
|
||||
catalogUnavailableDescription:
|
||||
'インストール済みの Runner は引き続き使用できます。再試行するか、拡張機能を確認してください。',
|
||||
noMarketplaceRunners: 'Runner 拡張機能はまだ公開されていません',
|
||||
noMarketplaceRunners: 'この用途に対応するランナープラグインはありません',
|
||||
noMarketplaceRunnersDescription:
|
||||
'設定済みのマーケットプレイスに Runner 拡張機能が公開された後、再試行してください。',
|
||||
'インストール済みのランナーを使うか、後でもう一度お試しください。',
|
||||
browseRunners: 'Runner 拡張機能を見る',
|
||||
installAndContinue: 'インストールして続行',
|
||||
installing: 'インストール中...',
|
||||
|
||||
@@ -1166,6 +1166,11 @@ const zhHans = {
|
||||
},
|
||||
},
|
||||
market: {
|
||||
runnerUsage: '运行器用途',
|
||||
runnerUsageAll: '全部',
|
||||
runnerUsageAgent: 'Agent / 流水线',
|
||||
runnerUsageEvent: '插件处理器',
|
||||
|
||||
searchPlaceholder: '搜索插件...',
|
||||
searchPlaceholderCount: '搜索 {{count}} 个扩展、能力或场景...',
|
||||
searchResults: '搜索到 {{count}} 个扩展',
|
||||
@@ -2404,9 +2409,8 @@ const zhHans = {
|
||||
catalogUnavailable: '无法加载运行器目录',
|
||||
catalogUnavailableDescription:
|
||||
'已安装的运行器仍可使用。你可以重试,或前往扩展页面查看。',
|
||||
noMarketplaceRunners: '市场暂未发布运行器扩展',
|
||||
noMarketplaceRunnersDescription:
|
||||
'请在运行器扩展发布到当前配置的市场后重试。',
|
||||
noMarketplaceRunners: '暂无适用于此用途的运行器插件',
|
||||
noMarketplaceRunnersDescription: '可以使用已安装的运行器,或稍后重试。',
|
||||
browseRunners: '浏览运行器扩展',
|
||||
installAndContinue: '安装并继续',
|
||||
installing: '正在安装...',
|
||||
|
||||
@@ -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