mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 20:50:58 +00:00
feat(agent-runner): enforce 4.x host-owned execution
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user