From 019e8ef1a5c2443d4e66509263fdc84fab51cfb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=87=8C=E5=8D=BF?= Date: Tue, 21 Jul 2026 15:20:55 +0800 Subject: [PATCH] feat(groups): pick members while creating a group Creating a group only took a name, so members and Agents could only be added after the group already existed. The invite API accepts a participant_id, and the only endpoint exposing those ids required an existing group, so the create flow had no way to resolve them. Backend: - POST /api/groups accepts an optional member_participant_ids list and stages the group with its initial members in one transaction, so an invalid member rolls the whole group back instead of leaving an empty group behind. - Add GET /api/groups/member-candidates, a tenant-scoped listing for the create flow, registered before /{group_id} so the literal path is not parsed as a group id. It excludes the creator, who joins as manager on create. - Extract _invitable_participant so create and invite share the same validation, including Agent visibility for the inviter. Frontend: - Replace the name-only prompt with CreateGroupModal: name, Agent and member tabs, search, multi-select and a live selection count. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/groups.py | 33 ++++ backend/app/services/group_chat_service.py | 171 +++++++++++++++--- backend/tests/test_group_api.py | 47 ++++- backend/tests/test_group_chat_service.py | 89 +++++++++ .../src/pages/groups/CreateGroupModal.tsx | 166 +++++++++++++++++ frontend/src/pages/groups/GroupsPage.tsx | 31 ++-- frontend/src/pages/groups/groups.css | 50 +++++ frontend/src/services/groupApi.ts | 7 +- 8 files changed, 552 insertions(+), 42 deletions(-) create mode 100644 frontend/src/pages/groups/CreateGroupModal.tsx diff --git a/backend/app/api/groups.py b/backend/app/api/groups.py index f642d5e3a..6c65487f1 100644 --- a/backend/app/api/groups.py +++ b/backend/app/api/groups.py @@ -46,6 +46,7 @@ class CreateGroupIn(BaseModel): name: str = Field(min_length=1, max_length=200) description: str | None = None + member_participant_ids: list[uuid.UUID] = Field(default_factory=list, max_length=100) class PatchGroupIn(BaseModel): @@ -494,6 +495,7 @@ async def create_group( creator_participant_id=participant.id, name=body.name, description=body.description, + member_participant_ids=body.member_participant_ids, ) except GroupChatServiceError as exc: raise _translate_domain_error(exc) from exc @@ -503,6 +505,11 @@ async def create_group( action="group:create", tenant_id=tenant_id, group_id=group.id, + details={ + "member_participant_ids": [ + str(participant_id) for participant_id in body.member_participant_ids + ] + }, ) return group @@ -521,6 +528,32 @@ async def list_groups( ) +# Registered before "/{group_id}" so the literal path is not parsed as a group id. +@router.get("/member-candidates", response_model=list[GroupMemberCandidateOut]) +async def list_tenant_member_candidates( + participant_type: Annotated[Literal["user", "agent"], Query()], + limit: Annotated[int, Query(ge=1, le=100)] = 100, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Candidates for the create-group flow, before any group exists.""" + tenant_id = _tenant_id(current_user) + try: + candidates = await group_chat_service.list_tenant_member_candidates( + db, + tenant_id=tenant_id, + actor_user=current_user, + participant_type=participant_type, + limit=limit, + ) + except GroupChatServiceError as exc: + raise _translate_domain_error(exc) from exc + return [ + GroupMemberCandidateOut.model_validate(candidate, from_attributes=True) + for candidate in candidates + ] + + @router.get("/{group_id}", response_model=GroupOut) async def get_group( group_id: uuid.UUID, diff --git a/backend/app/services/group_chat_service.py b/backend/app/services/group_chat_service.py index ca7d66d56..c17d82206 100644 --- a/backend/app/services/group_chat_service.py +++ b/backend/app/services/group_chat_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid +from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime @@ -164,6 +165,64 @@ async def _active_membership( return membership +async def _human_actor_user( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + actor: Participant, +) -> User: + """Resolve the active tenant user behind a validated human participant.""" + result = await db.execute( + select(User).where( + User.id == actor.ref_id, + User.tenant_id == tenant_id, + User.is_active.is_(True), + ) + ) + actor_user = result.scalar_one_or_none() + if actor_user is None: + raise GroupChatServiceError( + "group_human_member_required", + "An active human group member is required", + ) + return actor_user + + +async def _invitable_participant( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + actor: Participant, + participant_id: uuid.UUID, +) -> Participant: + """Validate an invite target, including Agent visibility for the inviter.""" + target = await _valid_participant( + db, + tenant_id=tenant_id, + participant_id=participant_id, + human_only=False, + error_code="group_participant_invalid", + ) + if target.type != "agent": + return target + + # Resolved only for Agent targets, where inviter visibility must be checked. + actor_user = await _human_actor_user(db, tenant_id=tenant_id, actor=actor) + target_agent_result = await db.execute( + select(Agent).where( + Agent.id == target.ref_id, + Agent.tenant_id == tenant_id, + ) + ) + target_agent = target_agent_result.scalar_one_or_none() + if target_agent is None or not await can_use_agent(db, actor_user, target_agent): + raise GroupChatServiceError( + "group_participant_invalid", + "Agent is not visible to the inviting member", + ) + return target + + async def _human_actor( db: AsyncSession, *, @@ -321,6 +380,7 @@ async def create_group( creator_participant_id: uuid.UUID, name: str, description: str | None = None, + member_participant_ids: Sequence[uuid.UUID] = (), ) -> Group: """Create a group and its initial manager without owning the transaction.""" normalized_name = _required_text( @@ -329,7 +389,7 @@ async def create_group( field="name", max_length=200, ) - await _valid_participant( + creator = await _valid_participant( db, tenant_id=tenant_id, participant_id=creator_participant_id, @@ -337,6 +397,22 @@ async def create_group( error_code="group_creator_invalid", ) + invited_ids: list[uuid.UUID] = [] + seen_ids = {creator_participant_id} + for participant_id in member_participant_ids: + if participant_id in seen_ids: + continue + seen_ids.add(participant_id) + invited_ids.append(participant_id) + + for participant_id in invited_ids: + await _invitable_participant( + db, + tenant_id=tenant_id, + actor=creator, + participant_id=participant_id, + ) + now = _now() group = Group( id=uuid.uuid4(), @@ -359,6 +435,18 @@ async def create_group( ) db.add(group) db.add(creator_membership) + for participant_id in invited_ids: + db.add( + GroupMember( + id=uuid.uuid4(), + group_id=group.id, + participant_id=participant_id, + role="member", + joined_at=now, + removed_at=None, + session_read_state={}, + ) + ) await db.flush() return group @@ -518,6 +606,57 @@ async def list_group_member_candidates( ) active_ref_ids = set(active_refs_result.scalars().all()) + return await _member_candidates( + db, + tenant_id=tenant_id, + actor_user=actor_user, + participant_type=participant_type, + limit=limit, + excluded_ref_ids=active_ref_ids, + ) + + +async def list_tenant_member_candidates( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + actor_user: User, + participant_type: str, + limit: int, +) -> tuple[GroupMemberCandidate, ...]: + """List inviteable identities before a group exists, for the create flow.""" + if participant_type not in {"user", "agent"}: + raise GroupChatServiceError( + "group_participant_type_invalid", + "Participant type must be 'user' or 'agent'", + ) + if actor_user.tenant_id != tenant_id or not actor_user.is_active: + raise GroupChatServiceError( + "group_human_member_required", + "An active human group member is required", + ) + + # The creator joins as manager on create, so never offer them as a candidate. + return await _member_candidates( + db, + tenant_id=tenant_id, + actor_user=actor_user, + participant_type=participant_type, + limit=limit, + excluded_ref_ids={actor_user.id} if participant_type == "user" else set(), + ) + + +async def _member_candidates( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + actor_user: User, + participant_type: str, + limit: int, + excluded_ref_ids: set[uuid.UUID], +) -> tuple[GroupMemberCandidate, ...]: + active_ref_ids = excluded_ref_ids candidates: list[GroupMemberCandidate] = [] if participant_type == "user": statement = select(User).where( @@ -598,38 +737,12 @@ async def invite_group_member( participant_id=actor_participant_id, manager_only=False, ) - target = await _valid_participant( + await _invitable_participant( db, tenant_id=tenant_id, + actor=actor, participant_id=participant_id, - human_only=False, - error_code="group_participant_invalid", ) - if target.type == "agent": - actor_user_result = await db.execute( - select(User).where( - User.id == actor.ref_id, - User.tenant_id == tenant_id, - User.is_active.is_(True), - ) - ) - actor_user = actor_user_result.scalar_one_or_none() - target_agent_result = await db.execute( - select(Agent).where( - Agent.id == target.ref_id, - Agent.tenant_id == tenant_id, - ) - ) - target_agent = target_agent_result.scalar_one_or_none() - if ( - actor_user is None - or target_agent is None - or not await can_use_agent(db, actor_user, target_agent) - ): - raise GroupChatServiceError( - "group_participant_invalid", - "Agent is not visible to the inviting member", - ) existing_result = await db.execute( select(GroupMember) diff --git a/backend/tests/test_group_api.py b/backend/tests/test_group_api.py index 294047df6..e9201cab2 100644 --- a/backend/tests/test_group_api.py +++ b/backend/tests/test_group_api.py @@ -94,6 +94,7 @@ def test_group_router_exposes_management_and_read_state_boundaries() -> None: assert ("POST", "/api/groups") in routes assert ("GET", "/api/groups/{group_id}/members") in routes assert ("GET", "/api/groups/{group_id}/member-candidates") in routes + assert ("GET", "/api/groups/member-candidates") in routes assert ("POST", "/api/groups/{group_id}/sessions") in routes assert ("DELETE", "/api/groups/{group_id}/sessions/{session_id}") in routes assert ("POST", "/api/groups/{group_id}/sessions/{session_id}/read") in routes @@ -115,6 +116,13 @@ def test_group_router_exposes_management_and_read_state_boundaries() -> None: assert ("PATCH", "/api/groups/{group_id}/members/{member_id}") not in routes +def test_tenant_member_candidates_is_matched_before_the_group_id_route() -> None: + """A literal path after "/{group_id}" would be parsed as a group id and 422.""" + paths = [getattr(route, "path", None) for route in groups_api.router.routes] + + assert paths.index("/api/groups/member-candidates") < paths.index("/api/groups/{group_id}") + + def test_group_invite_write_contract_only_accepts_participant_id() -> None: assert set(groups_api.InviteGroupMemberIn.model_fields) == {"participant_id"} @@ -255,6 +263,7 @@ async def fake_create(_db, **kwargs): "creator_participant_id": participant.id, "name": "Runtime Group", "description": None, + "member_participant_ids": [], } ] assert len(db.added) == 1 @@ -262,7 +271,43 @@ async def fake_create(_db, **kwargs): assert isinstance(audit, AuditLog) assert audit.action == "group:create" assert audit.user_id == user.id - assert audit.details == {"tenant_id": str(tenant_id), "group_id": str(group.id)} + assert audit.details == { + "tenant_id": str(tenant_id), + "group_id": str(group.id), + "member_participant_ids": [], + } + + +@pytest.mark.asyncio +async def test_create_group_forwards_initial_members_and_audits_them(monkeypatch) -> None: + tenant_id = uuid.uuid4() + user = _user(tenant_id) + participant = _participant(user) + group = _group(tenant_id, participant.id) + invited = [uuid.uuid4(), uuid.uuid4()] + db = _RecordingDB() + calls = [] + + async def fake_participant(_db, current_user): + return participant + + async def fake_create(_db, **kwargs): + calls.append(kwargs) + return group + + monkeypatch.setattr(groups_api, "_current_participant", fake_participant) + monkeypatch.setattr(groups_api.group_chat_service, "create_group", fake_create) + + result = await groups_api.create_group( + groups_api.CreateGroupIn(name="Runtime Group", member_participant_ids=invited), + current_user=user, + db=db, + ) + + assert result is group + assert calls[0]["member_participant_ids"] == invited + audit = db.added[0] + assert audit.details["member_participant_ids"] == [str(value) for value in invited] @pytest.mark.asyncio diff --git a/backend/tests/test_group_chat_service.py b/backend/tests/test_group_chat_service.py index 3eefd8b90..8773b64ec 100644 --- a/backend/tests/test_group_chat_service.py +++ b/backend/tests/test_group_chat_service.py @@ -204,6 +204,95 @@ async def test_create_group_stages_the_human_creator_as_manager() -> None: assert db.flush_count == 1 +@pytest.mark.asyncio +async def test_create_group_stages_initial_members_in_the_same_transaction() -> None: + tenant_id = uuid.uuid4() + user_id = uuid.uuid4() + creator = _participant("user", user_id) + agent_id = uuid.uuid4() + invited_agent = _participant("agent", agent_id) + target_agent = _agent(tenant_id, agent_id) + invited_user_id = uuid.uuid4() + invited_user = _participant("user", invited_user_id) + creator_user = User( + id=user_id, + tenant_id=tenant_id, + display_name="Group Creator", + role="member", + is_active=True, + ) + db = _RecordingDB( + _Result([creator]), + _Result([user_id]), + # Agent target: participant, tenant agent, inviter user, visibility agent. + _Result([invited_agent]), + _Result([target_agent]), + _Result([creator_user]), + _Result([target_agent]), + # User target: participant, then the active tenant user behind it. + _Result([invited_user]), + _Result([invited_user_id]), + ) + + group = await group_chat_service.create_group( + db, + tenant_id=tenant_id, + creator_participant_id=creator.id, + name="Product launch", + member_participant_ids=[invited_agent.id, invited_user.id, creator.id], + ) + + memberships = [value for value in db.added if isinstance(value, GroupMember)] + assert [membership.participant_id for membership in memberships] == [ + creator.id, + invited_agent.id, + invited_user.id, + ] + assert [membership.role for membership in memberships] == ["manager", "member", "member"] + assert all(membership.group_id == group.id for membership in memberships) + # One flush: the group and every initial member commit or roll back together. + assert db.flush_count == 1 + + +@pytest.mark.asyncio +async def test_create_group_rejects_an_invisible_agent_before_staging_the_group() -> None: + tenant_id = uuid.uuid4() + user_id = uuid.uuid4() + creator = _participant("user", user_id) + agent_id = uuid.uuid4() + invited_agent = _participant("agent", agent_id) + target_agent = _agent(tenant_id, agent_id, access_mode="custom") + creator_user = User( + id=user_id, + tenant_id=tenant_id, + display_name="Group Creator", + role="member", + is_active=True, + ) + db = _RecordingDB( + _Result([creator]), + _Result([user_id]), + _Result([invited_agent]), + _Result([target_agent]), + _Result([creator_user]), + _Result([target_agent]), + _Result(), + ) + + with pytest.raises(group_chat_service.GroupChatServiceError) as excinfo: + await group_chat_service.create_group( + db, + tenant_id=tenant_id, + creator_participant_id=creator.id, + name="Product launch", + member_participant_ids=[invited_agent.id], + ) + + assert excinfo.value.code == "group_participant_invalid" + assert db.added == [] + assert db.flush_count == 0 + + @pytest.mark.asyncio async def test_ordinary_human_member_can_invite_a_company_agent() -> None: tenant_id = uuid.uuid4() diff --git a/frontend/src/pages/groups/CreateGroupModal.tsx b/frontend/src/pages/groups/CreateGroupModal.tsx new file mode 100644 index 000000000..3872a9d8e --- /dev/null +++ b/frontend/src/pages/groups/CreateGroupModal.tsx @@ -0,0 +1,166 @@ +import { useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { IconCheck, IconRobot, IconSearch, IconUser, IconX } from '@tabler/icons-react'; +import { groupApi } from '../../services/groupApi'; +import type { GroupMemberCandidate, ParticipantType } from '../../types/group'; + +interface CreateGroupModalProps { + creating: boolean; + onCreate: (name: string, memberParticipantIds: string[]) => void; + onCancel: () => void; +} + +export default function CreateGroupModal({ creating, onCreate, onCancel }: CreateGroupModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [tab, setTab] = useState('agent'); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState([]); + const nameRef = useRef(null); + + const { data: backendCandidates = [], isLoading } = useQuery({ + queryKey: ['tenant-member-candidates', tab], + queryFn: () => groupApi.tenantMemberCandidates(tab), + }); + + const selectedIds = useMemo( + () => new Set(selected.map((candidate) => candidate.participant_id)), + [selected], + ); + + const candidates = useMemo(() => { + const needle = search.trim().toLowerCase(); + if (!needle) return backendCandidates; + return backendCandidates.filter((candidate) => + [candidate.display_name, candidate.role_description, candidate.title] + .some((value) => value?.toLowerCase().includes(needle)), + ); + }, [backendCandidates, search]); + + const toggle = (candidate: GroupMemberCandidate) => { + setSelected((previous) => + previous.some((item) => item.participant_id === candidate.participant_id) + ? previous.filter((item) => item.participant_id !== candidate.participant_id) + : [...previous, candidate], + ); + }; + + const canConfirm = Boolean(name.trim()) && !creating; + const confirm = () => { + if (!canConfirm) return; + onCreate(name.trim(), selected.map((candidate) => candidate.participant_id)); + }; + + return ( +
+
event.stopPropagation()}> +
+

{t('groups.create', '创建群聊')}

+ +
+ +
+ setName(event.target.value)} + placeholder={t('groups.namePlaceholder', '群名称')} + onKeyDown={(event) => { + // Enter commits an IME candidate before it should submit the form. + if (event.nativeEvent.isComposing) return; + if (event.key === 'Enter') confirm(); + }} + /> +
+ +
+ + +
+ +
+ + setSearch(event.target.value)} + placeholder={t('groups.searchPlaceholder', '搜索名称')} + /> +
+ +
+ {candidates.length === 0 && ( +
+ {isLoading + ? t('common.loading', '加载中...') + : t('groups.noCandidates', '没有可邀请的对象')} +
+ )} + {candidates.map((candidate) => { + const picked = selectedIds.has(candidate.participant_id); + return ( +
toggle(candidate)} + > + + {candidate.participant_type === 'agent' + ? + : } + +
+
{candidate.display_name}
+ {(candidate.role_description || candidate.title) && ( +
+ {candidate.role_description || candidate.title} +
+ )} +
+ + {picked && } + +
+ ); + })} +
+ +
+ + {t('groups.selectedCount', '已选 {{count}} 位', { count: selected.length })} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/groups/GroupsPage.tsx b/frontend/src/pages/groups/GroupsPage.tsx index b8a006852..125a87ace 100644 --- a/frontend/src/pages/groups/GroupsPage.tsx +++ b/frontend/src/pages/groups/GroupsPage.tsx @@ -31,6 +31,7 @@ import MessageComposer from './MessageComposer'; import GroupSidePanel from './GroupSidePanel'; import GroupSettingsModal from './GroupSettingsModal'; import InviteMemberModal from './InviteMemberModal'; +import CreateGroupModal from './CreateGroupModal'; import InlineEdit from './InlineEdit'; import type { GroupMessage, GroupSession } from '../../types/group'; import './groups.css'; @@ -91,6 +92,7 @@ export default function GroupsPage() { const [showPanel, setShowPanel] = useState(() => readFlag('groups.showPanel', false)); const [showInvite, setShowInvite] = useState(false); const [creatingGroup, setCreatingGroup] = useState(false); + const [creatingGroupPending, setCreatingGroupPending] = useState(false); // The group a "new session" prompt targets, or null when closed. const [creatingSession, setCreatingSession] = useState(null); // The session whose title is being renamed inline, or null. @@ -489,15 +491,21 @@ export default function GroupsPage() { } }; - const createGroup = async (name: string) => { - setCreatingGroup(false); - if (!name.trim()) return; + const createGroup = async (name: string, memberParticipantIds: string[]) => { + if (!name.trim() || creatingGroupPending) return; + setCreatingGroupPending(true); try { - const group = await groupApi.create({ name: name.trim() }); + const group = await groupApi.create({ + name: name.trim(), + member_participant_ids: memberParticipantIds, + }); + setCreatingGroup(false); await refetchGroups(); navigate(`/groups/${group.id}`); } catch (error: any) { toast.error(error?.message ?? t('groups.createFailed', '建群失败')); + } finally { + setCreatingGroupPending(false); } }; @@ -841,13 +849,14 @@ export default function GroupsPage() { /> )} - void createGroup(value)} - onCancel={() => setCreatingGroup(false)} - /> + {creatingGroup && ( + + void createGroup(name, memberParticipantIds)} + onCancel={() => setCreatingGroup(false)} + /> + )} fetchJson(`/groups/${groupId}`), - create: (data: { name: string; description?: string }) => + create: (data: { name: string; description?: string; member_participant_ids?: string[] }) => fetchJson('/groups', { method: 'POST', body: JSON.stringify(data) }), update: (groupId: string, data: { name?: string; description?: string }) => @@ -50,6 +50,11 @@ export const groupApi = { members: (groupId: string) => fetchJson(`/groups/${groupId}/members`), + tenantMemberCandidates: (participantType: ParticipantType) => + fetchJson( + `/groups/member-candidates${qs({ participant_type: participantType })}`, + ), + memberCandidates: (groupId: string, participantType: ParticipantType) => fetchJson( `/groups/${groupId}/member-candidates${qs({ participant_type: participantType })}`,