feat(api-tools): processor workspace operations authorization and WebUI

LANG-226

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-31 17:39:10 +00:00
co-authored by multica-agent
parent f527c61a41
commit a0405a0b2c
17 changed files with 1402 additions and 7 deletions
@@ -21,7 +21,9 @@ import {
Agent,
AgentPlatformTool,
ApiRespPluginSystemStatus,
DomainMeta,
PluginTool,
WorkspaceOperationItem,
} from '@/app/infra/entities/api';
import {
PipelineConfigStage,
@@ -44,12 +46,23 @@ import {
CardTitle,
} from '@/components/ui/card';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import AgentEventPatternPicker from './AgentEventPatternPicker';
import RunnerSelect from './RunnerSelect';
import GuidedTour, {
GuidedTourStep,
} from '@/app/home/components/guided-tour/GuidedTour';
import AgentApiToolPicker from './AgentApiToolPicker';
import WorkspaceOperationsPicker from '@/app/home/components/workspace-operations/WorkspaceOperationsPicker';
const OTHER_TOOL_SCOPES = [
'platform',
@@ -147,6 +160,13 @@ function AgentFormComponent(
const [hostTools, setHostTools] = useState<PluginTool[]>([]);
const [hostToolCatalogAvailable, setHostToolCatalogAvailable] =
useState(true);
const [workspaceOperations, setWorkspaceOperations] = useState<
WorkspaceOperationItem[]
>([]);
const [domains, setDomains] = useState<DomainMeta[]>([]);
const [dangerousModalOpen, setDangerousModalOpen] = useState(false);
const pendingSaveValuesRef = useRef<FormValues | null>(null);
const pendingResolveRef = useRef<((value: boolean) => void) | null>(null);
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
@@ -171,6 +191,7 @@ function AgentFormComponent(
supported_event_patterns: z.array(z.string()),
allowed_platform_tools: z.array(z.string()),
allowed_tools: z.array(z.string()),
allowed_workspace_operations: z.array(z.string()),
});
type FormValues = z.infer<typeof formSchema>;
@@ -187,6 +208,7 @@ function AgentFormComponent(
supported_event_patterns: ['*'],
allowed_platform_tools: [],
allowed_tools: [],
allowed_workspace_operations: [],
},
});
const runnerInstallScope = `agent:${agentId}`;
@@ -237,6 +259,8 @@ function AgentFormComponent(
: [];
setHostToolCatalogAvailable(hasHostToolCatalog);
setHostTools(availableHostTools);
setWorkspaceOperations(metadata.workspace_operations || []);
setDomains(metadata.domains || []);
const agent = resp.agent;
const config = (agent.config ?? {}) as Record<string, any>;
const configuredHostTools = Array.isArray(config.allowed_tools)
@@ -251,6 +275,13 @@ function AgentFormComponent(
(name): name is string => typeof name === 'string',
)
: [];
const configuredWorkspaceOperations = Array.isArray(
config.allowed_workspace_operations,
)
? config.allowed_workspace_operations.filter(
(name): name is string => typeof name === 'string',
)
: [];
const configuredEventPatterns =
agent.supported_event_patterns ??
agent.capability?.supported_event_patterns;
@@ -278,6 +309,7 @@ function AgentFormComponent(
}),
allowed_tools:
configuredHostTools ?? availableHostTools.map((tool) => tool.name),
allowed_workspace_operations: configuredWorkspaceOperations,
};
form.reset(loadedValues);
savedSnapshotRef.current = JSON.stringify(loadedValues);
@@ -639,6 +671,7 @@ function AgentFormComponent(
runner,
runner_config: values.runner_config ?? {},
allowed_platform_tools: values.allowed_platform_tools,
allowed_workspace_operations: values.allowed_workspace_operations,
};
if (hostToolCatalogAvailable) {
config.allowed_tools = values.allowed_tools;
@@ -688,8 +721,26 @@ function AgentFormComponent(
],
);
const confirmAndSave = useCallback(
(values: FormValues): Promise<boolean> => {
const hasDangerous = values.allowed_workspace_operations.some((opId) => {
const op = workspaceOperations.find((item) => item.id === opId);
return op?.risk === 'dangerous';
});
if (hasDangerous) {
return new Promise<boolean>((resolve) => {
pendingSaveValuesRef.current = values;
pendingResolveRef.current = resolve;
setDangerousModalOpen(true);
});
}
return saveValues(values);
},
[saveValues, workspaceOperations],
);
function handleSubmit(values: FormValues) {
void saveValues(values);
void confirmAndSave(values);
}
useImperativeHandle(
@@ -721,14 +772,14 @@ function AgentFormComponent(
if (isSavingRef.current) return false;
const valid = await form.trigger();
if (!valid) return false;
return (await saveValues(form.getValues())) ?? false;
return (await confirmAndSave(form.getValues())) ?? false;
},
}),
[
confirmAndSave,
form,
initialDataLoaded,
loadFailed,
saveValues,
hasUnsavedChangesRef,
refreshDirty,
],
@@ -892,6 +943,40 @@ function AgentFormComponent(
)}
/>
</section>
<section className="space-y-3 border-t pt-6">
<div className="space-y-1">
<h3 className="text-sm font-medium">
{t('workspaceOperations.title')}
</h3>
<p className="text-xs text-muted-foreground">
{t('workspaceOperations.description')}
</p>
</div>
<FormField
control={form.control}
name="allowed_workspace_operations"
render={({ field }) => (
<FormItem>
<WorkspaceOperationsPicker
operations={workspaceOperations}
domains={domains}
value={field.value}
onChange={(next) =>
form.setValue(
'allowed_workspace_operations',
next,
{
shouldDirty: true,
shouldValidate: true,
},
)
}
/>
<FormMessage />
</FormItem>
)}
/>
</section>
</CardContent>
</Card>
)}
@@ -903,6 +988,48 @@ function AgentFormComponent(
steps={runnerGuideSteps}
testId="runner-setup-guide"
/>
<AlertDialog
open={dangerousModalOpen}
onOpenChange={setDangerousModalOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('workspaceOperations.dangerousConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription>
{t('workspaceOperations.dangerousConfirmDescription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setDangerousModalOpen(false);
pendingResolveRef.current?.(false);
pendingSaveValuesRef.current = null;
pendingResolveRef.current = null;
}}
>
{t('workspaceOperations.dangerousConfirmCancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDangerousModalOpen(false);
if (pendingSaveValuesRef.current) {
const vals = pendingSaveValuesRef.current;
pendingSaveValuesRef.current = null;
const resolve = pendingResolveRef.current;
pendingResolveRef.current = null;
const success = await saveValues(vals);
resolve?.(success);
}
}}
>
{t('workspaceOperations.dangerousConfirmAction')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
</Form>
</div>
@@ -0,0 +1,439 @@
import { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
ChevronDown,
ChevronRight,
Search,
ShieldAlert,
HelpCircle,
} from 'lucide-react';
import type {
WorkspaceOperationItem,
DomainMeta,
} from '@/app/infra/entities/api';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
export interface WorkspaceOperationsPickerProps {
operations: WorkspaceOperationItem[];
domains?: DomainMeta[];
value: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
}
export default function WorkspaceOperationsPicker({
operations,
domains = [],
value = [],
onChange,
disabled = false,
}: WorkspaceOperationsPickerProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const [openDomains, setOpenDomains] = useState<Record<string, boolean>>({});
const selectedSet = useMemo(() => new Set(value), [value]);
// Map domain names to domain metadata
const domainMetaMap = useMemo(() => {
const map = new Map<string, DomainMeta>();
for (const d of domains) {
map.set(d.domain, d);
}
return map;
}, [domains]);
// Group operations by domain
const groupedOperations = useMemo(() => {
const groups = new Map<string, WorkspaceOperationItem[]>();
for (const op of operations) {
if (!groups.has(op.domain)) {
groups.set(op.domain, []);
}
groups.get(op.domain)!.push(op);
}
return groups;
}, [operations]);
// Sorted domain list
const domainList = useMemo(() => {
return Array.from(groupedOperations.keys()).sort();
}, [groupedOperations]);
// Filtered operations based on search query
const filteredDomainGroups = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
return domainList.map((domain) => ({
domain,
operations: groupedOperations.get(domain) || [],
}));
}
const result: Array<{
domain: string;
operations: WorkspaceOperationItem[];
}> = [];
for (const domain of domainList) {
const ops = groupedOperations.get(domain) || [];
const matchingOps = ops.filter((op) => {
const idMatch = op.id.toLowerCase().includes(query);
const zhLabelMatch = op.label?.zh_Hans?.toLowerCase().includes(query);
const enLabelMatch = op.label?.en_US?.toLowerCase().includes(query);
const summaryMatch = op.summary?.toLowerCase().includes(query);
const domainMatch = domain.toLowerCase().includes(query);
return (
idMatch || zhLabelMatch || enLabelMatch || summaryMatch || domainMatch
);
});
if (matchingOps.length > 0) {
result.push({
domain,
operations: matchingOps,
});
}
}
return result;
}, [domainList, groupedOperations, searchQuery]);
const toggleDomainOpen = (domain: string) => {
setOpenDomains((prev) => ({
...prev,
[domain]: prev[domain] !== undefined ? !prev[domain] : false, // default is open (true)
}));
};
const isDomainOpen = (domain: string) => {
// If search active, always expand matching domains
if (searchQuery.trim()) return true;
return openDomains[domain] !== false; // defaults to true (open)
};
const handleToggleOperation = useCallback(
(opId: string, checked: boolean) => {
if (disabled) return;
if (checked) {
if (!selectedSet.has(opId)) {
onChange([...value, opId]);
}
} else {
onChange(value.filter((id) => id !== opId));
}
},
[disabled, onChange, selectedSet, value],
);
const handleToggleDomainAll = useCallback(
(domainOps: WorkspaceOperationItem[]) => {
if (disabled) return;
const grantableOps = domainOps.filter((op) => op.grantable);
if (grantableOps.length === 0) return;
const allGrantableSelected = grantableOps.every((op) =>
selectedSet.has(op.id),
);
if (allGrantableSelected) {
// Deselect all grantable ops in this domain
const idsToRemove = new Set(grantableOps.map((op) => op.id));
onChange(value.filter((id) => !idsToRemove.has(id)));
} else {
// Select all grantable ops in this domain
const next = new Set(value);
for (const op of grantableOps) {
next.add(op.id);
}
onChange(Array.from(next));
}
},
[disabled, onChange, selectedSet, value],
);
const getDomainLabel = (domain: string): string => {
const meta = domainMetaMap.get(domain);
if (meta?.label) {
return (
extractI18nObject(
meta.label as unknown as Parameters<typeof extractI18nObject>[0],
) ||
meta.label.zh_Hans ||
meta.label.en_US ||
domain
);
}
return domain;
};
const renderRiskBadge = (risk: WorkspaceOperationItem['risk']) => {
switch (risk) {
case 'dangerous':
return (
<Badge
variant="destructive"
className="gap-1 px-1.5 py-0 text-[10px] font-semibold"
>
<ShieldAlert className="size-3" />
{t('workspaceOperations.riskDangerous')}
</Badge>
);
case 'write':
return (
<Badge
variant="outline"
className="border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0 text-[10px] font-semibold"
>
{t('workspaceOperations.riskWrite')}
</Badge>
);
case 'read':
default:
return (
<Badge
variant="outline"
className="border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-400 px-1.5 py-0 text-[10px] font-semibold"
>
{t('workspaceOperations.riskRead')}
</Badge>
);
}
};
return (
<div className="space-y-4">
{/* Search Bar & Summary */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative max-w-sm flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('workspaceOperations.searchPlaceholder')}
className="pl-9"
/>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>
{t('workspaceOperations.selectedCount', {
count: value.length,
})}
</span>
<span>•</span>
<span>
{t('workspaceOperations.totalCount', {
count: operations.length,
})}
</span>
</div>
</div>
{/* Empty State */}
{operations.length === 0 && (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
{t('workspaceOperations.noOperations')}
</div>
)}
{operations.length > 0 && filteredDomainGroups.length === 0 && (
<div className="rounded-lg border border-dashed py-8 text-center text-sm text-muted-foreground">
{t('workspaceOperations.noSearchResults')}
</div>
)}
{/* Domain Groups */}
{filteredDomainGroups.length > 0 && (
<div className="space-y-3">
{filteredDomainGroups.map(({ domain, operations: domainOps }) => {
const isOpen = isDomainOpen(domain);
const grantableOps = domainOps.filter((op) => op.grantable);
const selectedInDomain = domainOps.filter((op) =>
selectedSet.has(op.id),
);
const allGrantableSelected =
grantableOps.length > 0 &&
grantableOps.every((op) => selectedSet.has(op.id));
const someSelected =
selectedInDomain.length > 0 && !allGrantableSelected;
return (
<Collapsible
key={domain}
open={isOpen}
onOpenChange={() => toggleDomainOpen(domain)}
className="rounded-lg border bg-card text-card-foreground shadow-sm"
>
{/* Domain Header */}
<div className="flex items-center justify-between border-b bg-muted/40 px-3 py-2.5">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex flex-1 items-center gap-2 text-left text-sm font-semibold hover:opacity-80 transition-opacity"
>
{isOpen ? (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
)}
<span>{getDomainLabel(domain)}</span>
<span className="font-mono text-xs text-muted-foreground font-normal">
({domain})
</span>
<Badge
variant="secondary"
className="ml-1 text-[11px] font-normal"
>
{selectedInDomain.length} / {domainOps.length}
</Badge>
</button>
</CollapsibleTrigger>
{/* Domain-level Select All */}
<div className="flex items-center gap-2 pl-3">
<label
className={cn(
'flex items-center gap-1.5 text-xs font-medium cursor-pointer select-none',
(disabled || grantableOps.length === 0) &&
'cursor-not-allowed opacity-50',
)}
>
<Checkbox
checked={
allGrantableSelected
? true
: someSelected
? 'indeterminate'
: false
}
disabled={disabled || grantableOps.length === 0}
onCheckedChange={() => handleToggleDomainAll(domainOps)}
aria-label={`${domain} ${t('workspaceOperations.selectAll')}`}
/>
<span>{t('workspaceOperations.selectAll')}</span>
</label>
</div>
</div>
{/* Operations List in Domain */}
<CollapsibleContent>
<div className="divide-y">
{domainOps.map((op) => {
const isChecked = selectedSet.has(op.id);
const isGrantable = op.grantable;
const hasBothLabels =
op.label?.zh_Hans &&
op.label?.en_US &&
op.label.zh_Hans !== op.label.en_US;
const content = (
<div
key={op.id}
className={cn(
'flex items-start gap-3 px-3 py-2.5 transition-colors',
!isGrantable && 'opacity-60 bg-muted/20',
isGrantable && 'hover:bg-muted/30',
)}
>
<Checkbox
id={`op-${op.id}`}
checked={isChecked}
disabled={disabled || !isGrantable}
onCheckedChange={(checked) =>
handleToggleOperation(op.id, checked === true)
}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<label
htmlFor={`op-${op.id}`}
className={cn(
'block font-medium text-sm leading-5',
isGrantable
? 'cursor-pointer'
: 'cursor-not-allowed',
)}
>
<div className="flex flex-wrap items-center gap-2">
<span>
{hasBothLabels
? `${op.label.zh_Hans} (${op.label.en_US})`
: extractI18nObject(
op.label as unknown as Parameters<
typeof extractI18nObject
>[0],
) ||
op.label?.zh_Hans ||
op.label?.en_US ||
op.id}
</span>
{renderRiskBadge(op.risk)}
</div>
</label>
<div className="mt-0.5 font-mono text-xs text-muted-foreground">
{op.id}
</div>
{op.summary && (
<p className="mt-1 text-xs text-muted-foreground leading-normal">
{op.summary}
</p>
)}
</div>
{!isGrantable && (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 text-amber-600 dark:text-amber-400 mt-1 cursor-help">
<HelpCircle className="size-4" />
</span>
</TooltipTrigger>
<TooltipContent side="left">
{t('workspaceOperations.requiresPermission', {
permission: op.permission,
})}
</TooltipContent>
</Tooltip>
)}
</div>
);
if (!isGrantable) {
return (
<Tooltip key={op.id}>
<TooltipTrigger asChild>
<div>{content}</div>
</TooltipTrigger>
<TooltipContent side="top">
{t('workspaceOperations.requiresPermission', {
permission: op.permission,
})}
</TooltipContent>
</Tooltip>
);
}
return content;
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
</div>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { backendClient } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
@@ -20,6 +20,7 @@ import {
Wrench,
Sparkles,
Puzzle,
Loader2,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
@@ -29,10 +30,26 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Plugin } from '@/app/infra/entities/plugin';
import { MCPServer, Skill } from '@/app/infra/entities/api';
import {
MCPServer,
Skill,
WorkspaceOperationItem,
DomainMeta,
} from '@/app/infra/entities/api';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
import WorkspaceOperationsPicker from '@/app/home/components/workspace-operations/WorkspaceOperationsPicker';
function PluginThumbnail({ author, name }: { author: string; name: string }) {
const iconURL = useInstalledPluginIcon(author, name);
@@ -99,6 +116,24 @@ export default function PipelineExtension({
const [tempSelectedSkillIds, setTempSelectedSkillIds] = useState<string[]>(
[],
);
const [workspaceOperations, setWorkspaceOperations] = useState<string[]>([]);
const savedWorkspaceOperationsRef = useRef<string[]>([]);
const [availableWorkspaceOperations, setAvailableWorkspaceOperations] =
useState<WorkspaceOperationItem[]>([]);
const [domains, setDomains] = useState<DomainMeta[]>([]);
const [isSavingOperations, setIsSavingOperations] = useState(false);
const [dangerousModalOpen, setDangerousModalOpen] = useState(false);
const pendingOperationsRef = useRef<string[] | null>(null);
const isOperationsDirty = useMemo(() => {
const saved = new Set(savedWorkspaceOperationsRef.current);
if (workspaceOperations.length !== saved.size) return true;
return workspaceOperations.some((op) => !saved.has(op));
}, [workspaceOperations]);
const handleResetWorkspaceOperations = () => {
setWorkspaceOperations([...savedWorkspaceOperationsRef.current]);
};
useEffect(() => {
loadExtensions();
@@ -147,6 +182,15 @@ export default function PipelineExtension({
setSelectedSkills(selectedSkill);
setAllSkills(data.available_skills || []);
// Load Workspace Operations
const loadedOps = data.workspace_operations || [];
setWorkspaceOperations(loadedOps);
savedWorkspaceOperationsRef.current = loadedOps;
setAvailableWorkspaceOperations(
data.available_workspace_operations || [],
);
setDomains(data.domains || []);
} catch (error) {
console.error('Failed to load extensions:', error);
toast.error(t('pipelines.extensions.loadError'));
@@ -183,6 +227,9 @@ export default function PipelineExtension({
newEnableAllMCPServers ?? enableAllMCPServers,
boundSkillIds,
newEnableAllSkills ?? enableAllSkills,
undefined,
undefined,
workspaceOperations,
);
toast.success(t('pipelines.extensions.saveSuccess'));
} catch (error) {
@@ -193,6 +240,63 @@ export default function PipelineExtension({
}
};
const executeSaveWorkspaceOperations = async (opsToSave: string[]) => {
setIsSavingOperations(true);
try {
const boundPluginsArray = selectedPlugins.map((plugin) => {
const metadata = plugin.manifest.manifest.metadata;
return {
author: metadata.author || '',
name: metadata.name,
};
});
const boundMCPServerIds = selectedMCPServers.map(
(server) => server.uuid || '',
);
const boundSkillIds = selectedSkills.map((skill) => skill.name);
await backendClient.updatePipelineExtensions(
pipelineId,
boundPluginsArray,
boundMCPServerIds,
enableAllPlugins,
enableAllMCPServers,
boundSkillIds,
enableAllSkills,
undefined,
undefined,
opsToSave,
);
savedWorkspaceOperationsRef.current = opsToSave;
setWorkspaceOperations(opsToSave);
toast.success(t('workspaceOperations.saveSuccess'));
} catch (error) {
console.error('Failed to save workspace operations:', error);
const message =
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(
t('workspaceOperations.saveFailed') + (message ? `: ${message}` : ''),
);
} finally {
setIsSavingOperations(false);
}
};
const handleSaveWorkspaceOperations = async (opsToSave: string[]) => {
const hasDangerous = opsToSave.some((opId) => {
const op = availableWorkspaceOperations.find((item) => item.id === opId);
return op?.risk === 'dangerous';
});
if (hasDangerous) {
pendingOperationsRef.current = opsToSave;
setDangerousModalOpen(true);
return;
}
await executeSaveWorkspaceOperations(opsToSave);
};
const handleRemovePlugin = async (pluginId: string) => {
const newPlugins = selectedPlugins.filter(
(p) => getPluginId(p) !== pluginId,
@@ -639,6 +743,89 @@ export default function PipelineExtension({
</Button>
</div>
{/* Workspace Operations Section */}
<div className="space-y-3 border-t pt-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-0.5">
<h3 className="text-sm font-semibold text-foreground">
{t('workspaceOperations.title')}
</h3>
<p className="text-sm text-muted-foreground">
{t('workspaceOperations.scopeHint')}
</p>
</div>
<div className="flex items-center gap-2">
{isOperationsDirty && (
<Button
variant="outline"
size="sm"
onClick={handleResetWorkspaceOperations}
disabled={isSavingOperations}
>
{t('common.cancel')}
</Button>
)}
<Button
size="sm"
onClick={() => handleSaveWorkspaceOperations(workspaceOperations)}
disabled={isSavingOperations || !isOperationsDirty}
>
{isSavingOperations ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t('workspaceOperations.saveWorkspaceOperations')}
</Button>
</div>
</div>
<WorkspaceOperationsPicker
operations={availableWorkspaceOperations}
domains={domains}
value={workspaceOperations}
onChange={setWorkspaceOperations}
disabled={isSavingOperations}
/>
</div>
{/* Dangerous Operations Confirmation Dialog */}
<AlertDialog
open={dangerousModalOpen}
onOpenChange={setDangerousModalOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('workspaceOperations.dangerousConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription>
{t('workspaceOperations.dangerousConfirmDescription')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setDangerousModalOpen(false);
pendingOperationsRef.current = null;
}}
>
{t('workspaceOperations.dangerousConfirmCancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
setDangerousModalOpen(false);
if (pendingOperationsRef.current) {
const ops = pendingOperationsRef.current;
pendingOperationsRef.current = null;
await executeSaveWorkspaceOperations(ops);
}
}}
>
{t('workspaceOperations.dangerousConfirmAction')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Plugin Selection Dialog */}
<Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
+19
View File
@@ -267,11 +267,30 @@ export interface ApiRespAgent {
agent: Agent;
}
export interface WorkspaceOperationItem {
id: string;
domain: string;
label: Record<string, string>;
summary: string;
risk: 'read' | 'write' | 'dangerous';
permission: string;
grantable: boolean;
}
export interface DomainMeta {
domain: string;
label: Record<string, string>;
summary?: string;
count?: number;
}
export interface GetAgentMetadataResponseData {
event_processors?: RunnerDescriptor[];
runner_config?: PipelineConfigTab;
platform_tools: AgentPlatformTool[];
host_tools?: PluginTool[] | null;
workspace_operations?: WorkspaceOperationItem[];
domains?: DomainMeta[];
kinds: Array<{
name: AgentKind;
supported_event_patterns: string[];
+9
View File
@@ -45,6 +45,8 @@ import {
GetPipelineResponseData,
GetPipelineMetadataResponseData,
GetAgentMetadataResponseData,
WorkspaceOperationItem,
DomainMeta,
AsyncTask,
ApiRespWebChatMessages,
ApiRespKnowledgeBases,
@@ -609,6 +611,9 @@ export class BackendClient extends BaseHttpClient {
}>;
bound_skills: string[];
available_skills: Skill[];
workspace_operations?: string[];
available_workspace_operations?: WorkspaceOperationItem[];
domains?: DomainMeta[];
}> {
return this.get(`/api/v1/pipelines/${uuid}/extensions`);
}
@@ -631,6 +636,7 @@ export class BackendClient extends BaseHttpClient {
max_tokens?: number;
}>,
mcp_resource_agent_read_enabled?: boolean,
workspace_operations?: string[],
): Promise<object> {
const payload: Record<string, unknown> = {
bound_plugins,
@@ -646,6 +652,9 @@ export class BackendClient extends BaseHttpClient {
if (mcp_resource_agent_read_enabled !== undefined) {
payload.mcp_resource_agent_read_enabled = mcp_resource_agent_read_enabled;
}
if (workspace_operations !== undefined) {
payload.workspace_operations = workspace_operations;
}
return this.put(`/api/v1/pipelines/${uuid}/extensions`, payload);
}
+29
View File
@@ -3986,5 +3986,34 @@ const enUS = {
period: '.',
dismiss: 'Dismiss',
},
workspaceOperations: {
title: 'Workspace Operations',
description:
'Configure workspace unified API operations that this processor can invoke.',
scopeHint:
'Allow this processor to invoke LangBot workspace unified API operations under its own identity.',
searchPlaceholder: 'Search operation name, ID, or description...',
selectAll: 'Select All',
deselectAll: 'Deselect All',
selectedCount: '{{count}} selected',
totalCount: '{{count}} total',
noOperations: 'No workspace operations available',
noSearchResults: 'No matching operations found',
riskRead: 'Read',
riskWrite: 'Write',
riskDangerous: 'Dangerous',
requiresPermission: 'Requires permission: {{permission}}',
dangerousConfirmTitle: 'Contains Dangerous Workspace Operations',
dangerousConfirmDescription:
'This processor faces IM users; anyone who can chat with the Bot may trigger these operations. Are you sure you want to save?',
dangerousConfirmAction: 'Confirm Save',
dangerousConfirmCancel: 'Cancel',
saveWorkspaceOperations: 'Save Workspace Operations',
saveSuccess: 'Workspace operations saved successfully',
saveFailed: 'Failed to save workspace operations',
configuredCount: '{{count}} operations configured',
expandDomain: 'Expand',
collapseDomain: 'Collapse',
},
};
export default enUS;
+27
View File
@@ -3755,5 +3755,32 @@ const zhHans = {
period: '。',
dismiss: '关闭横幅',
},
workspaceOperations: {
title: '工作区操作',
description: '配置处理器可以调用的工作区统一 API 操作。勾选即授权。',
scopeHint: '允许此处理器以自身身份调用 LangBot 工作区统一 API 操作。',
searchPlaceholder: '搜索操作名称、ID 或描述...',
selectAll: '全选',
deselectAll: '取消全选',
selectedCount: '已选 {{count}} 项',
totalCount: '共 {{count}} 项',
noOperations: '暂无可用工作区操作',
noSearchResults: '未找到匹配的操作',
riskRead: '读',
riskWrite: '写',
riskDangerous: '高危',
requiresPermission: '需要权限:{{permission}}',
dangerousConfirmTitle: '包含高危工作区操作',
dangerousConfirmDescription:
'该处理器面向 IM 用户,任何能和 Bot 对话的人都可能触发这些操作。确定要保存吗?',
dangerousConfirmAction: '确认保存',
dangerousConfirmCancel: '取消',
saveWorkspaceOperations: '保存工作区操作',
saveSuccess: '工作区操作保存成功',
saveFailed: '工作区操作保存失败',
configuredCount: '已配置 {{count}} 个操作',
expandDomain: '展开',
collapseDomain: '折叠',
},
};
export default zhHans;
+27
View File
@@ -3750,5 +3750,32 @@ const zhHant = {
period: '。',
dismiss: '關閉橫幅',
},
workspaceOperations: {
title: '工作區操作',
description: '配置處理器可以調用的工作區統一 API 操作。勾選即授權。',
scopeHint: '允許此處理器以自身身份調用 LangBot 工作區統一 API 操作。',
searchPlaceholder: '搜尋操作名稱、ID 或描述...',
selectAll: '全選',
deselectAll: '取消全選',
selectedCount: '已選 {{count}} 項',
totalCount: '共 {{count}} 項',
noOperations: '暫無可用工作區操作',
noSearchResults: '未找到匹配的操作',
riskRead: '讀',
riskWrite: '寫',
riskDangerous: '高危',
requiresPermission: '需要權限:{{permission}}',
dangerousConfirmTitle: '包含高危工作區操作',
dangerousConfirmDescription:
'該處理器面向 IM 用戶,任何能和 Bot 對話的人都可能觸發這些操作。確定要保存嗎?',
dangerousConfirmAction: '確認保存',
dangerousConfirmCancel: '取消',
saveWorkspaceOperations: '保存工作區操作',
saveSuccess: '工作區操作保存成功',
saveFailed: '工作區操作保存失敗',
configuredCount: '已配置 {{count}} 個操作',
expandDomain: '展開',
collapseDomain: '折疊',
},
};
export default zhHant;