| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543 |
- import * as React from "react";
- import { useTranslation } from "react-i18next";
- import { GitBranch, ListPlus, Minus, Plus, Settings2, Users } from "lucide-react";
- import { listAgents } from "@/api/agents";
- import { createTeam, createTeamConfig, updateTeam, updateTeamConfig } from "@/api";
- import { Button } from "@/components/ui/button";
- import { Dialog } from "@/components/ui/dialog";
- import { Input, Textarea } from "@/components/ui/input";
- import { Select } from "@/components/ui/select";
- import { toast } from "@/components/ui/toaster";
- import { useAuthStore } from "@/stores/auth";
- import type { AgentDefinition, JSONObject, TeamConfig, TeamDefinition } from "@/types";
- type MemberDraft = {
- role: string;
- agent_id: string;
- responsibility: string;
- };
- type PolicyDraft = {
- max_rounds: string;
- handoff: string;
- failure_mode: string;
- };
- const DEFAULT_POLICY: PolicyDraft = {
- max_rounds: "3",
- handoff: "supervisor",
- failure_mode: "stop_on_critical",
- };
- function createDefaultMember(): MemberDraft {
- return { role: "executor", agent_id: "", responsibility: "" };
- }
- export function CreateTeamDialog({
- open,
- onOpenChange,
- onCreated,
- }: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onCreated: (team: TeamDefinition) => void;
- }) {
- const { t } = useTranslation();
- const { userId } = useAuthStore();
- const [form, setForm] = React.useState({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
- const [members, setMembers] = React.useState<MemberDraft[]>([createDefaultMember()]);
- const [policy, setPolicy] = React.useState<PolicyDraft>(DEFAULT_POLICY);
- const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
- const [formError, setFormError] = React.useState<string>();
- const [submitting, setSubmitting] = React.useState(false);
- React.useEffect(() => {
- if (!open) return;
- void listAgents().then(setAgents).catch(() => {});
- }, [open]);
- function reset() {
- setForm({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
- setMembers([createDefaultMember()]);
- setPolicy(DEFAULT_POLICY);
- setFormError(undefined);
- }
- async function submit(event: React.FormEvent) {
- event.preventDefault();
- if (!form.name.trim()) return;
- const configPayload = buildTeamConfig(members, agents, policy, t);
- if (!configPayload.ok) {
- setFormError(configPayload.message);
- return;
- }
- setSubmitting(true);
- setFormError(undefined);
- try {
- const team = await createTeam({ name: form.name, description: form.description || undefined, team_type: "collaborative", owner_user_id: userId });
- await createTeamConfig({
- team_id: team.id,
- coordination_mode: form.coordination_mode,
- objective: form.objective || undefined,
- member_refs: configPayload.memberRefs,
- policy_json: configPayload.policyJson,
- });
- toast.success(t("teams.teamCreated"));
- onOpenChange(false);
- reset();
- onCreated(team);
- } catch (err) {
- toast.error(err instanceof Error ? err.message : t("teams.failedCreateTeam"));
- } finally {
- setSubmitting(false);
- }
- }
- return (
- <Dialog
- open={open}
- onOpenChange={(value) => { if (!value) reset(); onOpenChange(value); }}
- title={t("teams.newTeam")}
- description={t("teams.createTeamDefineObjective")}
- className="max-w-6xl"
- >
- <form className="space-y-4" onSubmit={submit}>
- <div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 sm:grid-cols-3">
- <SummaryPill icon={<GitBranch className="h-4 w-4" />} label={t("teams.coordination")} value={t(`teams.${form.coordination_mode}`)} />
- <SummaryPill icon={<Users className="h-4 w-4" />} label={t("teams.members")} value={String(members.length)} />
- <SummaryPill icon={<Settings2 className="h-4 w-4" />} label={t("teams.maxRoundsField")} value={policy.max_rounds} />
- </div>
- <div className="grid gap-4 xl:grid-cols-[0.95fr_1.25fr]">
- <div className="space-y-4">
- <section className="rounded-md border border-border bg-muted/10 p-4">
- <SectionHeader icon={<ListPlus className="h-4 w-4" />} title={t("teams.basicInfo")} />
- <div className="mt-4 space-y-4">
- <div className="grid gap-4 sm:grid-cols-[1fr_190px]">
- <Field label={t("common.name")}>
- <Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
- </Field>
- <Field label={t("teams.coordinationMode")}>
- <Select
- value={form.coordination_mode}
- onChange={(event) => setForm({ ...form, coordination_mode: event.target.value })}
- options={coordinationModeOptions(t)}
- />
- <p className="text-xs leading-5 text-muted-foreground">
- {coordinationModeDescription(t, form.coordination_mode)}
- </p>
- </Field>
- </div>
- <Field label={t("common.description")}>
- <Textarea className="min-h-20" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
- </Field>
- <Field label={t("teams.objective")}>
- <Textarea className="min-h-24" value={form.objective} placeholder={t("teams.describeObjective")} onChange={(event) => setForm({ ...form, objective: event.target.value })} />
- </Field>
- </div>
- </section>
- <PolicyEditor policy={policy} onChange={setPolicy} />
- </div>
- <MemberEditor members={members} onChange={setMembers} agents={agents} />
- </div>
- {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm text-foreground">{formError}</p> : null}
- <div className="sticky bottom-0 -mx-4 -mb-4 flex justify-end gap-2 border-t border-border bg-surface-elevated/95 px-4 py-4 backdrop-blur sm:-mx-5 sm:-mb-5 sm:px-5">
- <Button type="button" variant="ghost" onClick={() => { reset(); onOpenChange(false); }}>
- {t("common.cancel")}
- </Button>
- <Button disabled={submitting || !form.name.trim()}>{submitting ? t("common.creating") : t("common.create")}</Button>
- </div>
- </form>
- </Dialog>
- );
- }
- function MemberEditor({ members, onChange, agents }: { members: MemberDraft[]; onChange: (members: MemberDraft[]) => void; agents: AgentDefinition[] }) {
- const { t } = useTranslation();
- function update(index: number, patch: Partial<MemberDraft>) {
- onChange(members.map((m, i) => (i === index ? { ...m, ...patch } : m)));
- }
- function remove(index: number) {
- onChange(members.filter((_, i) => i !== index));
- }
- const agentOptions = React.useMemo(
- () => [
- { value: "", label: t("teams.selectAgent") },
- ...agents.map((a) => ({ value: a.id, label: a.name })),
- ],
- [agents, t],
- );
- return (
- <section className="space-y-4 rounded-md border border-border bg-muted/10 p-4">
- <SectionHeader
- icon={<Users className="h-4 w-4" />}
- title={t("teams.members")}
- action={
- <Button type="button" size="sm" variant="outline" onClick={() => onChange([...members, createDefaultMember()])}>
- <Plus className="h-4 w-4" /> {t("teams.addMember")}
- </Button>
- }
- />
- {members.length ? (
- <div className="max-h-[520px] space-y-3 overflow-auto pr-1">
- {members.map((member, index) => (
- <div key={index} className="rounded-md border border-border bg-surface-elevated px-4 py-3 shadow-sm">
- <div className="mb-3 flex items-center justify-between gap-3">
- <p className="text-sm font-medium">{t("teams.member")} {index + 1}</p>
- <Button
- type="button"
- size="icon"
- variant="ghost"
- className="h-8 w-8 shrink-0 text-muted-foreground hover:text-red-500"
- disabled={members.length <= 1}
- onClick={() => remove(index)}
- aria-label={t("teams.remove")}
- >
- <Minus className="h-4 w-4" />
- </Button>
- </div>
- <div className="grid gap-3 lg:grid-cols-[180px_1fr]">
- <Field label={t("teams.role")}>
- <Select
- aria-label={`${t("teams.role")} ${index + 1}`}
- value={member.role}
- onChange={(event) => update(index, { role: event.target.value })}
- options={[
- { value: "supervisor", label: t("teams.supervisor") },
- { value: "executor", label: t("teams.executor") },
- { value: "reviewer", label: t("teams.reviewer") },
- { value: "planner", label: t("teams.planner") },
- ]}
- />
- </Field>
- <Field label={t("teams.selectAgent")}>
- <Select
- aria-label={`${t("teams.agent")} ${index + 1}`}
- value={member.agent_id}
- onChange={(event) => update(index, { agent_id: event.target.value })}
- options={agentOptions}
- />
- </Field>
- </div>
- <Field label={t("teams.responsibility")}>
- <Textarea
- className="min-h-16"
- aria-label={`${t("teams.responsibility")} ${index + 1}`}
- value={member.responsibility}
- placeholder={t("teams.responsibility")}
- onChange={(event) => update(index, { responsibility: event.target.value })}
- />
- </Field>
- </div>
- ))}
- </div>
- ) : (
- <div className="rounded-md border border-dashed border-border py-8 text-center text-sm text-muted-foreground">
- {t("teams.noMembersHint")}
- </div>
- )}
- </section>
- );
- }
- function PolicyEditor({ policy, onChange }: { policy: PolicyDraft; onChange: (policy: PolicyDraft) => void }) {
- const { t } = useTranslation();
- return (
- <section className="space-y-4 rounded-md border border-border bg-muted/10 p-4">
- <SectionHeader icon={<Settings2 className="h-4 w-4" />} title={t("teams.policy")} />
- <div className="grid gap-4">
- <Field label={t("teams.maxRoundsField")}>
- <Input type="number" min={1} max={20} value={policy.max_rounds} onChange={(event) => onChange({ ...policy, max_rounds: event.target.value })} />
- </Field>
- <Field label={t("teams.handoff")}>
- <Select
- value={policy.handoff}
- onChange={(event) => onChange({ ...policy, handoff: event.target.value })}
- options={[
- { value: "supervisor", label: t("teams.supervisor") },
- { value: "round_robin", label: t("teams.roundRobin") },
- { value: "parallel_merge", label: t("teams.parallelMerge") },
- ]}
- />
- </Field>
- <Field label={t("teams.failureMode")}>
- <Select
- value={policy.failure_mode}
- onChange={(event) => onChange({ ...policy, failure_mode: event.target.value })}
- options={[
- { value: "stop_on_critical", label: t("teams.stopOnCritical") },
- { value: "continue_with_warning", label: t("teams.continueWithWarning") },
- { value: "retry_once", label: t("teams.retryOnce") },
- ]}
- />
- </Field>
- </div>
- </section>
- );
- }
- function SectionHeader({ icon, title, action }: { icon: React.ReactNode; title: string; action?: React.ReactNode }) {
- return (
- <div className="flex items-center justify-between gap-3">
- <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-1.5">
- <div className="grid h-6 w-6 shrink-0 place-items-center rounded bg-primary/15 text-primary">{icon}</div>
- <span className="text-sm font-medium">{title}</span>
- </div>
- {action}
- </div>
- );
- }
- export function EditTeamDialog({
- open,
- onOpenChange,
- team,
- activeConfig,
- onUpdated,
- }: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- team?: TeamDefinition;
- activeConfig?: TeamConfig;
- onUpdated: (team: TeamDefinition) => void;
- }) {
- const { t } = useTranslation();
- const [form, setForm] = React.useState({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
- const [members, setMembers] = React.useState<MemberDraft[]>([createDefaultMember()]);
- const [policy, setPolicy] = React.useState<PolicyDraft>(DEFAULT_POLICY);
- const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
- const [formError, setFormError] = React.useState<string>();
- const [submitting, setSubmitting] = React.useState(false);
- React.useEffect(() => {
- if (!open) return;
- void listAgents().then(setAgents).catch(() => {});
- }, [open]);
- React.useEffect(() => {
- if (!open || !team) return;
- setForm({
- name: team.name,
- description: team.description ?? "",
- coordination_mode: activeConfig?.coordination_mode ?? "supervisor",
- objective: activeConfig?.objective ?? "",
- });
- setMembers(readMemberDrafts(activeConfig));
- setPolicy(readPolicyDraft(activeConfig));
- setFormError(undefined);
- }, [activeConfig, open, team]);
- async function submit(event: React.FormEvent) {
- event.preventDefault();
- if (!team || !form.name.trim()) return;
- const configPayload = buildTeamConfig(members, agents, policy, t);
- if (!configPayload.ok) {
- setFormError(configPayload.message);
- return;
- }
- setSubmitting(true);
- setFormError(undefined);
- try {
- const updatedTeam = await updateTeam(team.id, {
- name: form.name,
- description: form.description || null,
- team_type: team.team_type,
- owner_user_id: team.owner_user_id,
- });
- if (activeConfig) {
- await updateTeamConfig(activeConfig.id, {
- coordination_mode: form.coordination_mode,
- objective: form.objective || null,
- member_refs: configPayload.memberRefs,
- policy_json: configPayload.policyJson,
- });
- } else {
- await createTeamConfig({
- team_id: team.id,
- coordination_mode: form.coordination_mode,
- objective: form.objective || undefined,
- member_refs: configPayload.memberRefs,
- policy_json: configPayload.policyJson,
- });
- }
- toast.success(t("teams.teamUpdated"));
- onOpenChange(false);
- onUpdated(updatedTeam);
- } catch (err) {
- toast.error(err instanceof Error ? err.message : t("teams.failedUpdateTeam"));
- } finally {
- setSubmitting(false);
- }
- }
- return (
- <Dialog
- open={open}
- onOpenChange={onOpenChange}
- title={t("teams.editTeam")}
- description={team?.name ?? t("teams.teamDetails")}
- className="max-w-6xl"
- >
- <form className="space-y-4" onSubmit={submit}>
- <div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 sm:grid-cols-3">
- <SummaryPill icon={<GitBranch className="h-4 w-4" />} label={t("teams.coordination")} value={t(`teams.${form.coordination_mode}`)} />
- <SummaryPill icon={<Users className="h-4 w-4" />} label={t("teams.members")} value={String(members.length)} />
- <SummaryPill icon={<Settings2 className="h-4 w-4" />} label={t("teams.maxRoundsField")} value={policy.max_rounds} />
- </div>
- <div className="grid gap-4 xl:grid-cols-[0.95fr_1.25fr]">
- <div className="space-y-4">
- <section className="rounded-md border border-border bg-muted/10 p-4">
- <SectionHeader icon={<ListPlus className="h-4 w-4" />} title={t("teams.basicInfo")} />
- <div className="mt-4 space-y-4">
- <div className="grid gap-4 sm:grid-cols-[1fr_190px]">
- <Field label={t("common.name")}>
- <Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
- </Field>
- <Field label={t("teams.coordinationMode")}>
- <Select
- value={form.coordination_mode}
- onChange={(event) => setForm({ ...form, coordination_mode: event.target.value })}
- options={coordinationModeOptions(t)}
- />
- <p className="text-xs leading-5 text-muted-foreground">
- {coordinationModeDescription(t, form.coordination_mode)}
- </p>
- </Field>
- </div>
- <Field label={t("common.description")}>
- <Textarea className="min-h-20" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
- </Field>
- <Field label={t("teams.objective")}>
- <Textarea className="min-h-24" value={form.objective} placeholder={t("teams.describeObjective")} onChange={(event) => setForm({ ...form, objective: event.target.value })} />
- </Field>
- </div>
- </section>
- <PolicyEditor policy={policy} onChange={setPolicy} />
- </div>
- <MemberEditor members={members} onChange={setMembers} agents={agents} />
- </div>
- {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm text-foreground">{formError}</p> : null}
- <div className="sticky bottom-0 -mx-4 -mb-4 flex justify-end gap-2 border-t border-border bg-surface-elevated/95 px-4 py-4 backdrop-blur sm:-mx-5 sm:-mb-5 sm:px-5">
- <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
- {t("common.cancel")}
- </Button>
- <Button disabled={submitting || !form.name.trim()}>{submitting ? t("common.saving") : t("common.save")}</Button>
- </div>
- </form>
- </Dialog>
- );
- }
- function SummaryPill({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
- return (
- <div className="flex items-center gap-3 rounded-md border border-border bg-surface-elevated px-3 py-2">
- <div className="grid h-8 w-8 shrink-0 place-items-center rounded bg-primary/15 text-primary">{icon}</div>
- <div className="min-w-0">
- <p className="text-xs text-muted-foreground">{label}</p>
- <p className="truncate text-sm font-medium">{value}</p>
- </div>
- </div>
- );
- }
- function coordinationModeOptions(t: (key: string) => string) {
- return [
- { value: "supervisor", label: t("teams.coordinationSupervisorOption") },
- { value: "pipeline", label: t("teams.coordinationPipelineOption") },
- { value: "parallel", label: t("teams.coordinationParallelOption") },
- { value: "debate", label: t("teams.coordinationDebateOption") },
- ];
- }
- function coordinationModeDescription(t: (key: string) => string, mode: string) {
- const keyByMode: Record<string, string> = {
- supervisor: "coordinationSupervisorDescription",
- pipeline: "coordinationPipelineDescription",
- parallel: "coordinationParallelDescription",
- debate: "coordinationDebateDescription",
- };
- return t(`teams.${keyByMode[mode] ?? keyByMode.supervisor}`);
- }
- function Field({ label, children }: { label: string; children: React.ReactNode }) {
- return (
- <label className="block space-y-1.5 text-sm">
- <span className="text-muted-foreground">{label}</span>
- {children}
- </label>
- );
- }
- function buildTeamConfig(
- members: MemberDraft[],
- agents: AgentDefinition[],
- policy: PolicyDraft,
- t: (key: string) => string,
- ):
- | { ok: true; memberRefs: JSONObject[]; policyJson: JSONObject }
- | { ok: false; message: string } {
- const agentNameById = new Map(agents.map((agent) => [agent.id, agent.name]));
- const normalizedMembers = members
- .map((m) => {
- const agentId = m.agent_id.trim();
- const agentName = agentNameById.get(agentId);
- const member: JSONObject = {
- role: m.role.trim(),
- agent_id: agentId,
- responsibility: m.responsibility.trim(),
- };
- if (agentName) {
- member.name = agentName;
- member.agent_name = agentName;
- }
- return member;
- })
- .filter((m) => m.role || m.agent_id || m.responsibility);
- if (!normalizedMembers.length) return { ok: false, message: t("teams.addAtLeastOneMember") };
- if (normalizedMembers.some((m) => !m.agent_id)) return { ok: false, message: t("teams.eachMemberNeedsAgentId") };
- const maxRounds = Number(policy.max_rounds);
- if (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 20) return { ok: false, message: t("teams.maxRoundsMustBe") };
- return {
- ok: true,
- memberRefs: normalizedMembers,
- policyJson: { max_rounds: maxRounds, handoff: policy.handoff, failure_mode: policy.failure_mode },
- };
- }
- function readMemberDrafts(activeConfig?: TeamConfig): MemberDraft[] {
- if (!activeConfig?.member_refs_json.length) return [createDefaultMember()];
- return activeConfig.member_refs_json.map((member) => ({
- role: readString(member, "role") ?? "executor",
- agent_id: readString(member, "agent_id") ?? readString(member, "agentId") ?? "",
- responsibility: readString(member, "responsibility") ?? readString(member, "description") ?? "",
- }));
- }
- function readPolicyDraft(activeConfig?: TeamConfig): PolicyDraft {
- if (!activeConfig) return DEFAULT_POLICY;
- return {
- max_rounds: readString(activeConfig.policy_json, "max_rounds") ?? DEFAULT_POLICY.max_rounds,
- handoff: readString(activeConfig.policy_json, "handoff") ?? DEFAULT_POLICY.handoff,
- failure_mode: readString(activeConfig.policy_json, "failure_mode") ?? DEFAULT_POLICY.failure_mode,
- };
- }
- function readString(value: JSONObject, key: string) {
- const item = value[key];
- if (typeof item === "string" && item.trim()) return item;
- if (typeof item === "number" || typeof item === "boolean") return String(item);
- return undefined;
- }
|