mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
feat(views): extract environment variables into separate agent tab
Move the Environment Variables section from the Settings tab into its own "Environment" tab (KeyRound icon) between Tasks and Settings. Each tab now has independent save state.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import type { Agent, RuntimeDevice } from "@multica/core/types";
|
||||
import {
|
||||
@@ -34,17 +35,19 @@ import { InstructionsTab } from "./tabs/instructions-tab";
|
||||
import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { TasksTab } from "./tabs/tasks-tab";
|
||||
import { SettingsTab } from "./tabs/settings-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
|
||||
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
|
||||
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
|
||||
}
|
||||
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
|
||||
|
||||
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||
{ id: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
||||
{ id: "env", label: "Environment", icon: KeyRound },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -158,6 +161,12 @@ export function AgentDetail({
|
||||
<SkillsTab agent={agent} />
|
||||
)}
|
||||
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
||||
{activeTab === "env" && (
|
||||
<EnvTab
|
||||
agent={agent}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "settings" && (
|
||||
<SettingsTab
|
||||
agent={agent}
|
||||
|
||||
191
packages/views/agents/components/tabs/env-tab.tsx
Normal file
191
packages/views/agents/components/tabs/env-tab.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
|
||||
let nextEnvId = 0;
|
||||
|
||||
interface EnvEntry {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function envMapToEntries(env: Record<string, string>): EnvEntry[] {
|
||||
return Object.entries(env).map(([key, value]) => ({
|
||||
id: nextEnvId++,
|
||||
key,
|
||||
value,
|
||||
visible: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const key = entry.key.trim();
|
||||
if (key) {
|
||||
map[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function EnvTab({
|
||||
agent,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
|
||||
envMapToEntries(agent.custom_env ?? {}),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentEnvMap = entriesToEnvMap(envEntries);
|
||||
const originalEnvMap = agent.custom_env ?? {};
|
||||
const dirty =
|
||||
JSON.stringify(currentEnvMap) !== JSON.stringify(originalEnvMap);
|
||||
|
||||
const addEnvEntry = () => {
|
||||
setEnvEntries([
|
||||
...envEntries,
|
||||
{ id: nextEnvId++, key: "", value: "", visible: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEnvEntry = (index: number) => {
|
||||
setEnvEntries(envEntries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEnvEntry = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
val: string,
|
||||
) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, [field]: val } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEnvVisibility = (index: number) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, visible: !entry.visible } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const keys = envEntries.filter((e) => e.key.trim()).map((e) => e.key.trim());
|
||||
const uniqueKeys = new Set(keys);
|
||||
if (uniqueKeys.size < keys.length) {
|
||||
toast.error("Duplicate environment variable keys");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ custom_env: currentEnvMap });
|
||||
toast.success("Environment variables saved");
|
||||
} catch {
|
||||
toast.error("Failed to save environment variables");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Environment Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Injected into the agent process at launch (e.g. ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_BASE_URL)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addEnvEntry}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{envEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{envEntries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEnvEntry(index, "key", e.target.value)}
|
||||
placeholder="KEY"
|
||||
className="w-[40%] font-mono text-xs"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={entry.visible ? "text" : "password"}
|
||||
value={entry.value}
|
||||
onChange={(e) =>
|
||||
updateEnvEntry(index, "value", e.target.value)
|
||||
}
|
||||
placeholder="value"
|
||||
className="pr-8 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnvVisibility(index)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{entry.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvEntry(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,10 +10,6 @@ import {
|
||||
Lock,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import type { Agent, AgentVisibility, RuntimeDevice } from "@multica/core/types";
|
||||
import {
|
||||
@@ -29,35 +25,6 @@ import { api } from "@multica/core/api";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
|
||||
let nextEnvId = 0;
|
||||
|
||||
interface EnvEntry {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function envMapToEntries(env: Record<string, string>): EnvEntry[] {
|
||||
return Object.entries(env).map(([key, value]) => ({
|
||||
id: nextEnvId++,
|
||||
key,
|
||||
value,
|
||||
visible: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const key = entry.key.trim();
|
||||
if (key) {
|
||||
map[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function SettingsTab({
|
||||
agent,
|
||||
runtimes,
|
||||
@@ -74,9 +41,6 @@ export function SettingsTab({
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(agent.runtime_id);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
|
||||
envMapToEntries(agent.custom_env ?? {})
|
||||
);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -96,31 +60,18 @@ export function SettingsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const currentEnvMap = entriesToEnvMap(envEntries);
|
||||
const originalEnvMap = agent.custom_env ?? {};
|
||||
const envDirty =
|
||||
JSON.stringify(currentEnvMap) !== JSON.stringify(originalEnvMap);
|
||||
|
||||
const dirty =
|
||||
name !== agent.name ||
|
||||
description !== (agent.description ?? "") ||
|
||||
visibility !== agent.visibility ||
|
||||
maxTasks !== agent.max_concurrent_tasks ||
|
||||
selectedRuntimeId !== agent.runtime_id ||
|
||||
envDirty;
|
||||
selectedRuntimeId !== agent.runtime_id;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
// Validate env var keys
|
||||
const keys = envEntries.filter((e) => e.key.trim()).map((e) => e.key.trim());
|
||||
const uniqueKeys = new Set(keys);
|
||||
if (uniqueKeys.size < keys.length) {
|
||||
toast.error("Duplicate environment variable keys");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -130,7 +81,6 @@ export function SettingsTab({
|
||||
visibility,
|
||||
max_concurrent_tasks: maxTasks,
|
||||
runtime_id: selectedRuntimeId,
|
||||
custom_env: currentEnvMap,
|
||||
});
|
||||
toast.success("Settings saved");
|
||||
} catch {
|
||||
@@ -140,34 +90,6 @@ export function SettingsTab({
|
||||
}
|
||||
};
|
||||
|
||||
const addEnvEntry = () => {
|
||||
setEnvEntries([...envEntries, { id: nextEnvId++, key: "", value: "", visible: true }]);
|
||||
};
|
||||
|
||||
const removeEnvEntry = (index: number) => {
|
||||
setEnvEntries(envEntries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEnvEntry = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
val: string
|
||||
) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, [field]: val } : entry
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEnvVisibility = (index: number) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, visible: !entry.visible } : entry
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
<div>
|
||||
@@ -336,71 +258,6 @@ export function SettingsTab({
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Environment Variables</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Injected into the agent process at launch (e.g. ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addEnvEntry}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{envEntries.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{envEntries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEnvEntry(index, "key", e.target.value)}
|
||||
placeholder="KEY"
|
||||
className="w-[40%] font-mono text-xs"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={entry.visible ? "text" : "password"}
|
||||
value={entry.value}
|
||||
onChange={(e) =>
|
||||
updateEnvEntry(index, "value", e.target.value)
|
||||
}
|
||||
placeholder="value"
|
||||
className="pr-8 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnvVisibility(index)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{entry.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvEntry(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Save className="h-3.5 w-3.5 mr-1.5" />}
|
||||
Save Changes
|
||||
|
||||
Reference in New Issue
Block a user