CreateTeamDialog.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. import * as React from "react";
  2. import { useTranslation } from "react-i18next";
  3. import { GitBranch, ListPlus, Minus, Plus, Settings2, Users } from "lucide-react";
  4. import { listAgents } from "@/api/agents";
  5. import { createTeam, createTeamConfig, updateTeam, updateTeamConfig } from "@/api";
  6. import { Button } from "@/components/ui/button";
  7. import { Dialog } from "@/components/ui/dialog";
  8. import { Input, Textarea } from "@/components/ui/input";
  9. import { Select } from "@/components/ui/select";
  10. import { toast } from "@/components/ui/toaster";
  11. import { useAuthStore } from "@/stores/auth";
  12. import type { AgentDefinition, JSONObject, TeamConfig, TeamDefinition } from "@/types";
  13. type MemberDraft = {
  14. role: string;
  15. agent_id: string;
  16. responsibility: string;
  17. };
  18. type PolicyDraft = {
  19. max_rounds: string;
  20. handoff: string;
  21. failure_mode: string;
  22. };
  23. const DEFAULT_POLICY: PolicyDraft = {
  24. max_rounds: "3",
  25. handoff: "supervisor",
  26. failure_mode: "stop_on_critical",
  27. };
  28. function createDefaultMember(): MemberDraft {
  29. return { role: "executor", agent_id: "", responsibility: "" };
  30. }
  31. export function CreateTeamDialog({
  32. open,
  33. onOpenChange,
  34. onCreated,
  35. }: {
  36. open: boolean;
  37. onOpenChange: (open: boolean) => void;
  38. onCreated: (team: TeamDefinition) => void;
  39. }) {
  40. const { t } = useTranslation();
  41. const { userId } = useAuthStore();
  42. const [form, setForm] = React.useState({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
  43. const [members, setMembers] = React.useState<MemberDraft[]>([createDefaultMember()]);
  44. const [policy, setPolicy] = React.useState<PolicyDraft>(DEFAULT_POLICY);
  45. const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
  46. const [formError, setFormError] = React.useState<string>();
  47. const [submitting, setSubmitting] = React.useState(false);
  48. React.useEffect(() => {
  49. if (!open) return;
  50. void listAgents().then(setAgents).catch(() => {});
  51. }, [open]);
  52. function reset() {
  53. setForm({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
  54. setMembers([createDefaultMember()]);
  55. setPolicy(DEFAULT_POLICY);
  56. setFormError(undefined);
  57. }
  58. async function submit(event: React.FormEvent) {
  59. event.preventDefault();
  60. if (!form.name.trim()) return;
  61. const configPayload = buildTeamConfig(members, agents, policy, t);
  62. if (!configPayload.ok) {
  63. setFormError(configPayload.message);
  64. return;
  65. }
  66. setSubmitting(true);
  67. setFormError(undefined);
  68. try {
  69. const team = await createTeam({ name: form.name, description: form.description || undefined, team_type: "collaborative", owner_user_id: userId });
  70. await createTeamConfig({
  71. team_id: team.id,
  72. coordination_mode: form.coordination_mode,
  73. objective: form.objective || undefined,
  74. member_refs: configPayload.memberRefs,
  75. policy_json: configPayload.policyJson,
  76. });
  77. toast.success(t("teams.teamCreated"));
  78. onOpenChange(false);
  79. reset();
  80. onCreated(team);
  81. } catch (err) {
  82. toast.error(err instanceof Error ? err.message : t("teams.failedCreateTeam"));
  83. } finally {
  84. setSubmitting(false);
  85. }
  86. }
  87. return (
  88. <Dialog
  89. open={open}
  90. onOpenChange={(value) => { if (!value) reset(); onOpenChange(value); }}
  91. title={t("teams.newTeam")}
  92. description={t("teams.createTeamDefineObjective")}
  93. className="max-w-6xl"
  94. >
  95. <form className="space-y-4" onSubmit={submit}>
  96. <div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 sm:grid-cols-3">
  97. <SummaryPill icon={<GitBranch className="h-4 w-4" />} label={t("teams.coordination")} value={t(`teams.${form.coordination_mode}`)} />
  98. <SummaryPill icon={<Users className="h-4 w-4" />} label={t("teams.members")} value={String(members.length)} />
  99. <SummaryPill icon={<Settings2 className="h-4 w-4" />} label={t("teams.maxRoundsField")} value={policy.max_rounds} />
  100. </div>
  101. <div className="grid gap-4 xl:grid-cols-[0.95fr_1.25fr]">
  102. <div className="space-y-4">
  103. <section className="rounded-md border border-border bg-muted/10 p-4">
  104. <SectionHeader icon={<ListPlus className="h-4 w-4" />} title={t("teams.basicInfo")} />
  105. <div className="mt-4 space-y-4">
  106. <div className="grid gap-4 sm:grid-cols-[1fr_190px]">
  107. <Field label={t("common.name")}>
  108. <Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
  109. </Field>
  110. <Field label={t("teams.coordinationMode")}>
  111. <Select
  112. value={form.coordination_mode}
  113. onChange={(event) => setForm({ ...form, coordination_mode: event.target.value })}
  114. options={coordinationModeOptions(t)}
  115. />
  116. <p className="text-xs leading-5 text-muted-foreground">
  117. {coordinationModeDescription(t, form.coordination_mode)}
  118. </p>
  119. </Field>
  120. </div>
  121. <Field label={t("common.description")}>
  122. <Textarea className="min-h-20" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
  123. </Field>
  124. <Field label={t("teams.objective")}>
  125. <Textarea className="min-h-24" value={form.objective} placeholder={t("teams.describeObjective")} onChange={(event) => setForm({ ...form, objective: event.target.value })} />
  126. </Field>
  127. </div>
  128. </section>
  129. <PolicyEditor policy={policy} onChange={setPolicy} />
  130. </div>
  131. <MemberEditor members={members} onChange={setMembers} agents={agents} />
  132. </div>
  133. {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm text-foreground">{formError}</p> : null}
  134. <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">
  135. <Button type="button" variant="ghost" onClick={() => { reset(); onOpenChange(false); }}>
  136. {t("common.cancel")}
  137. </Button>
  138. <Button disabled={submitting || !form.name.trim()}>{submitting ? t("common.creating") : t("common.create")}</Button>
  139. </div>
  140. </form>
  141. </Dialog>
  142. );
  143. }
  144. function MemberEditor({ members, onChange, agents }: { members: MemberDraft[]; onChange: (members: MemberDraft[]) => void; agents: AgentDefinition[] }) {
  145. const { t } = useTranslation();
  146. function update(index: number, patch: Partial<MemberDraft>) {
  147. onChange(members.map((m, i) => (i === index ? { ...m, ...patch } : m)));
  148. }
  149. function remove(index: number) {
  150. onChange(members.filter((_, i) => i !== index));
  151. }
  152. const agentOptions = React.useMemo(
  153. () => [
  154. { value: "", label: t("teams.selectAgent") },
  155. ...agents.map((a) => ({ value: a.id, label: a.name })),
  156. ],
  157. [agents, t],
  158. );
  159. return (
  160. <section className="space-y-4 rounded-md border border-border bg-muted/10 p-4">
  161. <SectionHeader
  162. icon={<Users className="h-4 w-4" />}
  163. title={t("teams.members")}
  164. action={
  165. <Button type="button" size="sm" variant="outline" onClick={() => onChange([...members, createDefaultMember()])}>
  166. <Plus className="h-4 w-4" /> {t("teams.addMember")}
  167. </Button>
  168. }
  169. />
  170. {members.length ? (
  171. <div className="max-h-[520px] space-y-3 overflow-auto pr-1">
  172. {members.map((member, index) => (
  173. <div key={index} className="rounded-md border border-border bg-surface-elevated px-4 py-3 shadow-sm">
  174. <div className="mb-3 flex items-center justify-between gap-3">
  175. <p className="text-sm font-medium">{t("teams.member")} {index + 1}</p>
  176. <Button
  177. type="button"
  178. size="icon"
  179. variant="ghost"
  180. className="h-8 w-8 shrink-0 text-muted-foreground hover:text-red-500"
  181. disabled={members.length <= 1}
  182. onClick={() => remove(index)}
  183. aria-label={t("teams.remove")}
  184. >
  185. <Minus className="h-4 w-4" />
  186. </Button>
  187. </div>
  188. <div className="grid gap-3 lg:grid-cols-[180px_1fr]">
  189. <Field label={t("teams.role")}>
  190. <Select
  191. aria-label={`${t("teams.role")} ${index + 1}`}
  192. value={member.role}
  193. onChange={(event) => update(index, { role: event.target.value })}
  194. options={[
  195. { value: "supervisor", label: t("teams.supervisor") },
  196. { value: "executor", label: t("teams.executor") },
  197. { value: "reviewer", label: t("teams.reviewer") },
  198. { value: "planner", label: t("teams.planner") },
  199. ]}
  200. />
  201. </Field>
  202. <Field label={t("teams.selectAgent")}>
  203. <Select
  204. aria-label={`${t("teams.agent")} ${index + 1}`}
  205. value={member.agent_id}
  206. onChange={(event) => update(index, { agent_id: event.target.value })}
  207. options={agentOptions}
  208. />
  209. </Field>
  210. </div>
  211. <Field label={t("teams.responsibility")}>
  212. <Textarea
  213. className="min-h-16"
  214. aria-label={`${t("teams.responsibility")} ${index + 1}`}
  215. value={member.responsibility}
  216. placeholder={t("teams.responsibility")}
  217. onChange={(event) => update(index, { responsibility: event.target.value })}
  218. />
  219. </Field>
  220. </div>
  221. ))}
  222. </div>
  223. ) : (
  224. <div className="rounded-md border border-dashed border-border py-8 text-center text-sm text-muted-foreground">
  225. {t("teams.noMembersHint")}
  226. </div>
  227. )}
  228. </section>
  229. );
  230. }
  231. function PolicyEditor({ policy, onChange }: { policy: PolicyDraft; onChange: (policy: PolicyDraft) => void }) {
  232. const { t } = useTranslation();
  233. return (
  234. <section className="space-y-4 rounded-md border border-border bg-muted/10 p-4">
  235. <SectionHeader icon={<Settings2 className="h-4 w-4" />} title={t("teams.policy")} />
  236. <div className="grid gap-4">
  237. <Field label={t("teams.maxRoundsField")}>
  238. <Input type="number" min={1} max={20} value={policy.max_rounds} onChange={(event) => onChange({ ...policy, max_rounds: event.target.value })} />
  239. </Field>
  240. <Field label={t("teams.handoff")}>
  241. <Select
  242. value={policy.handoff}
  243. onChange={(event) => onChange({ ...policy, handoff: event.target.value })}
  244. options={[
  245. { value: "supervisor", label: t("teams.supervisor") },
  246. { value: "round_robin", label: t("teams.roundRobin") },
  247. { value: "parallel_merge", label: t("teams.parallelMerge") },
  248. ]}
  249. />
  250. </Field>
  251. <Field label={t("teams.failureMode")}>
  252. <Select
  253. value={policy.failure_mode}
  254. onChange={(event) => onChange({ ...policy, failure_mode: event.target.value })}
  255. options={[
  256. { value: "stop_on_critical", label: t("teams.stopOnCritical") },
  257. { value: "continue_with_warning", label: t("teams.continueWithWarning") },
  258. { value: "retry_once", label: t("teams.retryOnce") },
  259. ]}
  260. />
  261. </Field>
  262. </div>
  263. </section>
  264. );
  265. }
  266. function SectionHeader({ icon, title, action }: { icon: React.ReactNode; title: string; action?: React.ReactNode }) {
  267. return (
  268. <div className="flex items-center justify-between gap-3">
  269. <div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-1.5">
  270. <div className="grid h-6 w-6 shrink-0 place-items-center rounded bg-primary/15 text-primary">{icon}</div>
  271. <span className="text-sm font-medium">{title}</span>
  272. </div>
  273. {action}
  274. </div>
  275. );
  276. }
  277. export function EditTeamDialog({
  278. open,
  279. onOpenChange,
  280. team,
  281. activeConfig,
  282. onUpdated,
  283. }: {
  284. open: boolean;
  285. onOpenChange: (open: boolean) => void;
  286. team?: TeamDefinition;
  287. activeConfig?: TeamConfig;
  288. onUpdated: (team: TeamDefinition) => void;
  289. }) {
  290. const { t } = useTranslation();
  291. const [form, setForm] = React.useState({ name: "", description: "", coordination_mode: "supervisor", objective: "" });
  292. const [members, setMembers] = React.useState<MemberDraft[]>([createDefaultMember()]);
  293. const [policy, setPolicy] = React.useState<PolicyDraft>(DEFAULT_POLICY);
  294. const [agents, setAgents] = React.useState<AgentDefinition[]>([]);
  295. const [formError, setFormError] = React.useState<string>();
  296. const [submitting, setSubmitting] = React.useState(false);
  297. React.useEffect(() => {
  298. if (!open) return;
  299. void listAgents().then(setAgents).catch(() => {});
  300. }, [open]);
  301. React.useEffect(() => {
  302. if (!open || !team) return;
  303. setForm({
  304. name: team.name,
  305. description: team.description ?? "",
  306. coordination_mode: activeConfig?.coordination_mode ?? "supervisor",
  307. objective: activeConfig?.objective ?? "",
  308. });
  309. setMembers(readMemberDrafts(activeConfig));
  310. setPolicy(readPolicyDraft(activeConfig));
  311. setFormError(undefined);
  312. }, [activeConfig, open, team]);
  313. async function submit(event: React.FormEvent) {
  314. event.preventDefault();
  315. if (!team || !form.name.trim()) return;
  316. const configPayload = buildTeamConfig(members, agents, policy, t);
  317. if (!configPayload.ok) {
  318. setFormError(configPayload.message);
  319. return;
  320. }
  321. setSubmitting(true);
  322. setFormError(undefined);
  323. try {
  324. const updatedTeam = await updateTeam(team.id, {
  325. name: form.name,
  326. description: form.description || null,
  327. team_type: team.team_type,
  328. owner_user_id: team.owner_user_id,
  329. });
  330. if (activeConfig) {
  331. await updateTeamConfig(activeConfig.id, {
  332. coordination_mode: form.coordination_mode,
  333. objective: form.objective || null,
  334. member_refs: configPayload.memberRefs,
  335. policy_json: configPayload.policyJson,
  336. });
  337. } else {
  338. await createTeamConfig({
  339. team_id: team.id,
  340. coordination_mode: form.coordination_mode,
  341. objective: form.objective || undefined,
  342. member_refs: configPayload.memberRefs,
  343. policy_json: configPayload.policyJson,
  344. });
  345. }
  346. toast.success(t("teams.teamUpdated"));
  347. onOpenChange(false);
  348. onUpdated(updatedTeam);
  349. } catch (err) {
  350. toast.error(err instanceof Error ? err.message : t("teams.failedUpdateTeam"));
  351. } finally {
  352. setSubmitting(false);
  353. }
  354. }
  355. return (
  356. <Dialog
  357. open={open}
  358. onOpenChange={onOpenChange}
  359. title={t("teams.editTeam")}
  360. description={team?.name ?? t("teams.teamDetails")}
  361. className="max-w-6xl"
  362. >
  363. <form className="space-y-4" onSubmit={submit}>
  364. <div className="grid gap-3 rounded-md border border-border bg-muted/20 p-3 sm:grid-cols-3">
  365. <SummaryPill icon={<GitBranch className="h-4 w-4" />} label={t("teams.coordination")} value={t(`teams.${form.coordination_mode}`)} />
  366. <SummaryPill icon={<Users className="h-4 w-4" />} label={t("teams.members")} value={String(members.length)} />
  367. <SummaryPill icon={<Settings2 className="h-4 w-4" />} label={t("teams.maxRoundsField")} value={policy.max_rounds} />
  368. </div>
  369. <div className="grid gap-4 xl:grid-cols-[0.95fr_1.25fr]">
  370. <div className="space-y-4">
  371. <section className="rounded-md border border-border bg-muted/10 p-4">
  372. <SectionHeader icon={<ListPlus className="h-4 w-4" />} title={t("teams.basicInfo")} />
  373. <div className="mt-4 space-y-4">
  374. <div className="grid gap-4 sm:grid-cols-[1fr_190px]">
  375. <Field label={t("common.name")}>
  376. <Input required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
  377. </Field>
  378. <Field label={t("teams.coordinationMode")}>
  379. <Select
  380. value={form.coordination_mode}
  381. onChange={(event) => setForm({ ...form, coordination_mode: event.target.value })}
  382. options={coordinationModeOptions(t)}
  383. />
  384. <p className="text-xs leading-5 text-muted-foreground">
  385. {coordinationModeDescription(t, form.coordination_mode)}
  386. </p>
  387. </Field>
  388. </div>
  389. <Field label={t("common.description")}>
  390. <Textarea className="min-h-20" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
  391. </Field>
  392. <Field label={t("teams.objective")}>
  393. <Textarea className="min-h-24" value={form.objective} placeholder={t("teams.describeObjective")} onChange={(event) => setForm({ ...form, objective: event.target.value })} />
  394. </Field>
  395. </div>
  396. </section>
  397. <PolicyEditor policy={policy} onChange={setPolicy} />
  398. </div>
  399. <MemberEditor members={members} onChange={setMembers} agents={agents} />
  400. </div>
  401. {formError ? <p className="rounded-md border border-red-500/25 bg-red-500/5 p-3 text-sm text-foreground">{formError}</p> : null}
  402. <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">
  403. <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
  404. {t("common.cancel")}
  405. </Button>
  406. <Button disabled={submitting || !form.name.trim()}>{submitting ? t("common.saving") : t("common.save")}</Button>
  407. </div>
  408. </form>
  409. </Dialog>
  410. );
  411. }
  412. function SummaryPill({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
  413. return (
  414. <div className="flex items-center gap-3 rounded-md border border-border bg-surface-elevated px-3 py-2">
  415. <div className="grid h-8 w-8 shrink-0 place-items-center rounded bg-primary/15 text-primary">{icon}</div>
  416. <div className="min-w-0">
  417. <p className="text-xs text-muted-foreground">{label}</p>
  418. <p className="truncate text-sm font-medium">{value}</p>
  419. </div>
  420. </div>
  421. );
  422. }
  423. function coordinationModeOptions(t: (key: string) => string) {
  424. return [
  425. { value: "supervisor", label: t("teams.coordinationSupervisorOption") },
  426. { value: "pipeline", label: t("teams.coordinationPipelineOption") },
  427. { value: "parallel", label: t("teams.coordinationParallelOption") },
  428. { value: "debate", label: t("teams.coordinationDebateOption") },
  429. ];
  430. }
  431. function coordinationModeDescription(t: (key: string) => string, mode: string) {
  432. const keyByMode: Record<string, string> = {
  433. supervisor: "coordinationSupervisorDescription",
  434. pipeline: "coordinationPipelineDescription",
  435. parallel: "coordinationParallelDescription",
  436. debate: "coordinationDebateDescription",
  437. };
  438. return t(`teams.${keyByMode[mode] ?? keyByMode.supervisor}`);
  439. }
  440. function Field({ label, children }: { label: string; children: React.ReactNode }) {
  441. return (
  442. <label className="block space-y-1.5 text-sm">
  443. <span className="text-muted-foreground">{label}</span>
  444. {children}
  445. </label>
  446. );
  447. }
  448. function buildTeamConfig(
  449. members: MemberDraft[],
  450. agents: AgentDefinition[],
  451. policy: PolicyDraft,
  452. t: (key: string) => string,
  453. ):
  454. | { ok: true; memberRefs: JSONObject[]; policyJson: JSONObject }
  455. | { ok: false; message: string } {
  456. const agentNameById = new Map(agents.map((agent) => [agent.id, agent.name]));
  457. const normalizedMembers = members
  458. .map((m) => {
  459. const agentId = m.agent_id.trim();
  460. const agentName = agentNameById.get(agentId);
  461. const member: JSONObject = {
  462. role: m.role.trim(),
  463. agent_id: agentId,
  464. responsibility: m.responsibility.trim(),
  465. };
  466. if (agentName) {
  467. member.name = agentName;
  468. member.agent_name = agentName;
  469. }
  470. return member;
  471. })
  472. .filter((m) => m.role || m.agent_id || m.responsibility);
  473. if (!normalizedMembers.length) return { ok: false, message: t("teams.addAtLeastOneMember") };
  474. if (normalizedMembers.some((m) => !m.agent_id)) return { ok: false, message: t("teams.eachMemberNeedsAgentId") };
  475. const maxRounds = Number(policy.max_rounds);
  476. if (!Number.isInteger(maxRounds) || maxRounds < 1 || maxRounds > 20) return { ok: false, message: t("teams.maxRoundsMustBe") };
  477. return {
  478. ok: true,
  479. memberRefs: normalizedMembers,
  480. policyJson: { max_rounds: maxRounds, handoff: policy.handoff, failure_mode: policy.failure_mode },
  481. };
  482. }
  483. function readMemberDrafts(activeConfig?: TeamConfig): MemberDraft[] {
  484. if (!activeConfig?.member_refs_json.length) return [createDefaultMember()];
  485. return activeConfig.member_refs_json.map((member) => ({
  486. role: readString(member, "role") ?? "executor",
  487. agent_id: readString(member, "agent_id") ?? readString(member, "agentId") ?? "",
  488. responsibility: readString(member, "responsibility") ?? readString(member, "description") ?? "",
  489. }));
  490. }
  491. function readPolicyDraft(activeConfig?: TeamConfig): PolicyDraft {
  492. if (!activeConfig) return DEFAULT_POLICY;
  493. return {
  494. max_rounds: readString(activeConfig.policy_json, "max_rounds") ?? DEFAULT_POLICY.max_rounds,
  495. handoff: readString(activeConfig.policy_json, "handoff") ?? DEFAULT_POLICY.handoff,
  496. failure_mode: readString(activeConfig.policy_json, "failure_mode") ?? DEFAULT_POLICY.failure_mode,
  497. };
  498. }
  499. function readString(value: JSONObject, key: string) {
  500. const item = value[key];
  501. if (typeof item === "string" && item.trim()) return item;
  502. if (typeof item === "number" || typeof item === "boolean") return String(item);
  503. return undefined;
  504. }