web-client-vite/src/components/AgentModal.tsx

316 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from "react";
import type { Member, Label } from "@/lib/api";
import { updateMember, regenerateToken, revokeToken, getLabels } from "@/lib/api";
interface Props {
agent: Member;
onClose: () => void;
onUpdated: (agent: Member) => void;
}
export default function AgentModal({ agent, onClose, onUpdated }: Props) {
const [name, setName] = useState(agent.name);
const [slug, setSlug] = useState(agent.slug);
const [slugError, setSlugError] = useState("");
const [capabilities, setCapabilities] = useState(
agent.agent_config?.capabilities?.join(", ") || ""
);
const [chatListen, setChatListen] = useState(agent.agent_config?.chat_listen || "mentions");
const [taskListen, setTaskListen] = useState(agent.agent_config?.task_listen || "assigned");
const [prompt, setPrompt] = useState(agent.agent_config?.prompt || "");
const [model, setModel] = useState(agent.agent_config?.model || "");
const [saving, setSaving] = useState(false);
const [currentToken, setCurrentToken] = useState<string | null>(agent.token || null);
const [copied, setCopied] = useState(false);
const [confirmRegen, setConfirmRegen] = useState(false);
const [confirmRevoke, setConfirmRevoke] = useState(false);
const [allLabels, setAllLabels] = useState<Label[]>([]);
const [agentLabels, setAgentLabels] = useState<string[]>(agent.agent_config?.labels || []);
useEffect(() => {
getLabels().then(setAllLabels).catch(() => {});
}, []);
const handleSave = async () => {
setSaving(true);
try {
const caps = capabilities.split(",").map((c) => c.trim()).filter(Boolean);
const trimmedSlug = slug.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '');
if (!trimmedSlug) {
setSlugError("Slug не может быть пустым");
return;
}
const updated = await updateMember(agent.id, {
name: name.trim(),
slug: trimmedSlug !== agent.slug ? trimmedSlug : undefined,
agent_config: {
capabilities: caps,
labels: agentLabels,
chat_listen: chatListen,
task_listen: taskListen,
prompt: prompt.trim() || null,
model: model.trim() || null,
},
});
onUpdated(updated);
onClose();
} catch (e: any) {
if (e?.response?.status === 409) {
setSlugError("Этот slug уже занят");
} else {
console.error(e);
}
} finally {
setSaving(false);
}
};
const handleRegenerate = async () => {
try {
const { token } = await regenerateToken(agent.id);
setCurrentToken(token);
setConfirmRegen(false);
} catch (e) {
console.error(e);
}
};
const handleRevoke = async () => {
try {
await revokeToken(agent.id);
setCurrentToken(null);
setConfirmRevoke(false);
} catch (e) {
console.error(e);
}
};
const copyToken = () => {
if (currentToken) {
navigator.clipboard.writeText(currentToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-[var(--card)] border border-[var(--border)] rounded-xl w-full max-w-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="p-5 border-b border-[var(--border)] flex items-center justify-between">
<div>
<h2 className="text-lg font-bold">{agent.name}</h2>
<div className="text-xs text-[var(--muted)]">@{agent.slug}</div>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
agent.status === "online"
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
}`}
>
{agent.status === "online" ? "online" : "offline"}
</span>
</div>
<div className="p-5 space-y-4">
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Название</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Slug</label>
<input
value={slug}
onChange={(e) => { setSlug(e.target.value); setSlugError(""); }}
className={`w-full px-3 py-2 bg-[var(--bg)] border rounded-lg outline-none text-sm ${slugError ? 'border-red-500' : 'border-[var(--border)] focus:border-[var(--accent)]'}`}
placeholder="agent-slug"
/>
{slugError && <div className="text-xs text-red-400 mt-1">{slugError}</div>}
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Capabilities</label>
<input
value={capabilities}
onChange={(e) => setCapabilities(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
placeholder="code, review, deploy"
/>
</div>
{/* Labels */}
{allLabels.length > 0 && (
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Лейблы</label>
<div className="flex flex-wrap gap-1">
{allLabels.map((l) => (
<button
key={l.id}
onClick={() => {
setAgentLabels(prev =>
prev.includes(l.name) ? prev.filter(n => n !== l.name) : [...prev, l.name]
);
}}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors border ${
agentLabels.includes(l.name)
? "border-[var(--accent)] bg-[var(--accent)]/20"
: "border-[var(--border)] bg-white/5 text-[var(--muted)] hover:bg-white/10"
}`}
>
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: l.color }} />
{l.name}
</button>
))}
</div>
<p className="text-[10px] text-[var(--muted)] mt-1">Для авто-назначения: лейблы задачи лейблы агента</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Chat listen</label>
<select
value={chatListen}
onChange={(e) => setChatListen(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="all">all</option>
<option value="mentions">mentions</option>
<option value="none">none</option>
</select>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Task listen</label>
<select
value={taskListen}
onChange={(e) => setTaskListen(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
>
<option value="all">all</option>
<option value="assigned">assigned</option>
<option value="none">none</option>
</select>
</div>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Prompt</label>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm resize-y min-h-[60px]"
rows={3}
placeholder="Системный промпт агента..."
/>
</div>
<div>
<label className="block text-sm text-[var(--muted)] mb-1">Модель</label>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded-lg outline-none focus:border-[var(--accent)] text-sm"
placeholder="claude-sonnet-4-20250514, gpt-4o, ..."
/>
</div>
{/* Token section */}
<div className="pt-3 border-t border-[var(--border)]">
<div className="text-sm text-[var(--muted)] mb-2">Токен доступа</div>
{currentToken ? (
<div>
<div className="p-3 bg-[var(--bg)] border border-[var(--border)] rounded-lg font-mono text-xs break-all select-all mb-2">
{currentToken}
</div>
<div className="flex items-center gap-2">
<button
onClick={copyToken}
className="px-3 py-1.5 bg-[var(--accent)] text-white rounded text-xs hover:opacity-90"
>
{copied ? "Скопировано!" : "Скопировать"}
</button>
{confirmRegen ? (
<>
<button
onClick={handleRegenerate}
className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded text-xs hover:bg-yellow-500/30"
>
Да, новый
</button>
<button onClick={() => setConfirmRegen(false)} className="text-xs text-[var(--muted)]">
Отмена
</button>
</>
) : (
<button
onClick={() => setConfirmRegen(true)}
className="text-xs text-[var(--muted)] hover:text-[var(--fg)]"
>
Перегенерировать
</button>
)}
{confirmRevoke ? (
<>
<button
onClick={handleRevoke}
className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded text-xs hover:bg-red-500/30"
>
Да, отозвать
</button>
<button onClick={() => setConfirmRevoke(false)} className="text-xs text-[var(--muted)]">
Отмена
</button>
</>
) : (
<button
onClick={() => setConfirmRevoke(true)}
className="text-xs text-red-400/60 hover:text-red-400"
>
Отозвать
</button>
)}
</div>
</div>
) : (
<div className="flex items-center gap-3">
<span className="text-xs text-[var(--muted)]">Токен отозван</span>
<button
onClick={handleRegenerate}
className="text-xs text-[var(--accent)] hover:underline"
>
Сгенерировать новый
</button>
</div>
)}
</div>
</div>
<div className="p-5 border-t border-[var(--border)] flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
Отмена
</button>
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
>
{saving ? "..." : "Сохранить"}
</button>
</div>
</div>
</div>
);
}