mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
Merge pull request #964 from multica-ai/feat/agent-env-tab
feat(views): extract environment variables into separate agent tab
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