feat(agent-runner): enforce 4.x host-owned execution

This commit is contained in:
huanghuoguoguo
2026-07-12 20:36:32 +08:00
parent e6384aae5d
commit 99d9c227f9
171 changed files with 6958 additions and 5385 deletions
@@ -17,6 +17,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
const [agent, setAgent] = useState<Agent | null>(null);
const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
useEffect(() => {
if (isCreateMode) {
@@ -73,7 +74,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('agents.editAgent')}</h1>
<Button type="submit" form="agent-form" disabled={!formDirty}>
<Button
type="submit"
form="agent-form"
disabled={!formDirty || formSaving}
>
{t('common.save')}
</Button>
</div>
@@ -83,13 +88,13 @@ export default function AgentDetailContent({ id }: { id: string }) {
agentId={id}
onFinish={() => {
refreshPipelines();
setFormDirty(false);
}}
onDeleted={() => {
refreshPipelines();
navigate('/home/agents');
}}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
/>
</div>
</div>
@@ -62,6 +62,7 @@ interface AgentFormComponentProps {
onFinish: () => void;
onDeleted: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
}
interface SectionItem {
@@ -75,6 +76,7 @@ export default function AgentFormComponent({
onFinish,
onDeleted,
onDirtyChange,
onSavingChange,
}: AgentFormComponentProps) {
const { t } = useTranslation();
const [activeSection, setActiveSection] =
@@ -86,6 +88,8 @@ export default function AgentFormComponent({
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const isSavingRef = useRef(false);
const formSchema = z.object({
basic: z.object({
@@ -118,10 +122,10 @@ export default function AgentFormComponent({
const savedSnapshotRef = useRef('');
const initializedStagesRef = useRef<Set<string>>(new Set());
const watchedValues = form.watch();
const hasUnsavedChanges = useMemo(() => {
const hasUnsavedChanges = (() => {
if (!savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [watchedValues]);
})();
useEffect(() => {
onDirtyChange?.(hasUnsavedChanges);
@@ -388,6 +392,8 @@ export default function AgentFormComponent({
}
function handleSubmit(values: FormValues) {
if (isSavingRef.current) return;
const submittedSnapshot = JSON.stringify(values);
const runner = values.runner || {};
const agent: Partial<Agent> = {
name: values.basic.name,
@@ -404,16 +410,23 @@ export default function AgentFormComponent({
},
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.updateAgent(agentId, agent)
.then(() => {
const snapshotValues = form.getValues();
savedSnapshotRef.current = JSON.stringify(snapshotValues);
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('agents.saveSuccess'));
})
.catch((err) => {
toast.error(t('agents.saveError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
@@ -574,6 +587,7 @@ export default function AgentFormComponent({
type="button"
variant="destructive"
size="sm"
disabled={isSaving}
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
@@ -1561,7 +1561,11 @@ export default function EventBindingsEditor({
function toggleExpand(id: string) {
setExpandedIds((prev) => {
const s = new Set(prev);
s.has(id) ? s.delete(id) : s.add(id);
if (s.has(id)) {
s.delete(id);
} else {
s.add(id);
}
return s;
});
}
@@ -106,6 +106,25 @@ function getBoundPluginId(plugin: BoundPlugin) {
return `${plugin.author || ''}/${plugin.name}`;
}
function getGlobalExtensions(
plugins: AvailablePlugin[],
mcpServers: MCPServer[],
): PipelineExtensions {
return {
enable_all_plugins: true,
enable_all_mcp_servers: true,
enable_all_skills: true,
mcp_resource_agent_read_enabled: true,
bound_plugins: [],
available_plugins: plugins,
bound_mcp_servers: [],
available_mcp_servers: mcpServers,
bound_mcp_resources: [],
bound_skills: [],
available_skills: [],
};
}
function InfoTooltip({ label }: { label: string }) {
return (
<Tooltip>
@@ -348,19 +367,60 @@ export default function ToolResourceSelectors({
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
useEffect(() => {
let cancelled = false;
setTools([]);
setKnowledgeBases([]);
setExtensions(null);
if (mode !== 'resources') {
backendClient.getTools(pipelineId).then((resp) => setTools(resp.tools));
backendClient
.getTools(pipelineId)
.then((resp) => {
if (!cancelled) setTools(resp.tools);
})
.catch(() => {
if (!cancelled) setTools([]);
});
}
if (mode !== 'tools') {
backendClient
.getKnowledgeBases()
.then((resp) => setKnowledgeBases(resp.bases));
.then((resp) => {
if (!cancelled) setKnowledgeBases(resp.bases);
})
.catch(() => {
if (!cancelled) setKnowledgeBases([]);
});
}
if (pipelineId) {
backendClient
.getPipelineExtensions(pipelineId)
.then((resp) => setExtensions(resp));
.then((resp) => {
if (!cancelled) setExtensions(resp);
})
.catch(() => {
if (!cancelled) setExtensions(null);
});
} else {
Promise.all([
backendClient
.getPlugins()
.catch(() => ({ plugins: [] as AvailablePlugin[] })),
backendClient
.getMCPServers()
.catch(() => ({ servers: [] as MCPServer[] })),
]).then(([pluginResp, mcpResp]) => {
if (!cancelled) {
setExtensions(
getGlobalExtensions(pluginResp.plugins, mcpResp.servers),
);
}
});
}
return () => {
cancelled = true;
};
}, [mode, pipelineId]);
const enableAllTools = value['enable-all-tools'] !== false;
@@ -480,11 +540,6 @@ export default function ToolResourceSelectors({
],
);
const availableToolNames = useMemo(
() => new Set(availableTools.map((tool) => tool.name)),
[availableTools],
);
const resourceServers = useMemo(() => {
return scopedMCPServers.filter(
(server) =>
@@ -513,6 +568,9 @@ export default function ToolResourceSelectors({
? availableMCPResourceKeys.has(getMCPResourceKey(server, resource.uri))
: false;
});
const unavailableSelectedMCPResources = selectedMCPResources.filter(
(resource) => !scopedSelectedMCPResources.includes(resource),
);
const selectedTools = selectedToolNames
.map((name: string) => availableTools.find((tool) => tool.name === name))
@@ -524,10 +582,10 @@ export default function ToolResourceSelectors({
const sourceLabels = useMemo<Record<string, string>>(
() => ({
builtin: t('pipelines.localAgent.builtinTools'),
plugin: t('pipelines.localAgent.pluginTools'),
mcp: t('pipelines.localAgent.mcpTools'),
skill: t('pipelines.localAgent.skillTools'),
builtin: t('pipelines.agentRunner.builtinTools'),
plugin: t('pipelines.agentRunner.pluginTools'),
mcp: t('pipelines.agentRunner.mcpTools'),
skill: t('pipelines.agentRunner.skillTools'),
}),
[t],
);
@@ -546,11 +604,7 @@ export default function ToolResourceSelectors({
};
const handleConfirmTools = () => {
onChange({
tools: tempSelectedToolNames.filter((name) =>
availableToolNames.has(name),
),
});
onChange({ tools: tempSelectedToolNames });
setToolsDialogOpen(false);
};
@@ -580,7 +634,9 @@ export default function ToolResourceSelectors({
: scopedSelectedMCPResources.filter(
(item) => !isSameMCPResource(item, server, resource.uri),
);
onChange({ 'mcp-resources': next });
onChange({
'mcp-resources': [...unavailableSelectedMCPResources, ...next],
});
};
const isMCPResourceSelected = (server: MCPServer, uri: string) =>
@@ -597,25 +653,27 @@ export default function ToolResourceSelectors({
<div>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold">
{t('pipelines.localAgent.toolsTitle')}
{t('pipelines.agentRunner.toolsTitle')}
</h3>
<InfoTooltip
label={t('pipelines.localAgent.toolsScopeTooltip')}
/>
{pipelineId && (
<InfoTooltip
label={t('pipelines.agentRunner.toolsScopeTooltip')}
/>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{t('pipelines.localAgent.toolsDescription')}
{t('pipelines.agentRunner.toolsDescription')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Label
htmlFor="local-agent-enable-all-tools"
htmlFor="agent-runner-enable-all-tools"
className="cursor-pointer text-sm font-normal"
>
{t('pipelines.localAgent.enableAllTools')}
{t('pipelines.agentRunner.enableAllTools')}
</Label>
<Switch
id="local-agent-enable-all-tools"
id="agent-runner-enable-all-tools"
checked={enableAllTools}
onCheckedChange={handleToggleToolMode}
/>
@@ -625,13 +683,13 @@ export default function ToolResourceSelectors({
{enableAllTools ? (
<div className="flex h-24 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.localAgent.allToolsEnabled')}
{t('pipelines.agentRunner.allToolsEnabled')}
</p>
</div>
) : selectedTools.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.localAgent.noToolsSelected')}
{t('pipelines.agentRunner.noToolsSelected')}
</p>
</div>
) : (
@@ -701,16 +759,12 @@ export default function ToolResourceSelectors({
className="w-full"
disabled={enableAllTools}
onClick={() => {
setTempSelectedToolNames(
selectedToolNames.filter((name: string) =>
availableToolNames.has(name),
),
);
setTempSelectedToolNames(selectedToolNames);
setToolsDialogOpen(true);
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.localAgent.editTools')}
{t('pipelines.agentRunner.editTools')}
</Button>
</div>
)}
@@ -719,10 +773,10 @@ export default function ToolResourceSelectors({
<div className="space-y-4 rounded-lg border p-4">
<div>
<h3 className="text-sm font-semibold">
{t('pipelines.localAgent.resourcesTitle')}
{t('pipelines.agentRunner.resourcesTitle')}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t('pipelines.localAgent.resourcesDescription')}
{t('pipelines.agentRunner.resourcesDescription')}
</p>
</div>
@@ -731,7 +785,7 @@ export default function ToolResourceSelectors({
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t('pipelines.localAgent.knowledgeBases')}
{t('pipelines.agentRunner.knowledgeBases')}
</span>
</div>
<Button
@@ -801,24 +855,26 @@ export default function ToolResourceSelectors({
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t('pipelines.localAgent.mcpResources')}
{t('pipelines.agentRunner.mcpResources')}
</span>
<InfoTooltip
label={t('pipelines.localAgent.mcpResourcesScopeTooltip')}
/>
{pipelineId && (
<InfoTooltip
label={t('pipelines.agentRunner.mcpResourcesScopeTooltip')}
/>
)}
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="local-agent-mcp-resource-read"
htmlFor="agent-runner-mcp-resource-read"
className="cursor-pointer text-sm font-normal"
>
{t('pipelines.localAgent.enableMCPResourceRead')}
{t('pipelines.agentRunner.enableMCPResourceRead')}
</Label>
<InfoTooltip
label={t('pipelines.localAgent.mcpResourceReadTooltip')}
label={t('pipelines.agentRunner.mcpResourceReadTooltip')}
/>
<Switch
id="local-agent-mcp-resource-read"
id="agent-runner-mcp-resource-read"
checked={mcpResourceReadEnabled}
onCheckedChange={(checked) =>
onChange({ 'mcp-resource-agent-read-enabled': checked })
@@ -830,7 +886,7 @@ export default function ToolResourceSelectors({
{resourceServers.length === 0 ? (
<div className="flex h-20 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.localAgent.noMCPResourcesAvailable')}
{t('pipelines.agentRunner.noMCPResourcesAvailable')}
</p>
</div>
) : (
@@ -900,7 +956,9 @@ export default function ToolResourceSelectors({
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{t('pipelines.localAgent.selectTools')}</DialogTitle>
<DialogTitle>
{t('pipelines.agentRunner.selectTools')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 space-y-5 overflow-y-auto pr-2">
{availableToolGroups.map((sourceGroup) => {
@@ -910,15 +968,17 @@ export default function ToolResourceSelectors({
<span className="text-sm font-semibold">
{sourceGroup.label}
</span>
{sourceGroup.key === 'mcp' && (
{pipelineId && sourceGroup.key === 'mcp' && (
<InfoTooltip
label={t('pipelines.localAgent.mcpToolsScopeTooltip')}
label={t(
'pipelines.agentRunner.mcpToolsScopeTooltip',
)}
/>
)}
{sourceGroup.key === 'skill' && (
<InfoTooltip
label={t(
'pipelines.localAgent.skillToolsScopeTooltip',
'pipelines.agentRunner.skillToolsScopeTooltip',
)}
/>
)}
@@ -987,7 +1047,7 @@ export default function ToolResourceSelectors({
{availableToolGroups.length === 0 && (
<div className="flex h-24 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.localAgent.noToolsSelected')}
{t('pipelines.agentRunner.noToolsSelected')}
</p>
</div>
)}
@@ -1012,7 +1072,7 @@ export default function ToolResourceSelectors({
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>
{t('pipelines.localAgent.selectKnowledgeBases')}
{t('pipelines.agentRunner.selectKnowledgeBases')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 space-y-2 overflow-y-auto pr-2">
@@ -9,10 +9,6 @@ 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(() => {
@@ -205,49 +201,45 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
</div>
)}
{/* Query Variables Section - Only show for non-local-agent runners */}
{queryVariables &&
Object.keys(queryVariables).length > 0 &&
!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" />
{t('monitoring.queryVariables.title')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(queryVariables).map(([key, value]) => (
<div key={key} className="bg-background rounded p-2">
<div className="text-muted-foreground">{key}</div>
<div
className="font-medium text-foreground truncate"
title={
typeof value === 'string' ? value : JSON.stringify(value)
}
>
{value === null || value === undefined ? (
<span className="text-muted-foreground italic">null</span>
) : typeof value === 'string' ? (
value || (
<span className="text-muted-foreground italic">
empty
</span>
)
) : (
JSON.stringify(value)
)}
</div>
{/* Query Variables Section */}
{queryVariables && Object.keys(queryVariables).length > 0 && (
<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" />
{t('monitoring.queryVariables.title')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
{Object.entries(queryVariables).map(([key, value]) => (
<div key={key} className="bg-background rounded p-2">
<div className="text-muted-foreground">{key}</div>
<div
className="font-medium text-foreground truncate"
title={
typeof value === 'string' ? value : JSON.stringify(value)
}
>
{value === null || value === undefined ? (
<span className="text-muted-foreground italic">null</span>
) : typeof value === 'string' ? (
value || (
<span className="text-muted-foreground italic">
empty
</span>
)
) : (
JSON.stringify(value)
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* No data message */}
{(!details.llmCalls || details.llmCalls.length === 0) &&
(!details.errors || details.errors.length === 0) &&
(isLocalAgent ||
!queryVariables ||
Object.keys(queryVariables).length === 0) && (
(!queryVariables || Object.keys(queryVariables).length === 0) && (
<div className="text-sm text-muted-foreground text-center py-4">
{t('monitoring.messageDetails.noData')}
</div>
@@ -35,6 +35,7 @@ export default function PipelineDetailContent({
const [activeTab, setActiveTab] = useState('config');
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
function handleFinish() {
refreshPipelines();
@@ -53,7 +54,7 @@ export default function PipelineDetailContent({
<h1 className="text-xl font-semibold">
{t('pipelines.createPipeline')}
</h1>
<Button type="submit" form="pipeline-form">
<Button type="submit" form="pipeline-form" disabled={formSaving}>
{t('common.submit')}
</Button>
</div>
@@ -68,6 +69,7 @@ export default function PipelineDetailContent({
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
onDeletePipeline={() => {}}
onSavingChange={setFormSaving}
/>
</div>
</div>
@@ -89,7 +91,7 @@ export default function PipelineDetailContent({
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty}
disabled={!formDirty || formSaving}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')}
@@ -140,6 +142,7 @@ export default function PipelineDetailContent({
onDeletePipeline={handleDeletePipeline}
onCancel={() => navigate(routeBase)}
onDirtyChange={setFormDirty}
onSavingChange={setFormSaving}
/>
</TabsContent>
@@ -59,6 +59,7 @@ export default function PipelineFormComponent({
onDeletePipeline,
onCancel,
onDirtyChange,
onSavingChange,
}: {
pipelineId?: string;
isEditMode: boolean;
@@ -69,11 +70,14 @@ export default function PipelineFormComponent({
onDeletePipeline: () => void;
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
}) {
const { t } = useTranslation();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState(false);
const isSavingRef = useRef(false);
const formSchema = isEditMode
? z.object({
@@ -176,16 +180,20 @@ export default function PipelineFormComponent({
output: {},
},
});
const dynamicFormSystemContext = useMemo(
() => ({ pipeline_id: pipelineId }),
[pipelineId],
);
// Track unsaved changes by comparing current form values against a saved snapshot
const savedSnapshotRef = useRef<string>('');
// Track which dynamic form stages have completed their initial mount emission.
const initializedStagesRef = useRef<Set<string>>(new Set());
const watchedValues = form.watch();
const hasUnsavedChanges = useMemo(() => {
const hasUnsavedChanges = (() => {
if (!isEditMode || !savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]);
})();
// Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
// read the latest dirty state without stale closures.
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
@@ -257,12 +265,16 @@ export default function PipelineFormComponent({
}
function handleCreate(values: FormValues) {
if (isSavingRef.current) return;
const pipeline: Pipeline = {
config: {},
description: values.basic.description ?? '',
name: values.basic.name,
emoji: values.basic.emoji,
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.createPipeline(pipeline)
.then((resp) => {
@@ -272,10 +284,17 @@ export default function PipelineFormComponent({
})
.catch((err) => {
toast.error(t('pipelines.createError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
function handleModify(values: FormValues) {
if (isSavingRef.current) return;
const submittedSnapshot = JSON.stringify(values);
const realConfig = {
ai: values.ai,
trigger: values.trigger,
@@ -295,15 +314,23 @@ export default function PipelineFormComponent({
// uuid: pipelineId || '',
// is_default: false,
};
isSavingRef.current = true;
setIsSaving(true);
onSavingChange?.(true);
httpClient
.updatePipeline(pipelineId || '', pipeline)
.then(() => {
savedSnapshotRef.current = JSON.stringify(form.getValues());
savedSnapshotRef.current = submittedSnapshot;
onFinish();
toast.success(t('pipelines.saveSuccess'));
})
.catch((err) => {
toast.error(t('pipelines.saveError') + err.msg);
})
.finally(() => {
isSavingRef.current = false;
setIsSaving(false);
onSavingChange?.(false);
});
}
@@ -390,6 +417,7 @@ export default function PipelineFormComponent({
stage.name
] || {}
}
systemContext={dynamicFormSystemContext}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
@@ -424,6 +452,7 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={stageInitialValues}
systemContext={dynamicFormSystemContext}
onSubmit={(values) => {
handleRunnerConfigEmit(stage.name, values);
}}
@@ -451,6 +480,7 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={stageInitialValues}
systemContext={dynamicFormSystemContext}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
@@ -746,7 +776,7 @@ export default function PipelineFormComponent({
</Button>
)}
<Button type="submit" form="pipeline-form">
<Button type="submit" form="pipeline-form" disabled={isSaving}>
{isEditMode ? t('common.save') : t('common.submit')}
</Button>
</div>
+4 -5
View File
@@ -437,10 +437,6 @@ export interface SystemLimitation {
max_bots: number;
max_pipelines: number;
max_extensions: number;
/** When non-empty, every pipeline is forced to this Box sandbox-scope
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
force_box_session_id_template?: string;
}
export interface WizardProgress {
@@ -800,7 +796,10 @@ export interface ApiRespTools {
}
export interface ApiRespToolDetail {
tool: PluginTool;
tool: Omit<PluginTool, 'source' | 'source_id'> & {
source: NonNullable<PluginTool['source']>;
source_id: string | null;
};
}
// Skills
+8 -2
View File
@@ -982,8 +982,14 @@ export class BackendClient extends BaseHttpClient {
);
}
public getToolDetail(toolName: string): Promise<ApiRespToolDetail> {
return this.get(`/api/v1/tools/${toolName}`);
public getToolDetail(
toolName: string,
pipelineId?: string,
): Promise<ApiRespToolDetail> {
return this.get(
`/api/v1/tools/${encodeURIComponent(toolName)}`,
pipelineId ? { pipeline_uuid: pipelineId } : undefined,
);
}
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
-48
View File
@@ -25,7 +25,6 @@ import {
import { httpClient } from '@/app/infra/http/HttpClient';
import {
userInfo,
systemInfo,
initializeUserInfo,
initializeSystemInfo,
@@ -880,24 +879,6 @@ export default function WizardPage() {
saveProgress,
]);
// ---- Space auth redirect ----
const handleSpaceAuth = useCallback(async () => {
try {
const callbackUrl = `${window.location.origin}/auth/space/callback`;
const resp = await httpClient.getSpaceAuthorizeUrl(callbackUrl);
window.location.href = resp.authorize_url;
} catch (err) {
console.error('Failed to get space authorize URL', err);
toast.error(t('wizard.spaceAuthError'));
}
}, [t]);
// ---- Check if local account ----
// Re-evaluated after remote data fetch (when userInfo is populated)
const isLocalAccount =
!isLoading && (!userInfo || userInfo.account_type === 'local');
// ---- Skip handler ----
const [showSkipConfirm, setShowSkipConfirm] = useState(false);
const [isSkipping, setIsSkipping] = useState(false);
@@ -1063,8 +1044,6 @@ export default function WizardPage() {
onSelect={handleSelectRunner}
onInstall={handleInstallRunner}
onRetryCatalog={loadRunnerCatalog}
isLocalAccount={isLocalAccount}
onSpaceAuth={handleSpaceAuth}
runnerConfigItems={selectedRunnerConfigItems}
runnerConfigValues={runnerConfig}
onRunnerConfigChange={setRunnerConfig}
@@ -1579,8 +1558,6 @@ function StepAIEngine({
onSelect,
onInstall,
onRetryCatalog,
isLocalAccount,
onSpaceAuth,
runnerConfigItems,
runnerConfigValues,
onRunnerConfigChange,
@@ -1596,8 +1573,6 @@ function StepAIEngine({
onSelect: (name: string) => void;
onInstall: (plugin: PluginV4) => void;
onRetryCatalog: () => void;
isLocalAccount: boolean;
onSpaceAuth: () => void;
runnerConfigItems: IDynamicFormItemSchema[];
runnerConfigValues: Record<string, unknown>;
onRunnerConfigChange: (v: Record<string, unknown>) => void;
@@ -1870,29 +1845,6 @@ function StepAIEngine({
</Card>
);
})}
{/* Space promotion banner */}
{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">
<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>
+4 -9
View File
@@ -1220,14 +1220,14 @@ const enUS = {
selectSkills: 'Select Skills',
noSkillsAvailable: 'No skills available',
mcpServersScopeTooltip:
'This only controls which MCP servers are bound to the pipeline. Choose exact MCP tools and resources in AI Feature > Local Agent.',
'This only controls which MCP servers are bound to the pipeline. Choose exact MCP tools and resources in AI Feature > Agent Runner.',
enableAllMCPServersTooltip:
'When enabled, all configured and enabled MCP servers become candidates for MCP tools and resources in AI Feature.',
},
localAgent: {
agentRunner: {
toolsTitle: 'Tools',
toolsDescription:
'Select plugin, MCP, skill, and built-in tools available to this Local Agent.',
'Select plugin, MCP, skill, and built-in tools available to this Agent Runner.',
toolsScopeTooltip:
'MCP tools only come from MCP servers bound in Extensions. Bind another MCP server there to make its tools selectable here.',
enableAllTools: 'Enable all tools',
@@ -1245,7 +1245,7 @@ const enUS = {
selectTools: 'Select tools',
resourcesTitle: 'Resources',
resourcesDescription:
'Select MCP resources and knowledge bases available to this Local Agent.',
'Select MCP resources and knowledge bases available to this Agent Runner.',
knowledgeBases: 'Knowledge bases',
mcpResources: 'MCP resources',
mcpResourcesScopeTooltip:
@@ -2011,11 +2011,6 @@ const enUS = {
registrationTimeout:
'The extension installed, but its Runner did not register. Check the plugin runtime and retry.',
},
spaceBanner: {
message:
'Connect to LangBot Space for free trial model credits and zero-config instant setup!',
action: 'Authorize with Space',
},
config: {
botInfo: 'Bot Information',
botNamePlaceholder: 'Enter bot name',
+4 -9
View File
@@ -1047,14 +1047,14 @@ const esES = {
selectSkills: 'Seleccionar skills',
noSkillsAvailable: 'No hay skills disponibles',
mcpServersScopeTooltip:
'Aquí solo se controla qué servidores MCP se vinculan al Pipeline. Las herramientas y recursos MCP concretos se eligen en AI Feature > Local Agent.',
'Aquí solo se controla qué servidores MCP se vinculan al Pipeline. Las herramientas y recursos MCP concretos se eligen en AI Feature > Agent Runner.',
enableAllMCPServersTooltip:
'Al activarlo, todos los servidores MCP configurados y habilitados serán candidatos para herramientas y recursos MCP en AI Feature.',
},
localAgent: {
agentRunner: {
toolsTitle: 'Herramientas',
toolsDescription:
'Selecciona las herramientas de plugins, MCP e integradas disponibles para este Local Agent.',
'Selecciona las herramientas de plugins, MCP e integradas disponibles para este Agent Runner.',
toolsScopeTooltip:
'Las herramientas MCP solo provienen de servidores MCP vinculados en Extensiones. Vincula allí otro servidor para poder seleccionarlo aquí.',
enableAllTools: 'Activar todas las herramientas',
@@ -1072,7 +1072,7 @@ const esES = {
selectTools: 'Seleccionar herramientas',
resourcesTitle: 'Recursos',
resourcesDescription:
'Selecciona los recursos MCP y bases de conocimiento disponibles para este Local Agent.',
'Selecciona los recursos MCP y bases de conocimiento disponibles para este Agent Runner.',
knowledgeBases: 'Bases de conocimiento',
mcpResources: 'Recursos MCP',
mcpResourcesScopeTooltip:
@@ -1717,11 +1717,6 @@ const esES = {
description:
'Elige el motor de IA que impulsará la inteligencia de tu Bot.',
},
spaceBanner: {
message:
'¡Conéctate a LangBot Space para obtener créditos de prueba gratuitos y configuración instantánea sin esfuerzo!',
action: 'Autorizar con Space',
},
config: {
botInfo: 'Información del Bot',
botNamePlaceholder: 'Introduce el nombre del Bot',
+4 -9
View File
@@ -1232,14 +1232,14 @@ const jaJP = {
selectSkills: 'スキルを選択',
noSkillsAvailable: '利用可能なスキルがありません',
mcpServersScopeTooltip:
'ここでは、このパイプラインに紐付ける MCP サーバーだけを管理します。個別の MCP ツールとリソースは AI 機能の Local Agent で選択します。',
'ここでは、このパイプラインに紐付ける MCP サーバーだけを管理します。個別の MCP ツールとリソースは AI 機能の Agent Runner で選択します。',
enableAllMCPServersTooltip:
'有効にすると、設定済みで有効なすべての MCP サーバーが AI 機能の MCP ツールとリソース候補になります。',
},
localAgent: {
agentRunner: {
toolsTitle: 'ツール',
toolsDescription:
'この Local Agent が使用できるプラグイン、MCP、組み込みツールを選択します。',
'この Agent Runner が使用できるプラグイン、MCP、組み込みツールを選択します。',
toolsScopeTooltip:
'MCP ツールは拡張機能で紐付けられた MCP サーバーからのみ表示されます。追加するには先に拡張機能でサーバーを紐付けてください。',
enableAllTools: 'すべてのツールを有効化',
@@ -1257,7 +1257,7 @@ const jaJP = {
selectTools: 'ツールを選択',
resourcesTitle: 'リソース',
resourcesDescription:
'この Local Agent が読み取れる MCP リソースとナレッジベースを選択します。',
'この Agent Runner が読み取れる MCP リソースとナレッジベースを選択します。',
knowledgeBases: 'ナレッジベース',
mcpResources: 'MCP リソース',
mcpResourcesScopeTooltip:
@@ -1934,11 +1934,6 @@ const jaJP = {
registrationTimeout:
'拡張機能はインストールされましたが、Runner が登録されませんでした。プラグインランタイムを確認して再試行してください。',
},
spaceBanner: {
message:
'LangBot Spaceに接続して、無料トライアルモデルクレジットとゼロ設定の即時セットアップを入手!',
action: 'Spaceで認証',
},
config: {
botInfo: 'ボット情報',
botNamePlaceholder: 'ボット名を入力',
+4 -9
View File
@@ -1038,14 +1038,14 @@ const ruRU = {
selectSkills: 'Выбрать навыки',
noSkillsAvailable: 'Нет доступных навыков',
mcpServersScopeTooltip:
'Здесь задаётся только привязка MCP-серверов к конвейеру. Конкретные MCP-инструменты и ресурсы выбираются в AI Feature > Local Agent.',
'Здесь задаётся только привязка MCP-серверов к конвейеру. Конкретные MCP-инструменты и ресурсы выбираются в AI Feature > Agent Runner.',
enableAllMCPServersTooltip:
'Если включено, все настроенные и включённые MCP-серверы станут кандидатами для инструментов и ресурсов MCP в AI Feature.',
},
localAgent: {
agentRunner: {
toolsTitle: 'Инструменты',
toolsDescription:
'Выберите инструменты плагинов, MCP и встроенные инструменты для этого Local Agent.',
'Выберите инструменты плагинов, MCP и встроенные инструменты для этого Agent Runner.',
toolsScopeTooltip:
'MCP-инструменты берутся только из MCP-серверов, привязанных в Расширениях. Чтобы добавить источник, сначала привяжите там сервер.',
enableAllTools: 'Включить все инструменты',
@@ -1063,7 +1063,7 @@ const ruRU = {
selectTools: 'Выбрать инструменты',
resourcesTitle: 'Ресурсы',
resourcesDescription:
'Выберите MCP-ресурсы и базы знаний для этого Local Agent.',
'Выберите MCP-ресурсы и базы знаний для этого Agent Runner.',
knowledgeBases: 'Базы знаний',
mcpResources: 'MCP-ресурсы',
mcpResourcesScopeTooltip:
@@ -1687,11 +1687,6 @@ const ruRU = {
description:
'Выберите ИИ-движок, который будет управлять интеллектом вашего бота.',
},
spaceBanner: {
message:
'Подключитесь к LangBot Space для бесплатных пробных кредитов и мгновенной настройки!',
action: 'Авторизация через Space',
},
config: {
botInfo: 'Информация о боте',
botNamePlaceholder: 'Введите имя бота',
+4 -9
View File
@@ -1013,14 +1013,14 @@ const thTH = {
selectSkills: 'เลือกสกิล',
noSkillsAvailable: 'ไม่มีสกิลที่พร้อมใช้งาน',
mcpServersScopeTooltip:
'ส่วนนี้ใช้ควบคุมว่า Pipeline ผูกกับเซิร์ฟเวอร์ MCP ใดเท่านั้น ส่วนเครื่องมือและทรัพยากร MCP รายตัวให้เลือกใน AI Feature > Local Agent',
'ส่วนนี้ใช้ควบคุมว่า Pipeline ผูกกับเซิร์ฟเวอร์ MCP ใดเท่านั้น ส่วนเครื่องมือและทรัพยากร MCP รายตัวให้เลือกใน AI Feature > Agent Runner',
enableAllMCPServersTooltip:
'เมื่อเปิดใช้ เซิร์ฟเวอร์ MCP ที่ตั้งค่าและเปิดใช้งานทั้งหมดจะเป็นตัวเลือกสำหรับเครื่องมือและทรัพยากร MCP ใน AI Feature',
},
localAgent: {
agentRunner: {
toolsTitle: 'เครื่องมือ',
toolsDescription:
'เลือกเครื่องมือจากปลั๊กอิน MCP และเครื่องมือในตัวสำหรับ Local Agent นี้',
'เลือกเครื่องมือจากปลั๊กอิน MCP และเครื่องมือในตัวสำหรับ Agent Runner นี้',
toolsScopeTooltip:
'เครื่องมือ MCP จะแสดงจากเซิร์ฟเวอร์ MCP ที่ผูกไว้ในส่วนขยายเท่านั้น หากต้องการเพิ่มแหล่งเครื่องมือ ให้ไปผูกเซิร์ฟเวอร์ที่นั่นก่อน',
enableAllTools: 'เปิดใช้เครื่องมือทั้งหมด',
@@ -1038,7 +1038,7 @@ const thTH = {
selectTools: 'เลือกเครื่องมือ',
resourcesTitle: 'ทรัพยากร',
resourcesDescription:
'เลือกทรัพยากร MCP และคลังความรู้สำหรับ Local Agent นี้',
'เลือกทรัพยากร MCP และคลังความรู้สำหรับ Agent Runner นี้',
knowledgeBases: 'คลังความรู้',
mcpResources: 'ทรัพยากร MCP',
mcpResourcesScopeTooltip:
@@ -1652,11 +1652,6 @@ const thTH = {
title: 'เลือกเครื่องมือ AI',
description: 'เลือกเครื่องมือ AI ที่จะขับเคลื่อนความฉลาดของ Bot',
},
spaceBanner: {
message:
'เชื่อมต่อกับ LangBot Space เพื่อรับเครดิตทดลองใช้โมเดลฟรีและตั้งค่าทันทีโดยไม่ต้องกำหนดค่า!',
action: 'ยืนยันสิทธิ์กับ Space',
},
config: {
botInfo: 'ข้อมูล Bot',
botNamePlaceholder: 'กรอกชื่อ Bot',
+4 -9
View File
@@ -1030,14 +1030,14 @@ const viVN = {
selectSkills: 'Chọn kỹ năng',
noSkillsAvailable: 'Không có kỹ năng khả dụng',
mcpServersScopeTooltip:
'Tại đây chỉ kiểm soát máy chủ MCP được liên kết với Pipeline. Công cụ và tài nguyên MCP cụ thể được chọn trong AI Feature > Local Agent.',
'Tại đây chỉ kiểm soát máy chủ MCP được liên kết với Pipeline. Công cụ và tài nguyên MCP cụ thể được chọn trong AI Feature > Agent Runner.',
enableAllMCPServersTooltip:
'Khi bật, mọi máy chủ MCP đã cấu hình và bật sẽ trở thành ứng viên cho công cụ và tài nguyên MCP trong AI Feature.',
},
localAgent: {
agentRunner: {
toolsTitle: 'Công cụ',
toolsDescription:
'Chọn công cụ plugin, MCP và công cụ tích hợp sẵn cho Local Agent này.',
'Chọn công cụ plugin, MCP và công cụ tích hợp sẵn cho Agent Runner này.',
toolsScopeTooltip:
'Công cụ MCP chỉ đến từ máy chủ MCP đã liên kết trong Tiện ích mở rộng. Hãy liên kết máy chủ tại đó trước nếu muốn chọn thêm tại đây.',
enableAllTools: 'Bật tất cả công cụ',
@@ -1055,7 +1055,7 @@ const viVN = {
selectTools: 'Chọn công cụ',
resourcesTitle: 'Tài nguyên',
resourcesDescription:
'Chọn tài nguyên MCP và kho tri thức cho Local Agent này.',
'Chọn tài nguyên MCP và kho tri thức cho Agent Runner này.',
knowledgeBases: 'Kho tri thức',
mcpResources: 'Tài nguyên MCP',
mcpResourcesScopeTooltip:
@@ -1678,11 +1678,6 @@ const viVN = {
title: 'Chọn công cụ AI',
description: 'Chọn công cụ AI sẽ cung cấp trí tuệ cho Bot của bạn.',
},
spaceBanner: {
message:
'Kết nối với LangBot Space để nhận tín dụng dùng thử mô hình miễn phí và thiết lập tức thì không cần cấu hình!',
action: 'Ủy quyền với Space',
},
config: {
botInfo: 'Thông tin Bot',
botNamePlaceholder: 'Nhập tên Bot',
+4 -7
View File
@@ -1172,10 +1172,10 @@ const zhHans = {
enableAllMCPServersTooltip:
'开启后,所有已配置且启用的 MCP 服务器都会进入 AI 能力里的 MCP 工具和资源候选范围。',
},
localAgent: {
agentRunner: {
toolsTitle: '工具',
toolsDescription:
'选择此内置 Agent 可以调用的插件、MCP、技能和内置工具。',
'选择当前 AgentRunner 可以调用的插件、MCP、技能和内置工具。',
toolsScopeTooltip:
'MCP 工具只会从扩展集成中已绑定的 MCP 服务器里出现;如需增加 MCP 工具来源,请先到扩展集成绑定对应服务器。',
enableAllTools: '启用所有工具',
@@ -1192,7 +1192,8 @@ const zhHans = {
'技能工具会在 LangBot 技能服务和 Box 沙箱后端可用时出现,用于让 Agent 激活或注册技能。',
selectTools: '选择工具',
resourcesTitle: '资源',
resourcesDescription: '选择此内置 Agent 可以读取的 MCP 资源和知识库。',
resourcesDescription:
'选择当前 AgentRunner 可以读取的 MCP 资源和知识库。',
knowledgeBases: '知识库',
mcpResources: 'MCP 资源',
mcpResourcesScopeTooltip:
@@ -1920,10 +1921,6 @@ const zhHans = {
registrationTimeout:
'扩展已安装,但运行器未完成注册。请检查插件运行时后重试。',
},
spaceBanner: {
message: '接入 LangBot Space,获取免费试用模型额度,零配置极速开箱!',
action: '前往授权登录',
},
config: {
botInfo: '机器人信息',
botNamePlaceholder: '请输入机器人名称',
+1 -5
View File
@@ -986,7 +986,7 @@ const zhHant = {
enableAllMCPServersTooltip:
'啟用後,所有已配置且啟用的 MCP 伺服器都會進入 AI 能力中的 MCP 工具和資源候選範圍。',
},
localAgent: {
agentRunner: {
toolsTitle: '工具',
toolsDescription: '選擇此內建 Agent 可以調用的插件、MCP 和內建工具。',
toolsScopeTooltip:
@@ -1603,10 +1603,6 @@ const zhHant = {
title: '選擇 AI 引擎',
description: '選擇驅動機器人智慧的 AI 引擎。',
},
spaceBanner: {
message: '接入 LangBot Space,取得免費試用模型額度,零配置極速開箱!',
action: '前往授權登入',
},
config: {
botInfo: '機器人資訊',
botNamePlaceholder: '請輸入機器人名稱',
+253 -33
View File
@@ -19,6 +19,52 @@ async function confirmDelete(page: Page) {
.click();
}
async function installDelayedFirstSave(page: Page, apiPath: string) {
const payloads: Record<string, unknown>[] = [];
let releaseFirstSave = () => {};
const firstSaveGate = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
await page.route(`**${apiPath}`, async (route) => {
if (route.request().method() !== 'PUT') {
await route.fallback();
return;
}
payloads.push(
JSON.parse(route.request().postData() || '{}') as Record<string, unknown>,
);
if (payloads.length === 1) {
await firstSaveGate;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 0,
message: 'ok',
data: {},
timestamp: Date.now(),
}),
});
});
return { payloads, releaseFirstSave };
}
async function forceFormSubmit(page: Page, formSelector: string) {
await page.locator(formSelector).evaluate((form) => {
(form as HTMLFormElement).requestSubmit();
});
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
test.describe('frontend CRUD smoke flows', () => {
test('creates, edits, and deletes a bot', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
@@ -56,16 +102,17 @@ test.describe('frontend CRUD smoke flows', () => {
test('creates, edits, and deletes a pipeline', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=new');
await page.goto('/home/agents?id=new');
await page.getByRole('button', { name: /^Pipeline/ }).click();
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page.locator('input[name="basic.name"]').fill('Escalation Pipeline');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Escalation Pipeline');
await page
.locator('input[name="basic.description"]')
.locator('input[name="description"]')
.fill('Routes urgent customer issues.');
await submit(page);
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
await page.reload();
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
'Escalation Pipeline',
@@ -82,9 +129,9 @@ test.describe('frontend CRUD smoke flows', () => {
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/pipelines$/);
await expect(page).toHaveURL(/\/home\/agents$/);
await expect(
page.getByText('Select a pipeline from the sidebar'),
page.getByText('Select an Agent or Pipeline from the sidebar'),
).toBeVisible();
});
@@ -93,7 +140,7 @@ test.describe('frontend CRUD smoke flows', () => {
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=pipeline-ai');
await page.goto('/home/agents?id=pipeline-ai');
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page.getByRole('button', { name: /^AI$/ }).click();
@@ -101,7 +148,7 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(page.getByText('Runtime')).toBeVisible();
await expect(
page.locator('[data-slot="card-title"]').filter({
hasText: 'Built-in Agent',
hasText: 'Local Agent',
}),
).toBeVisible();
await expect(
@@ -330,12 +377,48 @@ test.describe('bot advanced flows', () => {
});
test.describe('pipeline advanced flows', () => {
test('scopes runner tool catalogs to the edited pipeline', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withRunnerToolSelector: true,
});
const toolCatalogUrls: URL[] = [];
const requestedPaths: string[] = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (url.pathname === '/api/v1/tools') toolCatalogUrls.push(url);
requestedPaths.push(url.pathname);
});
await page.goto('/home/agents?id=pipeline-scope');
await page.getByRole('button', { name: /^AI$/ }).click();
await expect(
page.getByRole('button', { name: 'Edit tools' }),
).toBeVisible();
await expect
.poll(() =>
toolCatalogUrls.some(
(url) => url.searchParams.get('pipeline_uuid') === 'pipeline-scope',
),
)
.toBe(true);
await expect
.poll(() =>
requestedPaths.includes('/api/v1/pipelines/pipeline-scope/extensions'),
)
.toBe(true);
});
test('switches to monitoring tab from pipeline detail', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Tab Test Pipeline');
await page.goto('/home/agents?id=new');
await page.getByRole('button', { name: /^Pipeline/ }).click();
await page.locator('input[name="name"]').fill('Tab Test Pipeline');
await submit(page);
// Verify we're on the Configuration tab
@@ -360,8 +443,9 @@ test.describe('pipeline advanced flows', () => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Dirty Form Pipeline');
await page.goto('/home/agents?id=new');
await page.getByRole('button', { name: /^Pipeline/ }).click();
await page.locator('input[name="name"]').fill('Dirty Form Pipeline');
await submit(page);
// Wait for the page to fully load and form to reset
@@ -385,14 +469,153 @@ test.describe('pipeline advanced flows', () => {
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=new');
await page.goto('/home/agents?id=new');
await page.getByRole('button', { name: /^Pipeline/ }).click();
// Submit without filling name
await submit(page);
// Should show validation error for name (zod validation)
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
await expect(page).toHaveURL(/\/home\/pipelines\?id=new$/);
await expect(page).toHaveURL(/\/home\/agents\?id=new$/);
});
});
test.describe('agent runner resource selectors', () => {
test('uses the global catalog and preserves temporarily unavailable tools', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const toolCatalogUrls: URL[] = [];
const requestedPaths: string[] = [];
let savedAgent: Record<string, unknown> | undefined;
page.on('request', (request) => {
const url = new URL(request.url());
requestedPaths.push(url.pathname);
if (url.pathname === '/api/v1/tools') toolCatalogUrls.push(url);
if (
url.pathname === '/api/v1/agents/agent-scope' &&
request.method() === 'PUT'
) {
savedAgent = JSON.parse(request.postData() || '{}') as Record<
string,
unknown
>;
}
});
await page.goto('/home/agents?id=agent-scope');
await page.getByRole('button', { name: /^Runner$/ }).click();
await page.getByRole('button', { name: 'Edit tools' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('available_plugin_tool')).toBeVisible();
await dialog
.getByRole('checkbox', { name: 'Select available_plugin_tool' })
.click();
await dialog.getByRole('button', { name: /^Confirm$/ }).click();
await save(page);
await expect.poll(() => savedAgent).toBeTruthy();
expect(savedAgent).toMatchObject({
config: {
runner_config: {
'plugin:langbot-team/LocalAgent/default': {
tools: ['unavailable_plugin_tool', 'available_plugin_tool'],
},
},
},
});
expect(
toolCatalogUrls.some((url) => url.searchParams.has('pipeline_uuid')),
).toBe(false);
expect(requestedPaths).not.toContain(
'/api/v1/pipelines/agent-scope/extensions',
);
});
});
test.describe('agent and pipeline save concurrency', () => {
test('agent save freezes its payload and keeps later edits dirty', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const delayedSave = await installDelayedFirstSave(
page,
'/api/v1/agents/agent-save-race',
);
await page.goto('/home/agents?id=agent-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ });
const nameInput = page.locator('input[name="basic.name"]');
const descriptionInput = page.locator('input[name="basic.description"]');
await expect(nameInput).toBeVisible();
await nameInput.fill('Submitted Agent');
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled();
await descriptionInput.fill('Edited while the agent save is pending');
await forceFormSubmit(page, '#agent-form');
expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled();
delayedSave.releaseFirstSave();
await expect(saveButton).toBeEnabled();
expect(delayedSave.payloads[0]).toMatchObject({
name: 'Submitted Agent',
description: '',
});
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(2);
expect(delayedSave.payloads[1]).toMatchObject({
name: 'Submitted Agent',
description: 'Edited while the agent save is pending',
});
await expect(saveButton).toBeDisabled();
});
test('pipeline save freezes its payload and keeps later edits dirty', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
const delayedSave = await installDelayedFirstSave(
page,
'/api/v1/pipelines/pipeline-save-race',
);
await page.goto('/home/agents?id=pipeline-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ });
const nameInput = page.locator('input[name="basic.name"]');
const descriptionInput = page.locator('input[name="basic.description"]');
await expect(nameInput).toBeVisible();
await nameInput.fill('Submitted Pipeline');
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(1);
await expect(saveButton).toBeDisabled();
await descriptionInput.fill('Edited while the pipeline save is pending');
await forceFormSubmit(page, '#pipeline-form');
expect(delayedSave.payloads).toHaveLength(1);
await expect(saveButton).toBeDisabled();
delayedSave.releaseFirstSave();
await expect(saveButton).toBeEnabled();
expect(delayedSave.payloads[0]).toMatchObject({
name: 'Submitted Pipeline',
description: '',
});
await saveButton.click();
await expect.poll(() => delayedSave.payloads.length).toBe(2);
expect(delayedSave.payloads[1]).toMatchObject({
name: 'Submitted Pipeline',
description: 'Edited while the pipeline save is pending',
});
await expect(saveButton).toBeDisabled();
});
});
@@ -401,10 +624,11 @@ test.describe('cross-resource flows', () => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline first
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Production Pipeline');
await page.goto('/home/agents?id=new');
await page.getByRole('button', { name: /^Pipeline/ }).click();
await page.locator('input[name="name"]').fill('Production Pipeline');
await submit(page);
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
await expect(page).toHaveURL(/\/home\/agents\?id=pipeline-1$/);
// Create a bot
await page.goto('/home/bots?id=new');
@@ -417,17 +641,15 @@ test.describe('cross-resource flows', () => {
// Wait for form to fully load
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
// Find the pipeline select by its label "Bind Pipeline"
const pipelineCard = page.getByText('Bind Pipeline').locator('..');
await expect(pipelineCard).toBeVisible({ timeout: 5000 });
// Click on the select trigger within the pipeline binding card
// The select trigger shows "Select Pipeline" placeholder initially
const pipelineSelectTrigger = page.getByText('Select Pipeline').first();
await pipelineSelectTrigger.click();
await page.getByRole('button', { name: 'Add behavior' }).click();
await page.getByRole('menuitem', { name: /^Reply to messages/ }).click();
await page
.getByRole('combobox')
.filter({ hasText: 'Select processor' })
.click();
// Select the pipeline option
await page.getByRole('option', { name: 'Production Pipeline' }).click();
await page.getByRole('option', { name: /Production Pipeline/ }).click();
// Save the bot
await save(page);
@@ -436,9 +658,7 @@ test.describe('cross-resource flows', () => {
await page.reload();
// The pipeline name should appear in the select trigger (not in sidebar or options)
await expect(
page
.locator('[data-slot="select-trigger"]')
.filter({ hasText: 'Production Pipeline' }),
page.getByRole('combobox').filter({ hasText: 'Production Pipeline' }),
).toBeVisible();
});
});
@@ -451,12 +671,12 @@ test.describe('empty states', () => {
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
});
test('shows empty state when no pipelines exist', async ({ page }) => {
test('shows empty state when no processors exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines');
await page.goto('/home/agents');
await expect(
page.getByText('Select a pipeline from the sidebar'),
page.getByText('Select an Agent or Pipeline from the sidebar'),
).toBeVisible();
});
+219 -13
View File
@@ -21,6 +21,29 @@ interface PipelineMock {
updated_at: string;
}
interface AgentMock {
uuid: string;
name: string;
description: string;
emoji: string;
kind: 'agent';
component_ref: string;
config: JsonRecord;
enabled: boolean;
supported_event_patterns: string[];
updated_at: string;
}
interface ToolMock {
name: string;
description: string;
human_desc: string;
parameters: JsonRecord;
source: 'builtin' | 'plugin' | 'mcp' | 'skill';
source_name?: string;
source_id?: string;
}
interface KnowledgeBaseMock {
uuid: string;
name: string;
@@ -64,10 +87,12 @@ interface BotMock {
use_pipeline_uuid?: string;
pipeline_routing_rules: unknown[];
adapter_runtime_values: JsonRecord;
event_bindings: unknown[];
updated_at: string;
}
interface LangBotApiMockState {
agents: AgentMock[];
bots: BotMock[];
counters: Record<string, number>;
knowledgeBases: KnowledgeBaseMock[];
@@ -78,6 +103,8 @@ interface LangBotApiMockState {
sessionAnalyses: Record<string, unknown>;
sessionMessages: Record<string, unknown[]>;
skills: SkillMock[];
tools: ToolMock[];
withRunnerToolSelector: boolean;
}
function ok(data: unknown) {
@@ -183,7 +210,20 @@ function makePipeline(
name: String(data.name || ''),
description: String(data.description || ''),
config: (data.config as JsonRecord | undefined) || {
ai: {},
ai: {
runner: {
id: 'plugin:langbot-team/LocalAgent/default',
'expire-time': 0,
},
runner_config: {
'plugin:langbot-team/LocalAgent/default': {
model: {
primary: 'llm-valid',
fallbacks: [],
},
},
},
},
trigger: {},
safety: {},
output: {},
@@ -194,7 +234,41 @@ function makePipeline(
};
}
function pipelineMetadata() {
function makeAgent(data: JsonRecord, uuid: string): AgentMock {
return {
uuid,
name: String(data.name || uuid),
description: String(data.description || ''),
emoji: String(data.emoji || '🤖'),
kind: 'agent',
component_ref: String(
data.component_ref || 'plugin:langbot-team/LocalAgent/default',
),
config: (data.config as JsonRecord | undefined) || {
runner: {
id: 'plugin:langbot-team/LocalAgent/default',
'expire-time': 0,
},
runner_config: {
'plugin:langbot-team/LocalAgent/default': {
model: {
primary: 'llm-valid',
fallbacks: [],
},
'enable-all-tools': false,
tools: ['unavailable_plugin_tool'],
},
},
},
enabled: data.enabled !== false,
supported_event_patterns: (data.supported_event_patterns as
| string[]
| undefined) || ['*'],
updated_at: now(),
};
}
function pipelineMetadata(withRunnerToolSelector = false) {
return {
configs: [
{
@@ -212,21 +286,21 @@ function pipelineMetadata() {
},
config: [
{
id: 'runner',
name: 'runner',
id: 'runner.id',
name: 'id',
label: {
en_US: 'Runner',
zh_Hans: '运行器',
},
type: 'select',
required: true,
default: 'local-agent',
default: 'plugin:langbot-team/LocalAgent/default',
options: [
{
name: 'local-agent',
name: 'plugin:langbot-team/LocalAgent/default',
label: {
en_US: 'Built-in Agent',
zh_Hans: '内置 Agent',
en_US: 'Local Agent',
zh_Hans: '本地 Agent',
},
},
],
@@ -234,14 +308,14 @@ function pipelineMetadata() {
],
},
{
name: 'local-agent',
name: 'plugin:langbot-team/LocalAgent/default',
label: {
en_US: 'Built-in Agent',
zh_Hans: '内置 Agent',
en_US: 'Local Agent',
zh_Hans: '本地 Agent',
},
config: [
{
id: 'model',
id: 'plugin:langbot-team/LocalAgent/default.model',
name: 'model',
label: {
en_US: 'Model',
@@ -254,6 +328,21 @@ function pipelineMetadata() {
fallbacks: [],
},
},
...(withRunnerToolSelector
? [
{
id: 'plugin:langbot-team/LocalAgent/default.tools',
name: 'tools',
label: {
en_US: 'Tools',
zh_Hans: '工具',
},
type: 'rich-tools-selector',
required: false,
default: [],
},
]
: []),
],
},
],
@@ -262,6 +351,19 @@ function pipelineMetadata() {
};
}
function agentMetadata() {
return {
runner_config: pipelineMetadata(true).configs[0],
kinds: [
{
name: 'agent',
supported_event_patterns: ['*'],
message_only: false,
},
],
};
}
function providerModelList() {
return {
models: [
@@ -366,6 +468,7 @@ function makeBot(
: undefined,
pipeline_routing_rules:
(data.pipeline_routing_rules as unknown[] | undefined) || [],
event_bindings: (data.event_bindings as unknown[] | undefined) || [],
adapter_runtime_values: {
webhook_full_url: `https://playwright.test/bots/${uuid}/webhook`,
extra_webhook_full_url: '',
@@ -503,8 +606,90 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { models: [] });
}
if (path === '/api/v1/agents/_/metadata') {
return fulfillJson(route, agentMetadata());
}
if (path === '/api/v1/agents') {
if (method === 'POST') {
const data = parseJsonBody(route);
if (data.kind === 'pipeline') {
const pipeline = makePipeline(state, data);
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== pipeline.uuid),
pipeline,
];
return fulfillJson(route, { uuid: pipeline.uuid, kind: 'pipeline' });
}
const agentId = nextId(state, 'agent');
const agent = makeAgent(data, agentId);
state.agents = [
...state.agents.filter((item) => item.uuid !== agentId),
agent,
];
return fulfillJson(route, { uuid: agentId, kind: 'agent' });
}
return fulfillJson(route, {
agents: [
...state.agents,
...state.pipelines.map((pipeline) => ({
...pipeline,
kind: 'pipeline',
component_ref: 'pipeline',
enabled: true,
supported_event_patterns: ['message.*'],
})),
],
});
}
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
if (agentMatch) {
const agentId = decodeURIComponent(agentMatch[1]);
if (method === 'PUT') {
const agent = makeAgent(parseJsonBody(route), agentId);
state.agents = [
...state.agents.filter((item) => item.uuid !== agentId),
agent,
];
return fulfillJson(route, {});
}
if (method === 'DELETE') {
state.agents = state.agents.filter((item) => item.uuid !== agentId);
return fulfillJson(route, {});
}
if (agentId.startsWith('pipeline-')) {
let pipeline = state.pipelines.find((item) => item.uuid === agentId);
if (!pipeline) {
pipeline = makePipeline(state, { name: agentId }, agentId);
state.pipelines = [...state.pipelines, pipeline];
}
return fulfillJson(route, {
agent: {
...pipeline,
kind: 'pipeline',
component_ref: 'pipeline',
enabled: true,
supported_event_patterns: ['message.*'],
},
});
}
let agent = state.agents.find((item) => item.uuid === agentId);
if (!agent) {
agent = makeAgent({ name: agentId }, agentId);
state.agents = [...state.agents, agent];
}
return fulfillJson(route, { agent });
}
if (path === '/api/v1/pipelines/_/metadata') {
return fulfillJson(route, pipelineMetadata());
return fulfillJson(route, pipelineMetadata(state.withRunnerToolSelector));
}
if (path === '/api/v1/pipelines') {
@@ -559,6 +744,8 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
available_plugins: [],
bound_mcp_servers: [],
available_mcp_servers: state.mcpServers,
bound_mcp_resources: [],
mcp_resource_agent_read_enabled: true,
bound_skills: [],
available_skills: state.skills,
});
@@ -624,6 +811,10 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
});
}
if (path === '/api/v1/tools') {
return fulfillJson(route, { tools: state.tools });
}
if (path === '/api/v1/plugins') {
return fulfillJson(route, { plugins: [] });
}
@@ -951,6 +1142,7 @@ export async function installLangBotApiMocks(
sessionAnalyses?: Record<string, unknown>;
sessionMessages?: Record<string, unknown[]>;
storage?: JsonRecord;
withRunnerToolSelector?: boolean;
} = {},
) {
const {
@@ -960,8 +1152,10 @@ export async function installLangBotApiMocks(
sessionAnalyses,
sessionMessages,
storage = {},
withRunnerToolSelector = false,
} = options;
const state: LangBotApiMockState = {
agents: [],
bots: [],
counters: {},
knowledgeBases: [],
@@ -972,6 +1166,18 @@ export async function installLangBotApiMocks(
sessionAnalyses: sessionAnalyses || {},
sessionMessages: sessionMessages || {},
skills: [],
tools: [
{
name: 'available_plugin_tool',
description: 'Available plugin tool',
human_desc: 'Available plugin tool',
parameters: {},
source: 'plugin',
source_name: 'langbot-app/TestTools',
source_id: 'langbot-app/TestTools',
},
],
withRunnerToolSelector,
};
await page.addInitScript(