feat(agent-runner): add plugin runner host integration

This commit is contained in:
huanghuoguoguo
2026-06-20 10:18:52 +08:00
parent acfac42107
commit 2e5244fe93
129 changed files with 26980 additions and 6209 deletions
@@ -1,6 +1,7 @@
import {
IDynamicFormItemSchema,
SYSTEM_FIELD_PREFIX,
DynamicFormItemType,
} from '@/app/infra/entities/form/dynamic';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -32,6 +33,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { parseDynamicFormItemType } from './DynamicFormItemConfig';
/**
* Resolve the value referenced by a `show_if.field` string.
@@ -290,6 +292,13 @@ function DisabledTooltipIcon({ text }: { text: string }) {
);
}
/**
* Normalize plugin manifest type names to frontend-compatible types
*/
function normalizeItemType(type: string): DynamicFormItemType {
return parseDynamicFormItemType(type);
}
export default function DynamicFormComponent({
itemConfigList,
onSubmit,
@@ -372,8 +381,11 @@ export default function DynamicFormComponent({
const formSchema = z.object(
editableItems.reduce(
(acc, item) => {
// Normalize type to handle plugin manifest type names
const normalizedType = normalizeItemType(item.type);
let fieldSchema;
switch (item.type) {
switch (normalizedType) {
case 'integer':
fieldSchema = z.number();
break;
@@ -427,6 +439,9 @@ export default function DynamicFormComponent({
}),
);
break;
case 'text':
fieldSchema = z.string();
break;
default:
fieldSchema = z.string();
}
@@ -579,7 +594,14 @@ export default function DynamicFormComponent({
}}
/>
{itemConfigList.map((config) => {
{itemConfigList.map((config, index) => {
// Create a normalized config with type converted to frontend format
const normalizedConfig = {
...config,
type: normalizeItemType(config.type),
};
const fieldKey = config.id || config.name || `field-${index}`;
if (config.show_if) {
const dependValue = resolveShowIfValue(
config.show_if.field,
@@ -674,7 +696,7 @@ export default function DynamicFormComponent({
}
// Webhook URL fields are display-only; render outside of form binding
if (config.type === 'webhook-url') {
if (normalizedConfig.type === 'webhook-url') {
const webhookUrl = (systemContext?.webhook_url as string) || '';
const extraWebhookUrl =
(systemContext?.extra_webhook_url as string) || '';
@@ -683,7 +705,7 @@ export default function DynamicFormComponent({
return (
<WebhookUrlField
key={config.id}
key={fieldKey}
label={extractI18nObject(config.label)}
description={
config.description
@@ -696,7 +718,7 @@ export default function DynamicFormComponent({
);
}
if (config.type === 'embed-code') {
if (normalizedConfig.type === 'embed-code') {
const botUuid = (systemContext?.bot_uuid as string) || '';
if (!botUuid) return null;
@@ -714,7 +736,7 @@ export default function DynamicFormComponent({
return (
<EmbedCodeField
key={config.id}
key={fieldKey}
label={extractI18nObject(config.label)}
description={
config.description
@@ -729,7 +751,7 @@ export default function DynamicFormComponent({
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
if (config.type === 'qr-code-login') {
return (
<FormItem key={config.id}>
<FormItem key={fieldKey}>
<div
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
style={{
@@ -787,10 +809,10 @@ export default function DynamicFormComponent({
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
if (normalizedConfig.type === 'boolean') {
return (
<FormField
key={config.id}
key={fieldKey}
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
@@ -814,7 +836,7 @@ export default function DynamicFormComponent({
</div>
<FormControl>
<DynamicFormItemComponent
config={config}
config={normalizedConfig}
field={field}
onFileUploaded={onFileUploaded}
/>
@@ -829,7 +851,7 @@ export default function DynamicFormComponent({
return (
<FormField
key={config.id}
key={fieldKey}
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
@@ -851,7 +873,7 @@ export default function DynamicFormComponent({
)}
>
<DynamicFormItemComponent
config={config}
config={normalizedConfig}
field={field}
onFileUploaded={onFileUploaded}
/>
@@ -251,11 +251,13 @@ export default function DynamicFormItemComponent({
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
case DynamicFormItemType.NUMBER:
return (
<Input
type="number"
className="w-full max-w-xs"
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
);
@@ -264,7 +266,11 @@ export default function DynamicFormItemComponent({
if (config.options && config.options.length > 0) {
return (
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<Input className="min-w-0 flex-1" {...field} />
<Input
className="min-w-0 flex-1"
{...field}
value={field.value ?? ''}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -295,18 +301,37 @@ export default function DynamicFormItemComponent({
</div>
);
}
return <Input className="w-full max-w-md" {...field} />;
return (
<Input
className="w-full max-w-md"
{...field}
value={field.value ?? ''}
/>
);
case DynamicFormItemType.TEXT:
return (
<Textarea
{...field}
value={field.value ?? ''}
className="min-h-[120px] w-full max-w-full resize-y overflow-x-hidden break-all"
/>
);
case DynamicFormItemType.JSON:
return (
<Textarea
{...field}
value={field.value ?? ''}
className="min-h-[200px] font-mono text-sm"
placeholder='{"key": "value"}'
/>
);
case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
return (
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
);
case DynamicFormItemType.STRING_ARRAY:
return (
@@ -1428,7 +1453,7 @@ export default function DynamicFormItemComponent({
{/* 内容输入 */}
<Textarea
className="min-h-20 w-full min-w-0 flex-1 resize-y overflow-x-hidden break-all sm:w-[300px]"
value={item.content}
value={item.content ?? ''}
onChange={(e) => {
const newValue = [...(field.value ?? promptItems)];
newValue[index] = {
@@ -42,6 +42,18 @@ export function isDynamicFormItemType(
}
export function parseDynamicFormItemType(value: string): DynamicFormItemType {
const typeMap: Record<string, DynamicFormItemType> = {
[DynamicFormItemType.SELECT_LLM_MODEL]:
DynamicFormItemType.LLM_MODEL_SELECTOR,
[DynamicFormItemType.SELECT_KNOWLEDGE_BASES]:
DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR,
[DynamicFormItemType.NUMBER]: DynamicFormItemType.FLOAT,
[DynamicFormItemType.JSON]: DynamicFormItemType.TEXT,
};
if (value in typeMap) {
return typeMap[value];
}
return isDynamicFormItemType(value) ? value : DynamicFormItemType.UNKNOWN;
}
@@ -6,7 +6,6 @@ import {
PipelineConfigStage,
} from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { systemInfo } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
@@ -170,6 +169,8 @@ export default function PipelineFormComponent({
resolver: zodResolver(formSchema),
defaultValues: {
basic: {
name: '',
description: '',
emoji: '⚙️',
},
ai: {},
@@ -219,10 +220,11 @@ export default function PipelineFormComponent({
.getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => {
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
const loadedValues = {
basic: {
name: resp.pipeline.name,
description: resp.pipeline.description,
name: resp.pipeline.name ?? '',
description: resp.pipeline.description ?? '',
emoji: resp.pipeline.emoji || '⚙️',
},
ai: resp.pipeline.config.ai,
@@ -235,7 +237,7 @@ export default function PipelineFormComponent({
initializedStagesRef.current.clear();
});
}
}, []);
}, [form, isEditMode, pipelineId]);
useEffect(() => {
if (!isEditMode) {
@@ -308,7 +310,7 @@ export default function PipelineFormComponent({
});
}
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
// Called from DynamicFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
// However, if the form is already dirty (the user has made real changes),
@@ -323,7 +325,7 @@ export default function PipelineFormComponent({
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues =
(form.getValues(formName) as Record<string, any>) || {};
(form.getValues(formName) as Record<string, unknown>) || {};
form.setValue(formName, {
...currentValues,
[stageName]: values,
@@ -342,14 +344,47 @@ export default function PipelineFormComponent({
}
}
function handleRunnerConfigEmit(stageName: string, values: object) {
const stageKey = `ai.runner_config.${stageName}`;
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentRunnerConfigs =
(form.getValues('ai.runner_config') as Record<string, unknown>) || {};
form.setValue('ai.runner_config', {
...currentRunnerConfigs,
[stageName]: values,
});
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
const currentSnapshot = JSON.stringify(form.getValues());
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
savedSnapshotRef.current = currentSnapshot;
}
}
}
function renderDynamicForms(
stage: PipelineConfigStage,
formName: keyof FormValues,
) {
const forcedBoxTemplate =
systemInfo.limitation?.force_box_session_id_template || '';
const boxScopeForced = !!forcedBoxTemplate;
const isLocalAgentRunner =
stage.name === 'local-agent' ||
stage.name === 'plugin:langbot/local-agent/default';
const stageSystemContext = isLocalAgentRunner
? {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
}
: undefined;
// Special handling for AI config section
if (formName === 'ai') {
// Get the currently selected runner
const currentRunner = form.watch('ai.runner.runner');
const runnerConfig = (form.watch('ai.runner') as any) || {};
const currentRunner = runnerConfig.id;
// If this is the runner selector stage, render it directly
if (stage.name === 'runner') {
@@ -367,8 +402,9 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
(form.watch(formName) as Record<string, unknown>)?.[
stage.name
] || {}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
@@ -384,8 +420,24 @@ export default function PipelineFormComponent({
return null;
}
// For n8n-service-api config, use N8nAuthFormComponent for form linkage
if (stage.name === 'n8n-service-api') {
// For plugin runner configs, store in ai.runner_config[runnerId]
const isPluginRunner =
currentRunner && currentRunner.startsWith('plugin:');
const stageSystemContext =
stage.name === 'plugin:langbot/local-agent/default'
? { box_available: boxAvailable }
: undefined;
if (isPluginRunner) {
const runnerConfigs = (form.watch('ai.runner_config') as any) || {};
const stageInitialValues = runnerConfigs[stage.name] || {};
const effectiveInitialValues =
isLocalAgentRunner && boxScopeForced
? {
...stageInitialValues,
'box-session-id-template': forcedBoxTemplate,
}
: stageInitialValues;
return (
<Card key={stage.name}>
<CardHeader>
@@ -397,15 +449,13 @@ export default function PipelineFormComponent({
)}
</CardHeader>
<CardContent className="space-y-6">
<N8nAuthFormComponent
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
initialValues={effectiveInitialValues}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
handleRunnerConfigEmit(stage.name, values);
}}
systemContext={stageSystemContext}
/>
</CardContent>
</Card>
@@ -418,23 +468,6 @@ export default function PipelineFormComponent({
// opt-in via ``disable_if`` + ``disabled_tooltip`` rather than every page
// hard-coding a banner. Field-level gating keeps unrelated fields
// untouched.
//
// ``box_scope_editable`` folds the two reasons the Sandbox Scope selector
// can be locked into a single flag the yaml ``disable_if`` consumes:
// 1. Box sandbox is unavailable, or
// 2. the deployment pins all pipelines to a fixed scope via
// ``system.limitation.force_box_session_id_template`` (SaaS).
const forcedBoxTemplate =
systemInfo.limitation?.force_box_session_id_template || '';
const boxScopeForced = !!forcedBoxTemplate;
const stageSystemContext =
stage.name === 'local-agent'
? {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
}
: undefined;
// When the deployment pins every pipeline to a fixed sandbox scope (SaaS
// ``force_box_session_id_template``), the Sandbox Scope selector is locked.
// The runtime already overrides the scope on every exec, but the stored
@@ -446,7 +479,7 @@ export default function PipelineFormComponent({
const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
const effectiveInitialValues =
stage.name === 'local-agent' && boxScopeForced
isLocalAgentRunner && boxScopeForced
? {
...stageInitialValues,
'box-session-id-template': forcedBoxTemplate,
@@ -580,7 +613,7 @@ export default function PipelineFormComponent({
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
@@ -611,7 +644,7 @@ export default function PipelineFormComponent({
<FormItem>
<FormLabel>{t('common.description')}</FormLabel>
<FormControl>
<Input {...field} />
<Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
+39 -6
View File
@@ -1,6 +1,6 @@
import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/components/providers/theme-provider';
@@ -24,7 +24,10 @@ export default function PluginPagesPage() {
const [searchParams] = useSearchParams();
const id = searchParams.get('id');
const { t } = useTranslation();
const { setDetailEntityName, pluginPages } = useSidebarData();
const { setDetailEntityName, pluginPages, refreshPlugins } = useSidebarData();
const [lookupCompleteForId, setLookupCompleteForId] = useState<string | null>(
null,
);
// Find the matching page for breadcrumb
const page = pluginPages.find((p) => p.id === id);
@@ -34,6 +37,18 @@ export default function PluginPagesPage() {
return () => setDetailEntityName(null);
}, [page, id, setDetailEntityName]);
useEffect(() => {
if (!id || page) return;
let cancelled = false;
setLookupCompleteForId(null);
refreshPlugins().finally(() => {
if (!cancelled) setLookupCompleteForId(id);
});
return () => {
cancelled = true;
};
}, [id, page, refreshPlugins]);
if (!id) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
@@ -54,9 +69,23 @@ export default function PluginPagesPage() {
const author = parts[0];
const pluginName = parts[1];
// Use the asset path from the page manifest, not the page ID
const assetPath = page?.path ?? parts.slice(2).join('/');
const pageId = parts.slice(2).join('/');
if (!page) {
if (lookupCompleteForId === id) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('pluginPages.invalidPage')}
</div>
);
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
);
}
const assetPath = page.path;
const pageId = page.pageId;
return (
<PluginPageIframe
@@ -84,7 +113,11 @@ function PluginPageIframe({
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
const assetUrl = useMemo(() => {
const url = httpClient.getPluginAssetURL(author, pluginName, pagePath);
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}_lb_page_v=${Date.now()}`;
}, [author, pluginName, pagePath]);
// Send context (theme + language) to iframe
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
@@ -70,6 +70,11 @@ export enum DynamicFormItemType {
WEBHOOK_URL = 'webhook-url',
EMBED_CODE = 'embed-code',
QR_CODE_LOGIN = 'qr-code-login',
// Plugin manifest type aliases for compatibility
SELECT_LLM_MODEL = 'select-llm-model',
SELECT_KNOWLEDGE_BASES = 'select-knowledge-bases',
NUMBER = 'number',
JSON = 'json',
}
export interface IFileConfig {
+49 -25
View File
@@ -86,7 +86,6 @@ export default function WizardPage() {
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(null);
const [selectedRunner, setSelectedRunner] = useState<string | null>(null);
const [botName, setBotName] = useState('');
const [botDescription, _setBotDescription] = useState('');
const [adapterConfig, setAdapterConfig] = useState<Record<string, unknown>>(
{},
@@ -202,7 +201,7 @@ export default function WizardPage() {
const runnerOptions = useMemo(() => {
if (!runnerStage) return [];
const runnerField = runnerStage.config.find((c) => c.name === 'runner');
const runnerField = runnerStage.config.find((c) => c.name === 'id');
return runnerField?.options ?? [];
}, [runnerStage]);
@@ -257,9 +256,11 @@ export default function WizardPage() {
const handleSelectRunner = useCallback(
(runner: string) => {
setSelectedRunner(runner);
const configStage = aiConfigTab?.stages.find((s) => s.name === runner);
setRunnerConfig(configStage ? getDefaultValues(configStage.config) : {});
saveProgress({ step: 2, selected_runner: runner });
},
[saveProgress],
[aiConfigTab, saveProgress],
);
// ---- Navigation helpers ----
@@ -427,14 +428,36 @@ export default function WizardPage() {
// (includes trigger, safety, ai, output sections).
// Then merge only the AI section with the wizard's runner config.
const createdPipeline = await httpClient.getPipeline(pipelineResp.uuid);
const fullConfig = createdPipeline.pipeline.config;
const fullConfig = createdPipeline.pipeline.config as unknown as Record<
string,
unknown
>;
const fullAiConfig =
fullConfig.ai && typeof fullConfig.ai === 'object'
? (fullConfig.ai as Record<string, unknown>)
: {};
const existingRunner =
fullAiConfig.runner && typeof fullAiConfig.runner === 'object'
? (fullAiConfig.runner as Record<string, unknown>)
: {};
const existingRunnerConfigs =
fullAiConfig.runner_config &&
typeof fullAiConfig.runner_config === 'object'
? (fullAiConfig.runner_config as Record<string, unknown>)
: {};
const mergedConfig = {
...fullConfig,
ai: {
...fullConfig.ai,
runner: { runner: selectedRunner },
[selectedRunner]: runnerConfig,
...fullAiConfig,
runner: {
...existingRunner,
id: selectedRunner,
},
runner_config: {
...existingRunnerConfigs,
[selectedRunner]: runnerConfig,
},
},
};
@@ -1113,26 +1136,27 @@ function StepAIEngine({
})}
{/* Space promotion banner */}
{selected === 'local-agent' && 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">
<div className="rounded-[calc(0.5rem-2px)] bg-background p-3 flex flex-col items-center gap-2 text-center">
<Sparkles className="w-6 h-6 text-purple-500 shrink-0" />
<p className="text-xs font-medium">
{t('wizard.spaceBanner.message')}
</p>
<Button
variant="outline"
size="sm"
onClick={onSpaceAuth}
className="w-full"
>
{t('wizard.spaceBanner.action')}
</Button>
{selected === 'plugin:langbot/local-agent/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">
<div className="rounded-[calc(0.5rem-2px)] bg-background p-3 flex flex-col items-center gap-2 text-center">
<Sparkles className="w-6 h-6 text-purple-500 shrink-0" />
<p className="text-xs font-medium">
{t('wizard.spaceBanner.message')}
</p>
<Button
variant="outline"
size="sm"
onClick={onSpaceAuth}
className="w-full"
>
{t('wizard.spaceBanner.action')}
</Button>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>