mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-22 20:36:08 +00:00
feat(agent-runner): finalize 4.x processor integration
This commit is contained in:
@@ -1,13 +1,25 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Brain, FileJson2, Info, Power, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Brain,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
FileJson2,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
Power,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Unplug,
|
||||
} from 'lucide-react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Agent } from '@/app/infra/entities/api';
|
||||
import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
|
||||
import {
|
||||
PipelineConfigStage,
|
||||
PipelineConfigTab,
|
||||
@@ -43,6 +55,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
interface AgentFormComponentProps {
|
||||
agentId: string;
|
||||
@@ -68,6 +81,10 @@ export default function AgentFormComponent({
|
||||
useState<SectionItem['name']>('basic');
|
||||
const [runnerConfigSchema, setRunnerConfigSchema] =
|
||||
useState<PipelineConfigTab | null>(null);
|
||||
const [pluginSystemStatus, setPluginSystemStatus] =
|
||||
useState<ApiRespPluginSystemStatus | null>(null);
|
||||
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
|
||||
const [pluginStatusError, setPluginStatusError] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -145,6 +162,23 @@ export default function AgentFormComponent({
|
||||
};
|
||||
}, [agentId, form, t]);
|
||||
|
||||
const loadPluginSystemStatus = useCallback(async () => {
|
||||
setPluginStatusLoading(true);
|
||||
setPluginStatusError(false);
|
||||
try {
|
||||
setPluginSystemStatus(await httpClient.getPluginSystemStatus());
|
||||
} catch {
|
||||
setPluginSystemStatus(null);
|
||||
setPluginStatusError(true);
|
||||
} finally {
|
||||
setPluginStatusLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPluginSystemStatus();
|
||||
}, [loadPluginSystemStatus]);
|
||||
|
||||
const sections: SectionItem[] = [
|
||||
{ label: t('agents.basicInfo'), name: 'basic', icon: Info },
|
||||
{ label: t('agents.runnerSettings'), name: 'runner', icon: Brain },
|
||||
@@ -152,6 +186,128 @@ export default function AgentFormComponent({
|
||||
];
|
||||
|
||||
const currentRunner = (form.watch('runner') as Record<string, any>)?.id;
|
||||
const runnerOptions = useMemo(() => {
|
||||
const runnerStage = runnerConfigSchema?.stages.find(
|
||||
(stage) => stage.name === 'runner',
|
||||
);
|
||||
return (
|
||||
runnerStage?.config.find((item) => item.name === 'id')?.options ?? []
|
||||
);
|
||||
}, [runnerConfigSchema]);
|
||||
const selectedRunnerOption = runnerOptions.find(
|
||||
(option) => option.name === currentRunner,
|
||||
);
|
||||
|
||||
function renderRunnerStatusActions(showRetry = true) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{showRetry && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadPluginSystemStatus()}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<Link to="/home/extensions">{t('plugins.title')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRunnerStatus() {
|
||||
if (pluginStatusLoading) {
|
||||
return (
|
||||
<Alert>
|
||||
<LoaderCircle className="animate-spin" />
|
||||
<AlertTitle>{t('agents.runnerStatusLoading')}</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (pluginStatusError || !pluginSystemStatus) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.runnerStatusCheckFailed')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.runnerStatusCheckFailedDescription')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pluginSystemStatus.is_enable) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<Power />
|
||||
<AlertTitle>{t('plugins.systemDisabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('plugins.systemDisabledDesc')}
|
||||
{renderRunnerStatusActions(false)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pluginSystemStatus.is_connected) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<Unplug />
|
||||
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('plugins.connectionErrorDesc')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (runnerOptions.length === 0) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.noRunnersAvailable')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.noRunnersAvailableDescription')}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!currentRunner || !selectedRunnerOption) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert />
|
||||
<AlertTitle>{t('agents.selectedRunnerUnavailable')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.selectedRunnerUnavailableDescription', {
|
||||
runner: currentRunner || t('agents.noRunnerSelected'),
|
||||
})}
|
||||
{renderRunnerStatusActions()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="border-emerald-600/40 bg-emerald-500/5 text-emerald-950 dark:text-emerald-100">
|
||||
<CircleCheck className="text-emerald-600" />
|
||||
<AlertTitle>{t('agents.runnerReady')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('agents.runnerReadyDescription', {
|
||||
runner: extractI18nObject(selectedRunnerOption.label),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function updateSnapshotIfInitial(stageKey: string) {
|
||||
if (!initializedStagesRef.current.has(stageKey)) {
|
||||
@@ -431,6 +587,7 @@ export default function AgentFormComponent({
|
||||
|
||||
{activeSection === 'runner' && (
|
||||
<div className="space-y-6">
|
||||
{renderRunnerStatus()}
|
||||
{runnerConfigSchema?.stages.map((stage) =>
|
||||
renderDynamicStage(stage),
|
||||
)}
|
||||
|
||||
@@ -120,6 +120,7 @@ function getValueSchema(spec: DynamicFormValueSpec) {
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return z.boolean();
|
||||
case DynamicFormItemType.STRING:
|
||||
case DynamicFormItemType.SECRET:
|
||||
return z.string();
|
||||
case DynamicFormItemType.STRING_ARRAY:
|
||||
return z.array(z.string());
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Wrench,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
@@ -137,6 +138,7 @@ export default function DynamicFormItemComponent({
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [tools, setTools] = useState<PluginTool[]>([]);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [secretVisible, setSecretVisible] = useState(false);
|
||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
||||
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
|
||||
@@ -385,6 +387,44 @@ export default function DynamicFormItemComponent({
|
||||
/>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.SECRET:
|
||||
return (
|
||||
<div className="relative w-full max-w-md">
|
||||
<Input
|
||||
type={secretVisible ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
className="pr-10"
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 size-8 -translate-y-1/2 text-muted-foreground"
|
||||
aria-label={
|
||||
secretVisible
|
||||
? t('common.hideSecret')
|
||||
: t('common.showSecret')
|
||||
}
|
||||
onClick={() => setSecretVisible((visible) => !visible)}
|
||||
>
|
||||
{secretVisible ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{secretVisible ? t('common.hideSecret') : t('common.showSecret')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.TEXT:
|
||||
return (
|
||||
<Textarea
|
||||
|
||||
@@ -9,6 +9,10 @@ interface MessageDetailsCardProps {
|
||||
|
||||
export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isLocalAgent = [
|
||||
'local-agent',
|
||||
'plugin:langbot-team/LocalAgent/default',
|
||||
].includes(details.message?.runnerName ?? '');
|
||||
|
||||
// Parse query variables JSON string
|
||||
const queryVariables = useMemo(() => {
|
||||
@@ -204,7 +208,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{/* Query Variables Section - Only show for non-local-agent runners */}
|
||||
{queryVariables &&
|
||||
Object.keys(queryVariables).length > 0 &&
|
||||
details.message?.runnerName !== 'local-agent' && (
|
||||
!isLocalAgent && (
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Braces className="w-4 h-4 mr-2" />
|
||||
@@ -241,7 +245,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{/* No data message */}
|
||||
{(!details.llmCalls || details.llmCalls.length === 0) &&
|
||||
(!details.errors || details.errors.length === 0) &&
|
||||
(details.message?.runnerName === 'local-agent' ||
|
||||
(isLocalAgent ||
|
||||
!queryVariables ||
|
||||
Object.keys(queryVariables).length === 0) && (
|
||||
<div className="text-sm text-muted-foreground text-center py-4">
|
||||
|
||||
@@ -55,6 +55,7 @@ export enum DynamicFormItemType {
|
||||
FLOAT = 'float',
|
||||
BOOLEAN = 'boolean',
|
||||
STRING = 'string',
|
||||
SECRET = 'secret',
|
||||
TEXT = 'text',
|
||||
STRING_ARRAY = 'array[string]',
|
||||
FILE = 'file',
|
||||
|
||||
+403
-7
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UUID } from 'uuidjs';
|
||||
import { toast } from 'sonner';
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
UserPlus,
|
||||
X,
|
||||
ExternalLink,
|
||||
Download,
|
||||
RefreshCw,
|
||||
CircleAlert,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
@@ -26,6 +29,8 @@ import {
|
||||
systemInfo,
|
||||
initializeUserInfo,
|
||||
initializeSystemInfo,
|
||||
getCloudServiceClient,
|
||||
getCloudServiceClientSync,
|
||||
} from '@/app/infra/http';
|
||||
import {
|
||||
Adapter,
|
||||
@@ -55,6 +60,7 @@ import {
|
||||
} from '@/app/infra/entities/adapter-categories';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
import i18n from 'i18next';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -81,6 +87,30 @@ import {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOTAL_STEPS = 4;
|
||||
const RUNNER_COMPONENT_FILTER = 'AgentRunner';
|
||||
const RUNNER_CATALOG_PAGE_SIZE = 100;
|
||||
const RUNNER_INSTALL_TIMEOUT_MS = 120_000;
|
||||
const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000;
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
const value = error as { msg?: string; message?: string };
|
||||
return value.msg || value.message || '';
|
||||
}
|
||||
return typeof error === 'string' ? error : '';
|
||||
}
|
||||
|
||||
function marketplacePluginId(plugin: Pick<PluginV4, 'author' | 'name'>) {
|
||||
return `${plugin.author}/${plugin.name}`;
|
||||
}
|
||||
|
||||
function runnerPluginPrefix(plugin: Pick<PluginV4, 'author' | 'name'>) {
|
||||
return `plugin:${plugin.author}/${plugin.name}/`;
|
||||
}
|
||||
|
||||
type WizardScenarioId =
|
||||
| 'message_reply'
|
||||
@@ -174,12 +204,108 @@ export default function WizardPage() {
|
||||
const [aiConfigTab, setAiConfigTab] = useState<PipelineConfigTab | null>(
|
||||
null,
|
||||
);
|
||||
const [marketplaceRunners, setMarketplaceRunners] = useState<PluginV4[]>([]);
|
||||
const [installedPluginIds, setInstalledPluginIds] = useState<string[]>([]);
|
||||
const [isRunnerCatalogLoading, setIsRunnerCatalogLoading] = useState(true);
|
||||
const [runnerCatalogError, setRunnerCatalogError] = useState(false);
|
||||
const [installingRunnerPluginId, setInstallingRunnerPluginId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [runnerInstallError, setRunnerInstallError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isCreatingBot, setIsCreatingBot] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSavingBot, setIsSavingBot] = useState(false);
|
||||
const [botSaved, setBotSaved] = useState(false);
|
||||
|
||||
const loadRunnerCatalog = useCallback(async () => {
|
||||
setIsRunnerCatalogLoading(true);
|
||||
setRunnerCatalogError(false);
|
||||
try {
|
||||
const cloudClient = await getCloudServiceClient();
|
||||
const [firstSearchResult, recommendationResult, installedResult] =
|
||||
await Promise.all([
|
||||
cloudClient.searchMarketplaceExtensions({
|
||||
query: '',
|
||||
page: 1,
|
||||
page_size: RUNNER_CATALOG_PAGE_SIZE,
|
||||
type_filter: 'plugin',
|
||||
component_filter: RUNNER_COMPONENT_FILTER,
|
||||
}),
|
||||
cloudClient.getRecommendationLists().catch(() => ({ lists: [] })),
|
||||
httpClient.getPlugins().catch(() => ({ plugins: [] })),
|
||||
]);
|
||||
|
||||
const remainingPageCount = Math.max(
|
||||
0,
|
||||
Math.ceil((firstSearchResult.total || 0) / RUNNER_CATALOG_PAGE_SIZE) -
|
||||
1,
|
||||
);
|
||||
const remainingResults = await Promise.all(
|
||||
Array.from({ length: remainingPageCount }, (_, index) =>
|
||||
cloudClient.searchMarketplaceExtensions({
|
||||
query: '',
|
||||
page: index + 2,
|
||||
page_size: RUNNER_CATALOG_PAGE_SIZE,
|
||||
type_filter: 'plugin',
|
||||
component_filter: RUNNER_COMPONENT_FILTER,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const catalogPlugins = [
|
||||
...(firstSearchResult.plugins || []),
|
||||
...remainingResults.flatMap((result) => result.plugins || []),
|
||||
];
|
||||
|
||||
const recommendationOrder = new Map<string, number>();
|
||||
let nextOrder = 0;
|
||||
for (const list of recommendationResult.lists || []) {
|
||||
for (const plugin of list.plugins || []) {
|
||||
if (!plugin.components?.[RUNNER_COMPONENT_FILTER]) continue;
|
||||
const id = marketplacePluginId(plugin);
|
||||
if (!recommendationOrder.has(id)) {
|
||||
recommendationOrder.set(id, nextOrder);
|
||||
nextOrder += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runners = catalogPlugins
|
||||
.filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER])
|
||||
.sort((left, right) => {
|
||||
const leftOrder = recommendationOrder.get(marketplacePluginId(left));
|
||||
const rightOrder = recommendationOrder.get(
|
||||
marketplacePluginId(right),
|
||||
);
|
||||
if (leftOrder !== undefined && rightOrder !== undefined) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
if (leftOrder !== undefined) return -1;
|
||||
if (rightOrder !== undefined) return 1;
|
||||
return right.install_count - left.install_count;
|
||||
});
|
||||
|
||||
setMarketplaceRunners(runners);
|
||||
setInstalledPluginIds(
|
||||
installedResult.plugins.map((plugin) => {
|
||||
const metadata = plugin.manifest.manifest.metadata;
|
||||
return `${metadata.author ?? ''}/${metadata.name}`;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to load AgentRunner catalog', error);
|
||||
setRunnerCatalogError(true);
|
||||
} finally {
|
||||
setIsRunnerCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRunnerCatalog();
|
||||
}, [loadRunnerCatalog]);
|
||||
|
||||
// ---- Helper: persist wizard progress to backend (fire-and-forget) ----
|
||||
const saveProgress = useCallback(
|
||||
(overrides: Partial<WizardProgress> = {}) => {
|
||||
@@ -366,9 +492,9 @@ export default function WizardPage() {
|
||||
|
||||
// ---- Runner selection with progress saving ----
|
||||
const handleSelectRunner = useCallback(
|
||||
(runner: string) => {
|
||||
(runner: string, configTab: PipelineConfigTab | null = aiConfigTab) => {
|
||||
setSelectedRunner(runner);
|
||||
const configStage = aiConfigTab?.stages.find((s) => s.name === runner);
|
||||
const configStage = configTab?.stages.find((s) => s.name === runner);
|
||||
const defaults = configStage ? getDefaultValues(configStage.config) : {};
|
||||
const promptKey = selectedScenario
|
||||
? WIZARD_SCENARIO_PROMPT_KEYS[selectedScenario]
|
||||
@@ -385,6 +511,90 @@ export default function WizardPage() {
|
||||
[aiConfigTab, saveProgress, selectedScenario, t],
|
||||
);
|
||||
|
||||
const handleInstallRunner = useCallback(
|
||||
async (plugin: PluginV4) => {
|
||||
const pluginId = marketplacePluginId(plugin);
|
||||
setInstallingRunnerPluginId(pluginId);
|
||||
setRunnerInstallError(null);
|
||||
|
||||
try {
|
||||
if (!plugin.latest_version) {
|
||||
throw new Error(t('wizard.aiEngine.versionUnavailable'));
|
||||
}
|
||||
|
||||
const { task_id: taskId } =
|
||||
await httpClient.installPluginFromMarketplace(
|
||||
plugin.author,
|
||||
plugin.name,
|
||||
plugin.latest_version,
|
||||
);
|
||||
const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS;
|
||||
let installCompleted = false;
|
||||
while (Date.now() < installDeadline) {
|
||||
const task = await httpClient.getAsyncTask(taskId);
|
||||
if (task.runtime.done) {
|
||||
if (task.runtime.exception) {
|
||||
throw new Error(task.runtime.exception);
|
||||
}
|
||||
installCompleted = true;
|
||||
break;
|
||||
}
|
||||
await wait(1000);
|
||||
}
|
||||
if (!installCompleted) {
|
||||
throw new Error(t('wizard.aiEngine.installTimeout'));
|
||||
}
|
||||
|
||||
const registrationDeadline =
|
||||
Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS;
|
||||
const prefix = runnerPluginPrefix(plugin);
|
||||
while (Date.now() < registrationDeadline) {
|
||||
const metadata = await httpClient.getGeneralPipelineMetadata();
|
||||
const nextAiTab =
|
||||
metadata.configs.find((config) => config.name === 'ai') ?? null;
|
||||
const nextRunnerStage = nextAiTab?.stages.find(
|
||||
(stage) => stage.name === 'runner',
|
||||
);
|
||||
const nextRunnerOptions =
|
||||
nextRunnerStage?.config.find((item) => item.name === 'id')
|
||||
?.options ?? [];
|
||||
const pluginRunnerOptions = nextRunnerOptions.filter((option) =>
|
||||
option.name.startsWith(prefix),
|
||||
);
|
||||
const preferredRunner =
|
||||
pluginRunnerOptions.find((option) =>
|
||||
option.name.endsWith('/default'),
|
||||
) ?? pluginRunnerOptions[0];
|
||||
|
||||
if (nextAiTab && preferredRunner) {
|
||||
setAiConfigTab(nextAiTab);
|
||||
setInstalledPluginIds((current) =>
|
||||
current.includes(pluginId) ? current : [...current, pluginId],
|
||||
);
|
||||
handleSelectRunner(preferredRunner.name, nextAiTab);
|
||||
toast.success(
|
||||
t('wizard.aiEngine.installSuccess', {
|
||||
runner: extractI18nObject(plugin.label),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await wait(1000);
|
||||
}
|
||||
|
||||
throw new Error(t('wizard.aiEngine.registrationTimeout'));
|
||||
} catch (error) {
|
||||
const message =
|
||||
getErrorMessage(error) || t('wizard.aiEngine.installFailed');
|
||||
setRunnerInstallError(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setInstallingRunnerPluginId(null);
|
||||
}
|
||||
},
|
||||
[handleSelectRunner, t],
|
||||
);
|
||||
|
||||
// ---- Navigation helpers ----
|
||||
|
||||
const canProceed = useCallback((): boolean => {
|
||||
@@ -851,8 +1061,16 @@ export default function WizardPage() {
|
||||
{currentStep === 2 && (
|
||||
<StepAIEngine
|
||||
runnerOptions={runnerOptions}
|
||||
marketplaceRunners={marketplaceRunners}
|
||||
installedPluginIds={installedPluginIds}
|
||||
isRunnerCatalogLoading={isRunnerCatalogLoading}
|
||||
runnerCatalogError={runnerCatalogError}
|
||||
installingRunnerPluginId={installingRunnerPluginId}
|
||||
runnerInstallError={runnerInstallError}
|
||||
selected={selectedRunner}
|
||||
onSelect={handleSelectRunner}
|
||||
onInstall={handleInstallRunner}
|
||||
onRetryCatalog={loadRunnerCatalog}
|
||||
isLocalAccount={isLocalAccount}
|
||||
onSpaceAuth={handleSpaceAuth}
|
||||
runnerConfigItems={selectedRunnerConfigItems}
|
||||
@@ -1359,8 +1577,16 @@ function StepBotConfig({
|
||||
|
||||
function StepAIEngine({
|
||||
runnerOptions,
|
||||
marketplaceRunners,
|
||||
installedPluginIds,
|
||||
isRunnerCatalogLoading,
|
||||
runnerCatalogError,
|
||||
installingRunnerPluginId,
|
||||
runnerInstallError,
|
||||
selected,
|
||||
onSelect,
|
||||
onInstall,
|
||||
onRetryCatalog,
|
||||
isLocalAccount,
|
||||
onSpaceAuth,
|
||||
runnerConfigItems,
|
||||
@@ -1368,8 +1594,16 @@ function StepAIEngine({
|
||||
onRunnerConfigChange,
|
||||
}: {
|
||||
runnerOptions: { name: string; label: { en_US: string; zh_Hans: string } }[];
|
||||
marketplaceRunners: PluginV4[];
|
||||
installedPluginIds: string[];
|
||||
isRunnerCatalogLoading: boolean;
|
||||
runnerCatalogError: boolean;
|
||||
installingRunnerPluginId: string | null;
|
||||
runnerInstallError: string | null;
|
||||
selected: string | null;
|
||||
onSelect: (name: string) => void;
|
||||
onInstall: (plugin: PluginV4) => void;
|
||||
onRetryCatalog: () => void;
|
||||
isLocalAccount: boolean;
|
||||
onSpaceAuth: () => void;
|
||||
runnerConfigItems: IDynamicFormItemSchema[];
|
||||
@@ -1391,6 +1625,20 @@ function StepAIEngine({
|
||||
return r ? extractI18nObject(r.label) : (selected ?? '');
|
||||
}, [runnerOptions, selected]);
|
||||
|
||||
const marketplaceRunnerIds = useMemo(
|
||||
() => new Set(marketplaceRunners.map(marketplacePluginId)),
|
||||
[marketplaceRunners],
|
||||
);
|
||||
const standaloneRunnerOptions = useMemo(
|
||||
() =>
|
||||
runnerOptions.filter((option) => {
|
||||
if (!option.name.startsWith('plugin:')) return true;
|
||||
const pluginId = option.name.slice('plugin:'.length).split('/');
|
||||
return !marketplaceRunnerIds.has(`${pluginId[0]}/${pluginId[1]}`);
|
||||
}),
|
||||
[marketplaceRunnerIds, runnerOptions],
|
||||
);
|
||||
|
||||
// Before any runner is selected: centered grid layout
|
||||
if (!selected) {
|
||||
return (
|
||||
@@ -1403,11 +1651,136 @@ function StepAIEngine({
|
||||
{t('wizard.aiEngine.description')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{runnerOptions.map((opt) => (
|
||||
{runnerCatalogError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CircleAlert className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('wizard.aiEngine.catalogUnavailable')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('wizard.aiEngine.catalogUnavailableDescription')}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
onClick={onRetryCatalog}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runnerInstallError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
{runnerInstallError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunnerCatalogLoading && marketplaceRunners.length === 0 && (
|
||||
<div className="flex min-h-32 items-center justify-center rounded-md border border-dashed">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('wizard.aiEngine.loadingCatalog')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{marketplaceRunners.map((plugin) => {
|
||||
const pluginId = marketplacePluginId(plugin);
|
||||
const prefix = runnerPluginPrefix(plugin);
|
||||
const registeredOptions = runnerOptions.filter((option) =>
|
||||
option.name.startsWith(prefix),
|
||||
);
|
||||
const preferredOption =
|
||||
registeredOptions.find((option) =>
|
||||
option.name.endsWith('/default'),
|
||||
) ?? registeredOptions[0];
|
||||
const isInstalled = installedPluginIds.includes(pluginId);
|
||||
const isInstalling = installingRunnerPluginId === pluginId;
|
||||
const iconUrl =
|
||||
getCloudServiceClientSync().resolveMarketplaceIconURL(
|
||||
plugin.type,
|
||||
plugin.author,
|
||||
plugin.name,
|
||||
plugin.icon,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={pluginId} className="flex min-h-52 flex-col">
|
||||
<CardHeader className="flex flex-row items-start gap-3 pb-3">
|
||||
<img
|
||||
src={iconUrl}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-md border bg-muted object-cover"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base">
|
||||
{extractI18nObject(plugin.label) || plugin.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs font-mono">
|
||||
{plugin.author}/{plugin.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
<p className="line-clamp-3 text-sm text-muted-foreground">
|
||||
{extractI18nObject(plugin.description)}
|
||||
</p>
|
||||
<div className="mt-auto pt-4">
|
||||
{preferredOption ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => onSelect(preferredOption.name)}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{t('wizard.aiEngine.useInstalled')}
|
||||
</Button>
|
||||
) : isInstalled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="w-full"
|
||||
>
|
||||
<CircleAlert className="size-4" />
|
||||
{t('wizard.aiEngine.installedUnavailable')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={installingRunnerPluginId !== null}
|
||||
onClick={() => onInstall(plugin)}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
{isInstalling
|
||||
? t('wizard.aiEngine.installing')
|
||||
: t('wizard.aiEngine.installAndContinue')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{standaloneRunnerOptions.map((opt) => (
|
||||
<Card
|
||||
key={opt.name}
|
||||
className="cursor-pointer transition-all hover:shadow-md hover:border-primary/50"
|
||||
className="min-h-40 cursor-pointer transition-all hover:border-primary/50 hover:shadow-md"
|
||||
onClick={() => onSelect(opt.name)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center gap-3">
|
||||
@@ -1423,6 +1796,29 @@ function StepAIEngine({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isRunnerCatalogLoading &&
|
||||
marketplaceRunners.length === 0 &&
|
||||
standaloneRunnerOptions.length === 0 &&
|
||||
!runnerCatalogError && (
|
||||
<div className="rounded-md border border-dashed p-6 text-center">
|
||||
<p className="font-medium">
|
||||
{t('wizard.aiEngine.noMarketplaceRunners')}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('wizard.aiEngine.noMarketplaceRunnersDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to="/home/extensions?type=plugin&component=AgentRunner">
|
||||
{t('wizard.aiEngine.browseRunners')}
|
||||
<ExternalLink className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1484,7 +1880,7 @@ function StepAIEngine({
|
||||
})}
|
||||
|
||||
{/* Space promotion banner */}
|
||||
{selected === 'plugin:langbot/local-agent/default' &&
|
||||
{selected === 'plugin:langbot-team/LocalAgent/default' &&
|
||||
isLocalAccount && (
|
||||
<div className="animate-in fade-in slide-in-from-left-2 duration-300">
|
||||
<div className="relative rounded-lg p-[2px] bg-gradient-to-r from-purple-500 via-pink-500 to-orange-500">
|
||||
|
||||
Reference in New Issue
Block a user