mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 10:32:36 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae0723cc55 |
@@ -108,6 +108,7 @@ export function CreateAgentDialog({
|
||||
currentUserId,
|
||||
template,
|
||||
existingAgentNames,
|
||||
squadId,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
@@ -129,6 +130,14 @@ export function CreateAgentDialog({
|
||||
// when absent, default names are used verbatim and 409 stays the
|
||||
// safety net.
|
||||
existingAgentNames?: readonly string[];
|
||||
// When set, every successful create (manual, duplicate, or template)
|
||||
// is followed by addSquadMember(squadId, agent) so the new agent
|
||||
// joins this squad. The template path also skips its usual
|
||||
// navigation to the agent detail page so the user stays on the
|
||||
// squad. If the squad-join call fails the agent still exists and
|
||||
// the dialog surfaces a warning toast — the user can add it
|
||||
// manually from the Members tab.
|
||||
squadId?: string;
|
||||
onClose: () => void;
|
||||
// Returns the created Agent so the dialog can run a follow-up
|
||||
// setAgentSkills with the IDs the user picked in the form. Pre-skill-
|
||||
@@ -208,6 +217,36 @@ export function CreateAgentDialog({
|
||||
setStep({ kind: "blank-form" });
|
||||
};
|
||||
|
||||
// Shared squad-join follow-up. Returns nothing — the caller has
|
||||
// already shown its create-success toast; we only need to surface a
|
||||
// warning when the agent landed but the squad-join failed. Cache
|
||||
// invalidation for the squad's members list rides along so the
|
||||
// Members tab re-renders without a manual refetch.
|
||||
const attachToSquad = async (agentId: string, displayName: string) => {
|
||||
if (!squadId) return;
|
||||
try {
|
||||
await api.addSquadMember(squadId, {
|
||||
member_type: "agent",
|
||||
member_id: agentId,
|
||||
});
|
||||
if (wsId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...workspaceKeys.squads(wsId), squadId, "members"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...workspaceKeys.squads(wsId), squadId],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toast.warning(
|
||||
t(($) => $.create_dialog.squad_join_failed_toast, {
|
||||
name: displayName,
|
||||
error: err instanceof Error ? err.message : "unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Template path is one-click — picker card click goes straight to the
|
||||
// API. Defaults: name auto-deduped, runtime = first usable one,
|
||||
// visibility = workspace. User refines on the detail page if needed.
|
||||
@@ -253,6 +292,14 @@ export function CreateAgentDialog({
|
||||
t(($) => $.create_dialog.template_created_toast, { name: candidate }),
|
||||
);
|
||||
}
|
||||
// Squad context: attach the freshly created agent to the squad
|
||||
// before closing — and skip the agent-detail navigation so the
|
||||
// user lands back on the squad page where they triggered the
|
||||
// flow. Failures here are non-fatal; the agent exists and can be
|
||||
// added manually if the join call 4xxs.
|
||||
if (squadId && resp.agent.id) {
|
||||
await attachToSquad(resp.agent.id, candidate);
|
||||
}
|
||||
onClose();
|
||||
// Land on the new agent's detail page so the user can verify or
|
||||
// customise instructions / skills / avatar — matches the navigation
|
||||
@@ -260,8 +307,9 @@ export function CreateAgentDialog({
|
||||
// response failed schema parsing (`agent.id === ""`, see schema
|
||||
// fallback) we skip the navigation: the agent was created server-
|
||||
// side, the list-invalidation above will surface it, and a push
|
||||
// to `/agents/` would land on a broken detail page.
|
||||
if (resp.agent.id) {
|
||||
// to `/agents/` would land on a broken detail page. Squad-context
|
||||
// entry also skips the push — the user wants to stay on Members.
|
||||
if (resp.agent.id && !squadId) {
|
||||
navigation.push(paths.agentDetail(resp.agent.id));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -349,6 +397,14 @@ export function CreateAgentDialog({
|
||||
);
|
||||
}
|
||||
}
|
||||
// Squad context: attach the agent after skills land so the
|
||||
// squad's Members tab shows the agent with its skills already
|
||||
// in place. Atomicity is best-effort by design (see plan in
|
||||
// MUL-2178) — a partial failure surfaces a warning toast and
|
||||
// the user can retry from the Add Member dialog.
|
||||
if (createdAgent && squadId) {
|
||||
await attachToSquad(createdAgent.id, createdAgent.name);
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t(($) => $.create_dialog.create_failed_toast));
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
"template_created_with_reuse_toast_one": "Agent \"{{name}}\" created. Reused {{count}} existing skill.",
|
||||
"template_created_with_reuse_toast_other": "Agent \"{{name}}\" created. Reused {{count}} existing skills.",
|
||||
"skill_attach_failed_toast": "Agent created, but failed to attach skills: {{error}}",
|
||||
"squad_join_failed_toast": "Agent \"{{name}}\" was created but couldn't join the squad: {{error}}. You can add it manually from Members.",
|
||||
"chooser": {
|
||||
"blank_title": "Start blank",
|
||||
"blank_desc": "Write everything yourself.",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"section_count_one": "{{count}} member in this squad",
|
||||
"section_count_other": "{{count}} members in this squad",
|
||||
"add_member_button": "Add Member",
|
||||
"create_agent_button": "Create Agent",
|
||||
"leader_chip": "Leader"
|
||||
},
|
||||
"instructions_tab": {
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"template_created_toast": "智能体「{{name}}」已创建",
|
||||
"template_created_with_reuse_toast_other": "智能体「{{name}}」已创建。复用了 {{count}} 个现有 skill。",
|
||||
"skill_attach_failed_toast": "智能体已创建,但 skill 关联失败:{{error}}",
|
||||
"squad_join_failed_toast": "智能体「{{name}}」已创建,但加入小队失败:{{error}}。你可以在 Members 标签里手动添加。",
|
||||
"chooser": {
|
||||
"blank_title": "从零开始",
|
||||
"blank_desc": "全部由你来填写。",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"section_count_one": "该小队有 {{count}} 名成员",
|
||||
"section_count_other": "该小队有 {{count}} 名成员",
|
||||
"add_member_button": "添加成员",
|
||||
"create_agent_button": "创建智能体",
|
||||
"leader_chip": "负责人"
|
||||
},
|
||||
"instructions_tab": {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { isImeComposing, timeAgo } from "@multica/core/utils";
|
||||
import { agentListOptions, memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { CreateAgentDialog } from "../../agents/components/create-agent-dialog";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
@@ -48,7 +51,7 @@ import {
|
||||
} from "../../issues/components/pickers/property-picker";
|
||||
import { ChevronDown, UserPlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { Squad, SquadMember, Agent, MemberWithUser } from "@multica/core/types";
|
||||
import type { Squad, SquadMember, Agent, CreateAgentRequest, MemberWithUser } from "@multica/core/types";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
export function SquadDetailPage() {
|
||||
@@ -75,7 +78,24 @@ export function SquadDetailPage() {
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: wsMembers = [] } = useQuery(memberListOptions(wsId));
|
||||
|
||||
// Runtimes are only fetched when the Create Agent dialog might open;
|
||||
// gating on isWorkspaceAdmin below means non-admins never trigger the
|
||||
// request. The runtime list mirrors the agents page so the picker
|
||||
// (and the "only my runtimes" filter) behaves identically here.
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const myRole = useMemo(() => {
|
||||
if (!currentUser) return null;
|
||||
return wsMembers.find((m) => m.user_id === currentUser.id)?.role ?? null;
|
||||
}, [wsMembers, currentUser]);
|
||||
const isWorkspaceAdmin = myRole === "owner" || myRole === "admin";
|
||||
|
||||
const { data: runtimes = [], isLoading: runtimesLoading } = useQuery({
|
||||
...runtimeListOptions(wsId),
|
||||
enabled: !!wsId && isWorkspaceAdmin,
|
||||
});
|
||||
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [showCreateAgent, setShowCreateAgent] = useState(false);
|
||||
|
||||
const updateSquadMut = useMutation({
|
||||
mutationFn: (data: { name?: string; description?: string; instructions?: string; avatar_url?: string; leader_id?: string }) => api.updateSquad(squadId, data),
|
||||
@@ -131,6 +151,25 @@ export function SquadDetailPage() {
|
||||
onError: () => toast.error("Failed to archive squad"),
|
||||
});
|
||||
|
||||
// CreateAgentDialog's onCreate contract: hit POST /api/agents and
|
||||
// return the created agent so the dialog can run its skill follow-up.
|
||||
// We deliberately do NOT navigate to the agent detail page (that's
|
||||
// the agents-page behaviour) — the user clicked Create Agent from
|
||||
// inside this squad, so the dialog will stay open just long enough
|
||||
// to also call addSquadMember (handled by the dialog when squadId
|
||||
// is set), then close the user back to Members where they can
|
||||
// verify the new agent appeared. Cache-update keeps the agents list
|
||||
// fresh for any pickers that read from it.
|
||||
const handleCreateAgent = async (data: CreateAgentRequest): Promise<Agent> => {
|
||||
const agent = await api.createAgent(data);
|
||||
queryClient.setQueryData<Agent[]>(workspaceKeys.agents(wsId), (current = []) => {
|
||||
const exists = current.some((a) => a.id === agent.id);
|
||||
return exists ? current.map((a) => (a.id === agent.id ? agent : a)) : [...current, agent];
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
return agent;
|
||||
};
|
||||
|
||||
const getEntityName = (type: string, id: string) => {
|
||||
if (type === "agent") return agents.find((a: Agent) => a.id === id)?.name ?? id.slice(0, 8);
|
||||
return wsMembers.find((m) => m.user_id === id)?.name ?? id.slice(0, 8);
|
||||
@@ -188,6 +227,7 @@ export function SquadDetailPage() {
|
||||
isLeader={isLeader}
|
||||
getEntityName={getEntityName}
|
||||
onAddMemberClick={() => setShowAddMember(true)}
|
||||
onCreateAgentClick={isWorkspaceAdmin ? () => setShowCreateAgent(true) : undefined}
|
||||
onSetLeader={(id) => setLeaderMut.mutate(id)}
|
||||
onRemoveMember={(m) => removeMemberMut.mutate(m)}
|
||||
onUpdateRole={async (m, role) => { await updateRoleMut.mutateAsync({ member: m, role }); }}
|
||||
@@ -204,6 +244,25 @@ export function SquadDetailPage() {
|
||||
onSubmit={async (input) => { await addMemberMut.mutateAsync(input); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Squad-scoped create flow: same dialog as the Agents page but
|
||||
with squadId set, so the dialog runs addSquadMember after
|
||||
createAgent / createAgentFromTemplate and skips the
|
||||
agent-detail navigation. Only mounted for workspace
|
||||
owner/admin since AddSquadMember is owner/admin-gated
|
||||
server-side; for everyone else the trigger never renders. */}
|
||||
{showCreateAgent && isWorkspaceAdmin && (
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
runtimesLoading={runtimesLoading}
|
||||
members={wsMembers}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
existingAgentNames={agents.map((a: Agent) => a.name)}
|
||||
squadId={squadId}
|
||||
onClose={() => setShowCreateAgent(false)}
|
||||
onCreate={handleCreateAgent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -846,6 +905,7 @@ function SquadOverviewPane({
|
||||
isLeader,
|
||||
getEntityName,
|
||||
onAddMemberClick,
|
||||
onCreateAgentClick,
|
||||
onSetLeader,
|
||||
onRemoveMember,
|
||||
onUpdateRole,
|
||||
@@ -857,6 +917,10 @@ function SquadOverviewPane({
|
||||
isLeader: (m: SquadMember) => boolean;
|
||||
getEntityName: (type: string, id: string) => string;
|
||||
onAddMemberClick: () => void;
|
||||
// Optional — only passed when the current user can manage the squad
|
||||
// (workspace owner/admin). Hidden otherwise so plain members don't
|
||||
// see a button they can't action.
|
||||
onCreateAgentClick?: () => void;
|
||||
onSetLeader: (agentId: string) => void;
|
||||
onRemoveMember: (m: SquadMember) => void;
|
||||
onUpdateRole: (m: SquadMember, role: string) => Promise<void>;
|
||||
@@ -910,6 +974,7 @@ function SquadOverviewPane({
|
||||
isLeader={isLeader}
|
||||
getEntityName={getEntityName}
|
||||
onAddMemberClick={onAddMemberClick}
|
||||
onCreateAgentClick={onCreateAgentClick}
|
||||
onSetLeader={onSetLeader}
|
||||
onRemoveMember={onRemoveMember}
|
||||
onUpdateRole={onUpdateRole}
|
||||
@@ -956,6 +1021,7 @@ function SquadMembersTab({
|
||||
isLeader,
|
||||
getEntityName,
|
||||
onAddMemberClick,
|
||||
onCreateAgentClick,
|
||||
onSetLeader,
|
||||
onRemoveMember,
|
||||
onUpdateRole,
|
||||
@@ -965,6 +1031,8 @@ function SquadMembersTab({
|
||||
isLeader: (m: SquadMember) => boolean;
|
||||
getEntityName: (type: string, id: string) => string;
|
||||
onAddMemberClick: () => void;
|
||||
// Hidden for non-admins — see SquadOverviewPane.
|
||||
onCreateAgentClick?: () => void;
|
||||
onSetLeader: (agentId: string) => void;
|
||||
onRemoveMember: (m: SquadMember) => void;
|
||||
onUpdateRole: (m: SquadMember, role: string) => Promise<void>;
|
||||
@@ -980,10 +1048,18 @@ function SquadMembersTab({
|
||||
{t(($) => $.members_tab.section_count, { count: members.length })}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onAddMemberClick}>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t(($) => $.members_tab.add_member_button)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onCreateAgentClick && (
|
||||
<Button size="sm" variant="outline" onClick={onCreateAgentClick}>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t(($) => $.members_tab.create_agent_button)}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={onAddMemberClick}>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t(($) => $.members_tab.add_member_button)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user