mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 13:25:18 +08:00
feat(api-tools): processor workspace operations authorization and WebUI
LANG-226 Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
f527c61a41
commit
a0405a0b2c
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user