diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index ea8a25ed..138fc9a4 100644 --- a/backend/app/api/channels.py +++ b/backend/app/api/channels.py @@ -78,6 +78,7 @@ ChannelInboundEvent, ChatSession, Message, + Team, User, utc_now, ) @@ -275,27 +276,48 @@ def create_channel_binding( ensure_current_user_tenant(request.tenant_id, current_user) if request.channel not in SUPPORTED_CHANNELS: raise HTTPException(status_code=400, detail=f"v1 仅支持渠道: {sorted(SUPPORTED_CHANNELS)}") - ensure_agent_scope_manager(db, request.tenant_id, request.agent_id, current_user) + # 挂员工集或绑团队二选一:都给/都不给均拒绝 + if bool(request.agent_id) == bool(request.team_id): + raise HTTPException(status_code=400, detail="agent_id 与 team_id 必须且只能提供一个") + if request.team_id: + team = db.get(Team, request.team_id) + if team is None or team.tenant_id != request.tenant_id: + raise HTTPException(status_code=404, detail="Team not found") + from app.teams.service import get_team_leader + + leader = get_team_leader(db, team.id) + if leader is None: + raise HTTPException(status_code=400, detail="团队暂未设置 TL,请先设置 TL 后再绑定渠道") + # 复用员工绑定同款守卫:创建者须能管理现任 TL 员工 + ensure_agent_scope_manager(db, request.tenant_id, leader.agent_id, current_user) + # agent_id 为非空遗留列(列表过滤/挂载回退仍在用):团队绑定回写现任 TL, + # 入站路由始终以 binding.team_id 解析的现任 TL 为准,换帅自动跟随 + target_agent_id = leader.agent_id + else: + ensure_agent_scope_manager(db, request.tenant_id, request.agent_id, current_user) + target_agent_id = request.agent_id # 同一员工同一渠道允许多个绑定实例,总是新建 binding = ChannelBinding( tenant_id=request.tenant_id, - agent_id=request.agent_id, + agent_id=target_agent_id, channel=request.channel, status="pending", created_by_user_id=current_user.id, + team_id=request.team_id, ) db.add(binding) db.flush() - # 新绑定自动挂载默认员工 - db.add( - ChannelBindingAgent( - tenant_id=request.tenant_id, - binding_id=binding.id, - agent_id=request.agent_id, - is_default=True, - sort_order=0, + if not request.team_id: + # 新绑定自动挂载默认员工;团队绑定走 TL 直路由,不写挂载行 + db.add( + ChannelBindingAgent( + tenant_id=request.tenant_id, + binding_id=binding.id, + agent_id=target_agent_id, + is_default=True, + sort_order=0, + ) ) - ) db.commit() db.refresh(binding) return channel_binding_read(db, binding) @@ -470,6 +492,9 @@ def update_channel_binding_agents( _ensure_binding_manager(db, tenant_id, binding, current_user) if request.agents is None and request.auto_route is None: raise HTTPException(status_code=400, detail="无有效更新内容") + if request.agents is not None and binding.team_id: + # 团队绑定的接待员工由团队现任 TL 决定,不允许整表替换员工挂载 + raise HTTPException(status_code=400, detail="团队绑定的渠道不支持修改员工挂载") default_agent_id: str | None = None if request.agents is not None: if not request.agents: diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index c3f2d37e..9a83212d 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -21,8 +21,8 @@ from app.agents.branching import model_for_agent, visible_published_skills from app.channels.service_outbox import stage_channel_delivery from app.core import AgentLoop -from app.core.capability_manifest import CapabilityManifestBuilder from app.core.cancellation import cancel_chat_turn +from app.core.capability_manifest import CapabilityManifestBuilder from app.core.harness_session_cleanup import ( harness_task_workspace_path, remove_harness_session_workspace, @@ -43,11 +43,17 @@ ScheduledTaskRun, Skill, SkillFeedback, + Team, User, new_id, utc_now, ) from app.feedback import enqueue_feedback_analysis +from app.harness import ( + HarnessArtifactAccessError, + normalize_harness_artifact_path, + open_harness_artifact, +) from app.knowledge.citations import CITATION_EXCERPT_CHAR_LIMIT, compact_knowledge_citation_labels from app.llm import LLMClient, LLMError from app.observability.spans import ( @@ -56,16 +62,11 @@ reset_span_sink, set_span_sink, ) +from app.scheduled_tasks.schema import ScheduledTaskDraftRead +from app.scheduled_tasks.service import DEFAULT_TASK_TIME, detect_scheduled_task_draft from app.security.auth import get_current_user from app.security.permissions import agent_owned_by_user, is_admin_user from app.security.tenant import ensure_tenant -from app.harness import ( - HarnessArtifactAccessError, - normalize_harness_artifact_path, - open_harness_artifact, -) -from app.scheduled_tasks.schema import ScheduledTaskDraftRead -from app.scheduled_tasks.service import DEFAULT_TASK_TIME, detect_scheduled_task_draft from app.session.attachments import ( parse_chat_attachment, validate_chat_turn_attachments, @@ -82,6 +83,8 @@ MessageFeedbackRequest, MessageRead, ) +from app.teams.service import get_team_leader +from app.teams.wakeup import build_tl_chat_message, process_tl_reply router = APIRouter(prefix="/api/chat", tags=["chat"]) logger = logging.getLogger(__name__) @@ -175,7 +178,9 @@ class HumanHandoffReplyRequest(BaseModel): reply: str -def session_read(row: ChatSession, *, is_scheduled: bool = False) -> ChatSessionRead: +def session_read( + row: ChatSession, *, is_scheduled: bool = False, team_name: str | None = None +) -> ChatSessionRead: return ChatSessionRead( id=row.id, tenant_id=row.tenant_id, @@ -188,6 +193,8 @@ def session_read(row: ChatSession, *, is_scheduled: bool = False) -> ChatSession summary=row.summary, last_agent_question=row.last_agent_question, is_scheduled=is_scheduled, + team_id=row.team_id, + team_name=team_name, created_at=row.created_at.isoformat(), updated_at=row.updated_at.isoformat(), ) @@ -985,14 +992,26 @@ def chat_turn( _ensure_request_tenant(request.tenant_id, current_user) request = request.model_copy(update={"user_id": current_user.id}) request = _validate_chat_turn_attachments(request) + team_tl_team: Team | None = None if request.session_id: chat_session = _ensure_chat_session_available(db, request.tenant_id, current_user.id, request.session_id) + _ensure_team_session_human_writable(chat_session) request = _bind_request_to_session_agent(db, request, chat_session, current_user) + team_tl_team = _team_tl_session_team(db, chat_session) else: _ensure_chat_agent_available(db, request.tenant_id, request.agent_id, current_user) ensure_tenant(db, request.tenant_id) if not request.message.strip() and not request.attachments: raise HTTPException(status_code=400, detail="Message cannot be empty") + original_message = request.message + if team_tl_team is not None: + # 团队 TL 会话:注入团队上下文(花名册/未闭环任务/黑板/派任务格式)后再走正常引擎 + request = request.model_copy( + update={ + "message": build_tl_chat_message(db, team_tl_team, original_message), + "interaction_mode": "team_tl", + } + ) if request.session_id: scheduled_response = _maybe_handle_scheduled_task_request(db, request, chat_session) if scheduled_response: @@ -1001,6 +1020,21 @@ def chat_turn( return response response = AgentLoop(db).handle_turn(request) _schedule_session_title_summary(request.tenant_id, request.user_id, response.session_id, request.agent_id) + if team_tl_team is not None: + # TL 回复后处理:解析派任务块并创建任务(与 tl_chat 端点同语义); + # 后处理失败不影响本轮回复 + try: + process_tl_reply( + db, + team=team_tl_team, + session=chat_session, + user=current_user, + user_message=original_message, + reply=response.reply or "", + client_turn_id=request.client_turn_id, + ) + except Exception: + logger.exception("team TL reply post-processing failed") if request.interaction_mode == "scheduled_task" and request.agent_id: draft = detect_scheduled_task_draft( db, @@ -1026,13 +1060,26 @@ def chat_stream( request = request.model_copy(update={"user_id": current_user.id}) request = _validate_chat_turn_attachments(request) ensure_tenant(db, request.tenant_id) + team_tl_team_id: str | None = None if request.session_id: chat_session = _ensure_chat_session_available(db, request.tenant_id, current_user.id, request.session_id) + _ensure_team_session_human_writable(chat_session) request = _bind_request_to_session_agent(db, request, chat_session, current_user) + team_tl_team = _team_tl_session_team(db, chat_session) + team_tl_team_id = team_tl_team.id if team_tl_team is not None else None else: _ensure_chat_agent_available(db, request.tenant_id, request.agent_id, current_user) if not request.message.strip() and not request.attachments: raise HTTPException(status_code=400, detail="Message cannot be empty") + original_message = request.message + if team_tl_team_id is not None: + # 团队 TL 会话:注入团队上下文(花名册/未闭环任务/黑板/派任务格式)后再走正常引擎 + request = request.model_copy( + update={ + "message": build_tl_chat_message(db, db.get(Team, team_tl_team_id), original_message), + "interaction_mode": "team_tl", + } + ) relay_ready = threading.Event() worker_done = threading.Event() @@ -1199,6 +1246,27 @@ def persist_span(event_type: str, payload: dict[str, object]) -> None: event_source_session_id, request.agent_id, ) + if team_tl_team_id is not None: + # 团队 TL 会话:complete 后做派任务后处理(与 tl_chat 端点同语义); + # 后处理失败不影响本轮回复 + try: + tl_team = worker_db.get(Team, team_tl_team_id) + tl_session = worker_db.get( + ChatSession, event_source_session_id or request.session_id or "" + ) + tl_user = worker_db.get(User, request.user_id) if request.user_id else None + if tl_team is not None and tl_session is not None and tl_user is not None: + process_tl_reply( + worker_db, + team=tl_team, + session=tl_session, + user=tl_user, + user_message=original_message, + reply=str(data.get("reply") or ""), + client_turn_id=request.client_turn_id, + ) + except Exception: + logger.exception("team TL reply post-processing failed") if event_source_session_id: summary_payload = _session_title_summary_payload(worker_db, request.tenant_id, event_source_session_id) if summary_payload: @@ -1834,7 +1902,19 @@ def list_chat_sessions( ).all() if session_id } - return [session_read(row, is_scheduled=row.id in scheduled_session_ids) for row in rows] + team_ids = {row.team_id for row in rows if row.team_id} + team_names = { + team.id: team.name + for team in db.exec(select(Team).where(Team.id.in_(team_ids))).all() + } if team_ids else {} + return [ + session_read( + row, + is_scheduled=row.id in scheduled_session_ids, + team_name=team_names.get(row.team_id) if row.team_id else None, + ) + for row in rows + ] @router.put("/sessions/{session_id}", response_model=ChatSessionRead) @@ -1858,7 +1938,8 @@ def rename_chat_session( ScheduledTaskRun.session_id == row.id, ) ).first() is not None - return session_read(row, is_scheduled=is_scheduled) + team = db.get(Team, row.team_id) if row.team_id else None + return session_read(row, is_scheduled=is_scheduled, team_name=team.name if team else None) @router.delete("/sessions/{session_id}") @@ -2413,11 +2494,45 @@ def _bind_request_to_session_agent( def _ensure_chat_session_available(db: Session, tenant_id: str, user_id: str, session_id: str) -> ChatSession: ensure_tenant(db, tenant_id) row = db.get(ChatSession, session_id) - if not row or row.tenant_id != tenant_id or row.user_id != user_id: + if not row or row.tenant_id != tenant_id: + raise HTTPException(status_code=404, detail="Session not found") + # 团队会话(team_id 非空)对本租户成员开放发言(如 TL 工作台聊天室); + # 普通会话仍仅创建者可见 + if row.user_id != user_id and not row.team_id: raise HTTPException(status_code=404, detail="Session not found") return row +def _ensure_team_session_human_writable(chat_session: ChatSession) -> None: + """团队内部会话(任务执行/竞标/验收)仅可查看,不允许人工 /turn、/stream 写入。 + + 判据与 _team_tl_session_team 一致:只有「TL 对话」标题的团队会话才对人类开放发言, + 其余团队会话由唤醒机制自主驱动,人工写入会污染任务历史并绕过 Agent 权限校验。 + """ + if chat_session.team_id and "TL 对话" not in (chat_session.title or ""): + raise HTTPException(status_code=403, detail="Team execution sessions are read-only") + + +def _team_tl_session_team(db: Session, chat_session: ChatSession) -> Team | None: + """识别团队 TL 会话:session 挂 team_id 且绑定 agent 是该团队现任 TL。 + + 非 TL 的团队会话(任务执行/竞标等)不对人直接聊,返回 None(不注入、不后处理)。 + """ + if not chat_session.team_id or not chat_session.agent_id: + return None + # 团队会话全量绑定 team_id 后,任务验收/竞标打分等会话同样挂在 TL 名下; + # 只有「TL 对话」标题的会话才按人对 TL 聊天处理(与 team-threads 列表同判据) + if "TL 对话" not in (chat_session.title or ""): + return None + team = db.get(Team, chat_session.team_id) + if team is None or team.tenant_id != chat_session.tenant_id or team.status != "active": + return None + leader = get_team_leader(db, team.id) + if leader is None or leader.agent_id != chat_session.agent_id: + return None + return team + + def _get_feedback_target_message(db: Session, tenant_id: str, user_id: str, message_id: str) -> Message: ensure_tenant(db, tenant_id) row = db.get(Message, message_id) @@ -3347,9 +3462,7 @@ def _event_trace_line( label = "等待SOP" elif runtime_decision in {"start_skill", "start_new_task"}: label = "选择SOP" - elif runtime_decision == "suspend_current_and_start_new_skill": - label = "切换SOP" - elif ( + elif runtime_decision == "suspend_current_and_start_new_skill" or ( runtime_decision in {"answer_related_question_then_resume", "answer_chitchat_then_resume"} and from_skill_id diff --git a/backend/app/api/teams.py b/backend/app/api/teams.py new file mode 100644 index 00000000..b61e1711 --- /dev/null +++ b/backend/app/api/teams.py @@ -0,0 +1,1124 @@ +from __future__ import annotations + +import base64 + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, select + +from app.async_jobs import enqueue_async_job +from app.core import AgentLoop +from app.db import get_session +from app.db.models import ( + AgentProfile, + ChatSession, + Message, + Team, + TeamBlackboardEntry, + TeamTask, + TeamTaskBid, + TeamTaskEvent, + User, + new_id, + utc_now, +) +from app.knowledge.service import IngestPayload, KnowledgeService +from app.security.auth import get_current_user +from app.security.permissions import is_admin_user as _is_admin_user +from app.security.tenant import ensure_tenant +from app.session.session_schema import ChatTurnRequest +from app.teams import service as team_service +from app.teams.schema import ( + AwardOverrideRequest, + ReviewOverrideRequest, + TeamBlackboardEntryArchiveRequest, + TeamBlackboardEntryCreateRequest, + TeamBlackboardEntryRead, + TeamBlackboardEntryUpdateRequest, + TeamBlackboardPromoteRequest, + TeamBlackboardPromoteResponse, + TeamBlackboardWriteResponse, + TeamConversationKind, + TeamConversationMessageRead, + TeamConversationRead, + TeamConversationsResponse, + TeamConversationTLRead, + TeamCreateRequest, + TeamEventRead, + TeamLeaderUpdateRequest, + TeamMemberAddRequest, + TeamMemberRead, + TeamRead, + TeamTaskBidRead, + TeamTaskCreateRequest, + TeamTaskEventRead, + TeamTaskRead, + TeamThreadRead, + TeamTLChatRequest, + TeamTLChatResponse, + TeamTLSessionRequest, + TeamTLSessionResponse, + TeamUpdateRequest, +) +from app.teams.service import ( + VERDICT_TARGET_STATUS, + add_member, + apply_task_transition, + create_team, + delete_team, + get_team, + get_team_leader, + list_team_members, + normalize_blackboard_content, + normalize_blackboard_tags, + record_task_event, + remove_member, + set_leader, + strip_json_blocks, + write_blackboard_entries, +) +from app.teams.wakeup import ( + build_tl_chat_message, + enqueue_wake_event, + process_tl_reply, + start_bidding, + start_wakeup_async, +) + +router = APIRouter(prefix="/api/enterprise/teams", tags=["enterprise:teams"]) + +# 可被人改判的任务状态:TL 验收后(review)或已升级(escalated) +OVERRIDABLE_STATUSES = {"review", "escalated"} + +# 可被人推翻判罚(改派中标者)的任务状态:竞标中(bidding)或执行开始前(pending) +AWARD_OVERRIDABLE_STATUSES = {"bidding", "pending"} + + +def _ensure_request_tenant(tenant_id: str, user: User) -> None: + if user.tenant_id != tenant_id: + raise HTTPException(status_code=403, detail="Tenant mismatch") + + +def _ensure_team_manager(team: Team, user: User) -> None: + """写操作权限:团队创建者(owner)或管理员。""" + if team.owner_user_id != user.id and not _is_admin_user(user): + raise HTTPException(status_code=403, detail="Only team owner or administrator can manage this team") + + +def _member_read(db: Session, member) -> TeamMemberRead: + agent = db.get(AgentProfile, member.agent_id) + return TeamMemberRead( + id=member.id, + team_id=member.team_id, + agent_id=member.agent_id, + role=member.role, + agent_name=agent.name if agent else None, + created_at=member.created_at, + ) + + +def _team_read(db: Session, team: Team) -> TeamRead: + members = [_member_read(db, item) for item in list_team_members(db, team.id)] + return TeamRead( + id=team.id, + tenant_id=team.tenant_id, + name=team.name, + description=team.description, + owner_user_id=team.owner_user_id, + config=dict(team.config_json or {}), + status=team.status, + members=members, + created_at=team.created_at, + updated_at=team.updated_at, + ) + + +def _task_read(db: Session, task: TeamTask, *, with_events: bool = False) -> TeamTaskRead: + events: list[TeamTaskEventRead] = [] + bids: list[TeamTaskBidRead] = [] + if with_events: + rows = db.exec( + select(TeamTaskEvent) + .where(TeamTaskEvent.task_id == task.id) + .order_by(TeamTaskEvent.created_at) + ).all() + events = [ + TeamTaskEventRead( + id=row.id, + task_id=row.task_id, + team_id=row.team_id, + actor_type=row.actor_type, + actor_id=row.actor_id, + event_type=row.event_type, + payload=dict(row.payload_json or {}), + created_at=row.created_at, + ) + for row in rows + ] + bid_rows = db.exec( + select(TeamTaskBid) + .where(TeamTaskBid.task_id == task.id) + .order_by(TeamTaskBid.round, TeamTaskBid.created_at) + ).all() + bids = [ + TeamTaskBidRead( + id=row.id, + task_id=row.task_id, + agent_id=row.agent_id, + agent_name=(agent.name if (agent := db.get(AgentProfile, row.agent_id)) else None), + round=row.round, + kind=row.kind, + content=row.content, + score=row.score, + score_rationale=row.score_rationale, + created_at=row.created_at, + ) + for row in bid_rows + ] + return TeamTaskRead( + id=task.id, + team_id=task.team_id, + tenant_id=task.tenant_id, + parent_task_id=task.parent_task_id, + title=task.title, + description=task.description, + priority=task.priority, + status=task.status, + created_by_user_id=task.created_by_user_id, + created_by_tl=task.created_by_tl, + assignee_agent_id=task.assignee_agent_id, + session_id=task.session_id, + report=dict(task.report_json or {}), + review=dict(task.review_json or {}), + version=task.version, + events=events, + bids=bids, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + +def _get_team_task(db: Session, team: Team, task_id: str) -> TeamTask: + task = db.get(TeamTask, task_id) + if task is None or task.team_id != team.id: + raise HTTPException(status_code=404, detail="Team task not found") + return task + + +@router.post("", response_model=TeamRead) +def create_team_endpoint( + request: TeamCreateRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamRead: + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = create_team( + db, + tenant_id=request.tenant_id, + name=request.name, + description=request.description, + owner_user_id=current_user.id, + config=request.config, + ) + return _team_read(db, team) + + +@router.get("", response_model=list[TeamRead]) +def list_teams( + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> list[TeamRead]: + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + rows = db.exec( + select(Team).where(Team.tenant_id == tenant_id).order_by(Team.updated_at.desc()) + ).all() + return [_team_read(db, row) for row in rows] + + +@router.get("/{team_id}", response_model=TeamRead) +def get_team_endpoint( + team_id: str, + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamRead: + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + return _team_read(db, get_team(db, tenant_id, team_id)) + + +@router.put("/{team_id}", response_model=TeamRead) +def update_team_endpoint( + team_id: str, + request: TeamUpdateRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamRead: + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + if request.name is not None: + name = request.name.strip() + if not name: + raise HTTPException(status_code=400, detail="Team name cannot be empty") + existing = db.exec( + select(Team).where( + Team.tenant_id == team.tenant_id, Team.name == name, Team.id != team.id + ) + ).first() + if existing: + raise HTTPException(status_code=409, detail="Team name already exists") + team.name = name + if request.description is not None: + team.description = request.description + if request.status is not None: + team.status = request.status + if request.config is not None: + team.config_json = dict(request.config) + team.updated_at = utc_now() + db.add(team) + db.commit() + db.refresh(team) + return _team_read(db, team) + + +@router.delete("/{team_id}") +def delete_team_endpoint( + team_id: str, + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> dict[str, bool]: + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + team = get_team(db, tenant_id, team_id) + _ensure_team_manager(team, current_user) + delete_team(db, team) + return {"ok": True} + + +@router.post("/{team_id}/members", response_model=TeamMemberRead) +def add_member_endpoint( + team_id: str, + request: TeamMemberAddRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamMemberRead: + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + member = add_member(db, team, agent_id=request.agent_id, role=request.role) + return _member_read(db, member) + + +@router.delete("/{team_id}/members/{agent_id}") +def remove_member_endpoint( + team_id: str, + agent_id: str, + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> dict[str, bool]: + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + team = get_team(db, tenant_id, team_id) + _ensure_team_manager(team, current_user) + remove_member(db, team, agent_id) + return {"ok": True} + + +@router.put("/{team_id}/leader", response_model=TeamMemberRead) +def set_leader_endpoint( + team_id: str, + request: TeamLeaderUpdateRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamMemberRead: + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + member = set_leader(db, team, request.agent_id) + return _member_read(db, member) + + +@router.post("/{team_id}/tl/chat", response_model=TeamTLChatResponse) +def tl_chat_endpoint( + team_id: str, + request: TeamTLChatRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamTLChatResponse: + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + if not request.message.strip(): + raise HTTPException(status_code=400, detail="Message cannot be empty") + leader = get_team_leader(db, team.id) + if leader is None: + raise HTTPException(status_code=400, detail="Team has no leader (TL) yet") + tl_agent = db.get(AgentProfile, leader.agent_id) + if tl_agent is None or tl_agent.tenant_id != team.tenant_id or tl_agent.status != "active": + raise HTTPException(status_code=400, detail="Team leader agent is unavailable") + if request.session_id: + session = db.get(ChatSession, request.session_id) + # 同一 Agent 可同时担任多个团队的 TL,必须同时校验 team_id 与「TL 对话」类型, + # 否则会把 A 团队的会话写进 B 团队的上下文(任务/审计串线) + if ( + session is None + or session.tenant_id != team.tenant_id + or session.team_id != team.id + or session.agent_id != tl_agent.id + or "TL 对话" not in (session.title or "") + ): + raise HTTPException(status_code=404, detail="TL chat session not found") + else: + session = ChatSession( + id=new_id("session"), + tenant_id=team.tenant_id, + user_id=current_user.id, + agent_id=tl_agent.id, + title=f"团队 {team.name} · TL 对话", + status="active", + team_id=team.id, + ) + db.add(session) + db.commit() + db.refresh(session) + turn = ChatTurnRequest( + tenant_id=team.tenant_id, + session_id=session.id, + agent_id=tl_agent.id, + client_turn_id=new_id("teamturn"), + user_id=current_user.id, + message=build_tl_chat_message(db, team, request.message), + channel="team", + interaction_mode="team_tl", + ) + response = AgentLoop(db).handle_turn(turn) + reply = response.reply or "" + created = process_tl_reply( + db, + team=team, + session=session, + user=current_user, + user_message=request.message, + reply=reply, + client_turn_id=turn.client_turn_id, + ) + clean_reply = strip_json_blocks(reply) + return TeamTLChatResponse( + reply=clean_reply or reply, + session_id=session.id, + created_tasks=[_task_read(db, task) for task in created], + ) + + +@router.post("/{team_id}/tl/session", response_model=TeamTLSessionResponse) +def tl_session_endpoint( + team_id: str, + request: TeamTLSessionRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamTLSessionResponse: + """get-or-create 团队 TL 会话,供前端跳转工作区聊天(幂等)。""" + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + leader = get_team_leader(db, team.id) + if leader is None: + raise HTTPException(status_code=400, detail="Team has no leader (TL) yet") + tl_agent = db.get(AgentProfile, leader.agent_id) + if tl_agent is None or tl_agent.tenant_id != team.tenant_id or tl_agent.status != "active": + raise HTTPException(status_code=400, detail="Team leader agent is unavailable") + # 团队内 TL 名下的会话还有任务验收/竞标打分/竞标裁决,只有「TL 对话」标题的才是人对 TL 的聊天会话 + session = db.exec( + select(ChatSession) + .where( + ChatSession.tenant_id == team.tenant_id, + ChatSession.team_id == team.id, + ChatSession.agent_id == tl_agent.id, + ChatSession.title.like("%TL 对话%"), + ) + .order_by(ChatSession.created_at) + ).first() + if session is None: + session = ChatSession( + id=new_id("session"), + tenant_id=team.tenant_id, + user_id=current_user.id, + agent_id=tl_agent.id, + title=f"团队 {team.name} · TL 对话", + status="active", + team_id=team.id, + ) + db.add(session) + db.commit() + db.refresh(session) + return TeamTLSessionResponse(session_id=session.id) + + +# ---------- 团队 TL 工作台聊天室(按团队维度查询会话与消息) ---------- + + +def _conversation_kind(session: ChatSession) -> TeamConversationKind: + """会话种类按标题前缀判定,与 wakeup.py / tl_chat 端点的命名约定一一对应(测试锁定): + + 「团队任务验收:」-> tl_review、「团队任务:」-> member_task、 + 「团队竞标」(竞标:/竞标打分:/竞标裁决:)-> member_bid、 + 其余(「团队 xx · TL 对话」)-> tl_chat。 + 标题前缀是当前唯一的持久化判据(session 无 kind 列),改命名约定需同步改这里。 + """ + title = session.title or "" + if title.startswith("团队任务验收:"): + return "tl_review" + if title.startswith("团队任务:"): + return "member_task" + if title.startswith("团队竞标"): + return "member_bid" + return "tl_chat" + + +def _tl_conversation_session(db: Session, team: Team, leader_agent_id: str) -> ChatSession | None: + """已有 TL 对话会话(与 tl/session 端点同判据:「TL 对话」标题,取最早一个)。""" + return db.exec( + select(ChatSession) + .where( + ChatSession.tenant_id == team.tenant_id, + ChatSession.team_id == team.id, + ChatSession.agent_id == leader_agent_id, + ChatSession.title.like("%TL 对话%"), + ) + .order_by(ChatSession.created_at) + ).first() + + +@router.get("/{team_id}/conversations", response_model=TeamConversationsResponse) +def list_team_conversations( + team_id: str, + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamConversationsResponse: + """团队会话列表:本租户登录用户可读(对齐 GET tasks);严格按 team_id 过滤,不串团队。""" + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + team = get_team(db, tenant_id, team_id) + leader = get_team_leader(db, team.id) + tl: TeamConversationTLRead | None = None + if leader is not None: + tl_agent = db.get(AgentProfile, leader.agent_id) + tl_session = _tl_conversation_session(db, team, leader.agent_id) + tl = TeamConversationTLRead( + agent_id=leader.agent_id, + agent_name=tl_agent.name if tl_agent else None, + session_id=tl_session.id if tl_session else None, + ) + sessions = list( + db.exec( + select(ChatSession).where( + ChatSession.tenant_id == tenant_id, + ChatSession.team_id == team.id, + ) + ).all() + ) + # 每个会话的末条消息:按 created_at 正序扫一遍,后者覆盖前者 + last_messages: dict[str, Message] = {} + if sessions: + message_rows = db.exec( + select(Message) + .where(Message.session_id.in_([item.id for item in sessions])) + .order_by(Message.created_at) + ).all() + for row in message_rows: + last_messages[row.session_id] = row + # member_task 会话由 task.session_id 反向关联任务 + task_by_session = { + str(task.session_id): task.id + for task in db.exec( + select(TeamTask).where( + TeamTask.team_id == team.id, + TeamTask.session_id.in_([item.id for item in sessions]), + ) + ).all() + } if sessions else {} + agent_ids = {item.agent_id for item in sessions if item.agent_id} + agent_names = { + agent.id: agent.name + for agent in db.exec(select(AgentProfile).where(AgentProfile.id.in_(agent_ids))).all() + } if agent_ids else {} + conversations: list[TeamConversationRead] = [] + for item in sessions: + last = last_messages.get(item.id) + conversations.append( + TeamConversationRead( + session_id=item.id, + kind=_conversation_kind(item), + agent_id=item.agent_id, + agent_name=agent_names.get(item.agent_id or ""), + task_id=task_by_session.get(item.id), + title=item.title or "", + preview=last.content[:80] if last else "", + updated_at=last.created_at if last else item.created_at, + ) + ) + conversations.sort(key=lambda entry: entry.updated_at, reverse=True) + return TeamConversationsResponse( + team_id=team.id, + team_name=team.name, + tl=tl, + conversations=conversations, + ) + + +@router.get( + "/{team_id}/conversations/{session_id}/messages", + response_model=list[TeamConversationMessageRead], +) +def list_team_conversation_messages( + team_id: str, + session_id: str, + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> list[TeamConversationMessageRead]: + """团队会话消息:会话必须属于本团队(物理隔离),跨团队 sessionId 一律 404。""" + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + team = get_team(db, tenant_id, team_id) + session = db.get(ChatSession, session_id) + if session is None or session.tenant_id != tenant_id or session.team_id != team.id: + raise HTTPException(status_code=404, detail="Team conversation not found") + rows = db.exec( + select(Message).where(Message.session_id == session.id).order_by(Message.created_at) + ).all() + return [ + TeamConversationMessageRead( + id=row.id, role=row.role, content=row.content, created_at=row.created_at + ) + for row in rows + ] + + +@router.post("/{team_id}/tasks", response_model=TeamTaskRead) +def create_team_task_endpoint( + team_id: str, + request: TeamTaskCreateRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamTaskRead: + """人直接建任务:指定 assignee 直派(同 TL 直派链路);省略则投入任务池竞标。""" + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + title = request.title.strip() + if not title: + raise HTTPException(status_code=400, detail="Task title cannot be empty") + assignee = (request.assignee_agent_id or "").strip() + if assignee: + member_ids = {item.agent_id for item in list_team_members(db, team.id)} + if assignee not in member_ids: + raise HTTPException(status_code=404, detail="Agent is not a team member") + task = TeamTask( + team_id=team.id, + tenant_id=team.tenant_id, + title=title, + description=request.description, + priority=request.priority or "normal", + status="pending", + created_by_user_id=current_user.id, + created_by_tl=False, + assignee_agent_id=assignee or None, + ) + db.add(task) + db.flush() + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="user", + actor_id=current_user.id, + event_type="task_created", + payload={"title": task.title, "assignee_agent_id": assignee or None}, + ) + wake_id: str | None = None + if assignee: + wake = enqueue_wake_event( + db, + team=team, + target_agent_id=assignee, + trigger_type="task_assigned", + payload={"task_id": task.id}, + ) + wake_id = wake.id + db.commit() + db.refresh(task) + if wake_id is not None: + start_wakeup_async(wake_id) + else: + start_bidding(db, team, task) + return _task_read(db, task, with_events=True) + + +@router.get("/{team_id}/events", response_model=list[TeamEventRead]) +def list_team_events( + team_id: str, + tenant_id: str = Query(...), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> list[TeamEventRead]: + """团队级审计流水:全团队 task_events 按 created_at 倒序聚合,含任务标题。""" + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + team = get_team(db, tenant_id, team_id) + rows = db.exec( + select(TeamTaskEvent) + .where(TeamTaskEvent.team_id == team.id) + .order_by(TeamTaskEvent.created_at.desc()) + .limit(limit) + ).all() + task_titles = { + task.id: task.title + for task in db.exec(select(TeamTask).where(TeamTask.team_id == team.id)).all() + } + return [ + TeamEventRead( + id=row.id, + task_id=row.task_id, + team_id=row.team_id, + task_title=task_titles.get(row.task_id), + actor_type=row.actor_type, + actor_id=row.actor_id, + event_type=row.event_type, + payload=dict(row.payload_json or {}), + created_at=row.created_at, + ) + for row in rows + ] + + +@router.get("/{team_id}/tasks", response_model=list[TeamTaskRead]) +def list_team_tasks( + team_id: str, + tenant_id: str = Query(...), + status: str | None = Query(None), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> list[TeamTaskRead]: + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + team = get_team(db, tenant_id, team_id) + if status is not None and status not in team_service.TASK_STATUSES: + raise HTTPException(status_code=400, detail=f"Unknown task status: {status}") + statement = select(TeamTask).where(TeamTask.team_id == team.id) + if status is not None: + statement = statement.where(TeamTask.status == status) + rows = db.exec(statement.order_by(TeamTask.updated_at.desc())).all() + return [_task_read(db, row) for row in rows] + + +@router.get("/{team_id}/tasks/{task_id}", response_model=TeamTaskRead) +def get_team_task( + team_id: str, + task_id: str, + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamTaskRead: + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + team = get_team(db, tenant_id, team_id) + return _task_read(db, _get_team_task(db, team, task_id), with_events=True) + + +@router.post("/{team_id}/tasks/{task_id}/award-override", response_model=TeamTaskRead) +def override_task_award( + team_id: str, + task_id: str, + request: AwardOverrideRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamTaskRead: + """人推翻竞标判罚(HITL):竞标中或执行开始前可改写中标者并重新派发。""" + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + task = _get_team_task(db, team, task_id) + if task.status not in AWARD_OVERRIDABLE_STATUSES: + raise HTTPException( + status_code=409, + detail=f"Task in status {task.status} cannot be award-overridden", + ) + member_ids = {item.agent_id for item in list_team_members(db, team.id)} + if request.agent_id not in member_ids: + raise HTTPException(status_code=404, detail="Agent is not a team member") + previous = task.assignee_agent_id + task.assignee_agent_id = request.agent_id + apply_task_transition( + db, + task, + "pending", + actor_type="user", + actor_id=current_user.id, + event_type="award_overridden", + payload={ + "previous_assignee_agent_id": previous, + "winner_agent_id": request.agent_id, + "comment": request.comment or "", + }, + ) + db.add(task) + db.commit() + db.refresh(task) + wake = enqueue_wake_event( + db, + team=team, + target_agent_id=request.agent_id, + trigger_type="task_assigned", + payload={"task_id": task.id}, + ) + db.commit() + start_wakeup_async(wake.id) + return _task_read(db, task, with_events=True) + + +@router.post("/{team_id}/tasks/{task_id}/override", response_model=TeamTaskRead) +def override_task_review( + team_id: str, + task_id: str, + request: ReviewOverrideRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamTaskRead: + """人改判 TL 的验收结论(HITL):approve->done / rework->退回重做 / escalate->升级。""" + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + task = _get_team_task(db, team, task_id) + if task.status not in OVERRIDABLE_STATUSES: + raise HTTPException( + status_code=409, + detail=f"Task in status {task.status} cannot be overridden", + ) + target = VERDICT_TARGET_STATUS[request.verdict] + payload = {"verdict": request.verdict, "comment": request.comment or "", "override": True} + if target in team_service.TASK_TRANSITIONS.get(task.status, set()) or target == task.status: + apply_task_transition( + db, + task, + target, + actor_type="user", + actor_id=current_user.id, + event_type=f"review_override_{request.verdict}", + payload=payload, + ) + else: + # 人已升级(escalated)的任务改判不走状态机,直接落库并写审计 + previous = task.status + task.status = target + task.version += 1 + task.updated_at = utc_now() + db.add(task) + record_task_event( + db, + team_id=task.team_id, + task_id=task.id, + actor_type="user", + actor_id=current_user.id, + event_type=f"review_override_{request.verdict}", + payload={"from_status": previous, "to_status": target, **payload}, + ) + task.review_json = { + **dict(task.review_json or {}), + "verdict": request.verdict, + "comment": request.comment or "", + "overridden_by_user_id": current_user.id, + "reviewed_at": utc_now().isoformat(), + } + db.add(task) + db.commit() + db.refresh(task) + if request.verdict == "rework" and task.assignee_agent_id: + wake = enqueue_wake_event( + db, + team=team, + target_agent_id=task.assignee_agent_id, + trigger_type="task_rework", + payload={"task_id": task.id}, + ) + db.commit() + start_wakeup_async(wake.id) + return _task_read(db, task, with_events=True) + + +def _blackboard_entry_read(entry: TeamBlackboardEntry) -> TeamBlackboardEntryRead: + return TeamBlackboardEntryRead( + id=entry.id, + team_id=entry.team_id, + tenant_id=entry.tenant_id, + content=entry.content, + tags=list(entry.tags_json or []), + source_type=entry.source_type, + source_agent_id=entry.source_agent_id, + source_task_id=entry.source_task_id, + citation=dict(entry.citation_json or {}), + status=entry.status, + pinned=entry.pinned, + created_at=entry.created_at, + updated_at=entry.updated_at, + ) + + +def _get_blackboard_entry(db: Session, team: Team, entry_id: str) -> TeamBlackboardEntry: + entry = db.get(TeamBlackboardEntry, entry_id) + if entry is None or entry.team_id != team.id: + raise HTTPException(status_code=404, detail="Blackboard entry not found") + return entry + + +@router.get("/{team_id}/blackboard", response_model=list[TeamBlackboardEntryRead]) +def list_blackboard_entries( + team_id: str, + tenant_id: str = Query(...), + status: str = Query("active"), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> list[TeamBlackboardEntryRead]: + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + team = get_team(db, tenant_id, team_id) + if status not in team_service.BLACKBOARD_STATUSES: + raise HTTPException(status_code=400, detail=f"Unknown blackboard status: {status}") + rows = db.exec( + select(TeamBlackboardEntry) + .where(TeamBlackboardEntry.team_id == team.id, TeamBlackboardEntry.status == status) + .order_by(TeamBlackboardEntry.pinned.desc(), TeamBlackboardEntry.updated_at.desc()) + ).all() + return [_blackboard_entry_read(row) for row in rows] + + +@router.post("/{team_id}/blackboard", response_model=TeamBlackboardWriteResponse) +def create_blackboard_entry( + team_id: str, + request: TeamBlackboardEntryCreateRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamBlackboardWriteResponse: + """人直写黑板:与 TL 裁决写入走同一条轻量流水线。""" + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + written, skipped = write_blackboard_entries( + db, + team=team, + entries=[{"content": request.content, "tags": request.tags}], + source_type="human", + ) + db.commit() + for entry in written: + db.refresh(entry) + return TeamBlackboardWriteResponse( + entries=[_blackboard_entry_read(entry) for entry in written], + skipped=skipped, + ) + + +@router.put("/{team_id}/blackboard/{entry_id}", response_model=TeamBlackboardEntryRead) +def update_blackboard_entry( + team_id: str, + entry_id: str, + request: TeamBlackboardEntryUpdateRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamBlackboardEntryRead: + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + entry = _get_blackboard_entry(db, team, entry_id) + if request.content is not None: + content = normalize_blackboard_content(request.content) + if not content: + raise HTTPException(status_code=400, detail="Blackboard content cannot be empty") + entry.content = content + if request.tags is not None: + entry.tags_json = normalize_blackboard_tags(request.tags) + if request.pinned is not None: + entry.pinned = request.pinned + entry.updated_at = utc_now() + db.add(entry) + db.commit() + db.refresh(entry) + return _blackboard_entry_read(entry) + + +@router.post("/{team_id}/blackboard/{entry_id}/archive", response_model=TeamBlackboardEntryRead) +def archive_blackboard_entry( + team_id: str, + entry_id: str, + request: TeamBlackboardEntryArchiveRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamBlackboardEntryRead: + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + entry = _get_blackboard_entry(db, team, entry_id) + entry.status = "archived" + entry.updated_at = utc_now() + db.add(entry) + db.commit() + db.refresh(entry) + return _blackboard_entry_read(entry) + + +@router.post( + "/{team_id}/blackboard/{entry_id}/promote", + response_model=TeamBlackboardPromoteResponse, +) +def promote_blackboard_entry( + team_id: str, + entry_id: str, + request: TeamBlackboardPromoteRequest, + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> TeamBlackboardPromoteResponse: + """黑板条目沉淀到知识库:拼成 markdown 作为原始资料建 ingest job,异步执行。 + + 幂等:已沉淀的条目直接返回 citation 里的既有引用,不重复建 job。 + """ + ensure_tenant(db, request.tenant_id) + _ensure_request_tenant(request.tenant_id, current_user) + team = get_team(db, request.tenant_id, team_id) + _ensure_team_manager(team, current_user) + entry = _get_blackboard_entry(db, team, entry_id) + citation = dict(entry.citation_json or {}) + existing_job_id = str(citation.get("ingest_job_id") or "") + if existing_job_id: + return TeamBlackboardPromoteResponse( + entry=_blackboard_entry_read(entry), + knowledge_base_id=str(citation.get("knowledge_base_id") or ""), + ingest_job_id=existing_job_id, + already_promoted=True, + ) + service = KnowledgeService(db) + knowledge_base = service.ensure_default_knowledge_base(team.tenant_id) + source_task_title = "" + if entry.source_task_id: + source_task = db.get(TeamTask, entry.source_task_id) + source_task_title = source_task.title if source_task else "" + # 内容 + tags 拼成一段 markdown,标注来源团队/任务 + lines = [f"# 团队黑板沉淀 · {team.name}", ""] + lines.append(f"> 来源团队:{team.name}(team_id={team.id})") + if entry.source_task_id: + lines.append(f"> 来源任务:{source_task_title or entry.source_task_id}(task_id={entry.source_task_id})") + if entry.tags_json: + lines.append(f"> 标签:{', '.join(str(tag) for tag in entry.tags_json)}") + lines.extend(["", entry.content, ""]) + markdown = "\n".join(lines) + filename = f"team-blackboard-{entry.id}.md" + job = service.create_ingest_job( + IngestPayload( + tenant_id=team.tenant_id, + knowledge_base_id=knowledge_base.id, + filename=filename, + content_base64=base64.b64encode(markdown.encode("utf-8")).decode("ascii"), + title=f"团队黑板:{entry.content[:30]}", + metadata={ + "source": "team_blackboard", + "team_id": team.id, + "blackboard_entry_id": entry.id, + }, + ) + ) + entry.citation_json = { + **citation, + "knowledge_base_id": knowledge_base.id, + "ingest_job_id": job.id, + } + entry.updated_at = utc_now() + db.add(entry) + db.commit() + db.refresh(entry) + # 异步执行与知识库文档上传同款:进程内 AsyncJob 队列 + enqueue_async_job( + "knowledge_ingest", + service.run_ingest_job, + job.id, + metadata={"tenant_id": team.tenant_id, "filename": filename}, + ) + return TeamBlackboardPromoteResponse( + entry=_blackboard_entry_read(entry), + knowledge_base_id=knowledge_base.id, + ingest_job_id=job.id, + already_promoted=False, + ) + + +# 跨团队统一线程列表:独立前缀 /api/enterprise/team-threads,与 /teams/{team_id} 无冲突 +threads_router = APIRouter(prefix="/api/enterprise/team-threads", tags=["enterprise:teams"]) + + +@threads_router.get("", response_model=list[TeamThreadRead]) +def list_team_threads( + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> list[TeamThreadRead]: + """TL 对话会话 + 任务执行会话,按 updated_at 倒序取 50 条。""" + ensure_tenant(db, tenant_id) + _ensure_request_tenant(tenant_id, current_user) + teams = list(db.exec(select(Team).where(Team.tenant_id == tenant_id)).all()) + threads: list[TeamThreadRead] = [] + for team in teams: + leader = get_team_leader(db, team.id) + if leader is not None: + tl_sessions = db.exec( + select(ChatSession).where( + ChatSession.tenant_id == tenant_id, + ChatSession.team_id == team.id, + ChatSession.agent_id == leader.agent_id, + ChatSession.title.like("%TL 对话%"), + ) + ).all() + for session in tl_sessions: + threads.append( + TeamThreadRead( + team_id=team.id, + team_name=team.name, + kind="tl_chat", + session_id=session.id, + title=session.title or f"团队 {team.name} · TL 对话", + updated_at=session.updated_at, + ) + ) + task_rows = db.exec( + select(TeamTask).where( + TeamTask.team_id == team.id, TeamTask.session_id.is_not(None) + ) + ).all() + for task in task_rows: + threads.append( + TeamThreadRead( + team_id=team.id, + team_name=team.name, + kind="task", + session_id=str(task.session_id), + task_id=task.id, + title=task.title, + task_status=task.status, + updated_at=task.updated_at, + ) + ) + threads.sort(key=lambda item: item.updated_at, reverse=True) + return threads[:50] diff --git a/backend/app/channels/schema.py b/backend/app/channels/schema.py index 6a7cddaa..6a0c6bfc 100644 --- a/backend/app/channels/schema.py +++ b/backend/app/channels/schema.py @@ -5,12 +5,14 @@ from pydantic import BaseModel from sqlmodel import Session -from app.db.models import ChannelBinding, ChannelDelivery, User +from app.db.models import ChannelBinding, ChannelDelivery, Team, User class ChannelBindingCreate(BaseModel): tenant_id: str - agent_id: str + # 与 team_id 互斥:挂员工集(现状)或绑一个团队,二选一 + agent_id: Optional[str] = None + team_id: Optional[str] = None channel: str = "wechat" @@ -39,6 +41,9 @@ class ChannelBindingRead(BaseModel): tenant_id: str agent_id: str channel: str + # 团队绑定:非空表示接入某团队(与员工挂载互斥) + team_id: Optional[str] = None + team_name: Optional[str] = None status: str connected: bool ilink_bot_id: Optional[str] = None @@ -225,11 +230,17 @@ def channel_binding_creator_name(db: Session, binding: ChannelBinding) -> Option def channel_binding_read(db: Session, binding: ChannelBinding) -> ChannelBindingRead: config = dict(binding.config_json or {}) bound_at = config.get("bound_at") + team_name: Optional[str] = None + if binding.team_id: + team = db.get(Team, binding.team_id) + team_name = team.name if team else None return ChannelBindingRead( id=binding.id, tenant_id=binding.tenant_id, agent_id=binding.agent_id, channel=binding.channel, + team_id=binding.team_id, + team_name=team_name, status=binding.status, connected=binding.connected, ilink_bot_id=config.get("ilink_bot_id"), diff --git a/backend/app/channels/service_intake.py b/backend/app/channels/service_intake.py index f18a5d46..b33b4490 100644 --- a/backend/app/channels/service_intake.py +++ b/backend/app/channels/service_intake.py @@ -44,6 +44,7 @@ ChatSession, MemoryRecord, Message, + Team, User, new_id, utc_now, @@ -831,6 +832,9 @@ def process_inbound( if command: if command.kind in {"bind", "unbind"}: reply = _run_bind_command(db, binding, inbound, command) + elif binding.team_id: + # 团队绑定:消息直路由团队 TL,员工列表/切换等指令无意义 + reply = "该渠道已接入团队,消息由团队 TL 统一接收,员工切换类指令不可用。" else: reply = run_command(db, binding, inbound.external_conv_id, command) _stage_notice( @@ -872,15 +876,63 @@ def process_inbound( db.add(event) db.commit() return False - current_agent_id, pointer_reset = resolve_current_agent(db, binding, inbound.external_conv_id) - pre_route_agent_id = current_agent_id - # 智能前台:LLM 意图分类自动分发(开关/挂载数/粘性保护由 maybe_auto_route 把关,异常全部回退当前) - route_decision = maybe_auto_route(db, binding, current_agent_id, inbound.external_conv_id, inbound.text) - if route_decision and route_decision.switched: - current_agent_id = route_decision.agent_id - chat_session = find_or_create_channel_session( - db, binding, user, current_agent_id, inbound.external_conv_id, inbound.text - ) + team: Team | None = None + team_leader_agent_id: str | None = None + if binding.team_id: + # 团队绑定:跳过路由指针/自动分发,消息直路由团队现任 TL(换帅自动跟随) + from app.teams.service import get_team_leader + + team_row = db.get(Team, binding.team_id) + if team_row and team_row.tenant_id == binding.tenant_id and team_row.status == "active": + team = team_row + team_notice: str | None = None + if team is None: + team_notice = "该渠道绑定的团队已解散或停用,请联系管理员调整渠道绑定。" + else: + leader = get_team_leader(db, team.id) + if leader is None: + team_notice = f"团队「{team.name}」暂未设置 TL,请先在 StaffDeck 网页端设置 TL 后再试。" + else: + team_leader_agent_id = leader.agent_id + if team_notice is not None: + _stage_notice( + db, + binding, + inbound.external_conv_id, + target, + team_notice, + final_for_event=True, + ) + event.status = "done" + event.processed_at = utc_now() + event.updated_at = utc_now() + db.add(event) + db.commit() + return False + if team is not None: + current_agent_id = team_leader_agent_id + pointer_reset = False + route_decision = None + chat_session = find_or_create_channel_session( + db, + binding, + user, + current_agent_id, + inbound.external_conv_id, + inbound.text, + team_id=team.id, + team_title=f"团队 {team.name} · TL 对话", + ) + else: + current_agent_id, pointer_reset = resolve_current_agent(db, binding, inbound.external_conv_id) + pre_route_agent_id = current_agent_id + # 智能前台:LLM 意图分类自动分发(开关/挂载数/粘性保护由 maybe_auto_route 把关,异常全部回退当前) + route_decision = maybe_auto_route(db, binding, current_agent_id, inbound.external_conv_id, inbound.text) + if route_decision and route_decision.switched: + current_agent_id = route_decision.agent_id + chat_session = find_or_create_channel_session( + db, binding, user, current_agent_id, inbound.external_conv_id, inbound.text + ) # 群聊回复投递到群会话,私聊投递到发言人 chat_session.channel_target_json = target db.add(chat_session) @@ -919,6 +971,7 @@ def process_inbound( session_id = chat_session.id event_id = event.id user_id = user.id + turn_team_id = team.id if team is not None else None with _session_lock(session_id): with Session(use_engine) as db: @@ -947,19 +1000,31 @@ def process_inbound( inbound.event_id, ) + # 团队绑定:重新挂到当前会话(跨 Session 边界只带 id) + team = db.get(Team, turn_team_id) if turn_team_id else None + user_message = _message_text(binding, inbound) + message = user_message + interaction_mode = "normal" + if team is not None: + # 注入团队上下文(花名册/未闭环任务/黑板/派任务格式),与主聊天端 TL 会话同语义 + from app.teams.wakeup import build_tl_chat_message + + message = build_tl_chat_message(db, team, user_message) + interaction_mode = "team_tl" request = ChatTurnRequest( tenant_id=binding.tenant_id, session_id=session_id, agent_id=current_agent_id, user_id=user_id, - message=_message_text(binding, inbound), + message=message, channel=binding.channel, client_turn_id=inbound.event_id, attachments=attachments, + interaction_mode=interaction_mode, ) _send_wechat_typing(binding, inbound.from_user_id, inbound.context_token, 1, db_engine=use_engine) try: - AgentLoop(db).handle_turn(request) + response = AgentLoop(db).handle_turn(request) except Exception as exc: logger.exception("渠道入站处理失败 binding=%s event=%s", binding.id, inbound.event_id) db.rollback() @@ -981,6 +1046,25 @@ def process_inbound( event.updated_at = utc_now() db.add(event) db.commit() + if team is not None: + # TL 回复后处理:解析派任务块并创建任务(与主聊天端同语义); + # 后处理失败不影响本轮回复 + from app.teams.wakeup import process_tl_reply + + try: + turn_user = db.get(User, user_id) + if turn_user is not None: + process_tl_reply( + db, + team=team, + session=chat_session, + user=turn_user, + user_message=user_message, + reply=(response.reply if response else "") or "", + client_turn_id=inbound.event_id, + ) + except Exception: + logger.exception("渠道团队 TL 回复后处理失败 binding=%s", binding.id) return True diff --git a/backend/app/channels/service_session.py b/backend/app/channels/service_session.py index 26c6b9f6..e02e6405 100644 --- a/backend/app/channels/service_session.py +++ b/backend/app/channels/service_session.py @@ -83,8 +83,15 @@ def find_or_create_channel_session( agent_id: str, external_conv_id: str, first_text: str, + *, + team_id: str | None = None, + team_title: str | None = None, ) -> ChatSession: - """按 (agent_id, channel, external_conv_id) 锚定渠道会话,无则创建。""" + """按 (agent_id, channel, external_conv_id) 锚定渠道会话,无则创建。 + + 团队绑定传 team_id/team_title:创建时落 team_id 并以「团队 X · TL 对话」为题, + 命中 api/chat 的 TL 会话三条件识别;TL 换帅后按新 agent_id 锚定自然另起会话。 + """ chat_session = find_channel_session(db, binding, agent_id, external_conv_id) if chat_session: if chat_session.user_id != user.id: @@ -105,7 +112,7 @@ def find_or_create_channel_session( db.add(chat_session) return chat_session - title = (first_text or "").strip()[:_CHANNEL_TITLE_LIMIT] or None + title = team_title or (first_text or "").strip()[:_CHANNEL_TITLE_LIMIT] or None chat_session = ChatSession( id=new_id("session"), tenant_id=binding.tenant_id, @@ -116,6 +123,7 @@ def find_or_create_channel_session( external_conv_id=external_conv_id, channel_binding_id=binding.id, channel_account_key=binding.external_account_key, + team_id=team_id, ) db.add(chat_session) try: diff --git a/backend/app/core/agent_loop.py b/backend/app/core/agent_loop.py index 52a8fcbd..e68d2950 100644 --- a/backend/app/core/agent_loop.py +++ b/backend/app/core/agent_loop.py @@ -1423,6 +1423,7 @@ def _mark_session_running(self, chat_session: ChatSession) -> None: chat_session.updated_at = utc_now() self.db.add(chat_session) + @staticmethod def _fallback_session_title_from_message(message: str) -> str: return ConversationProjection.fallback_session_title(message) diff --git a/backend/app/core/conversation_projection.py b/backend/app/core/conversation_projection.py index b14b83a4..179db801 100644 --- a/backend/app/core/conversation_projection.py +++ b/backend/app/core/conversation_projection.py @@ -92,8 +92,8 @@ def user_message_metadata(request: ChatTurnRequest) -> dict[str, Any]: metadata: dict[str, Any] = {} if request.client_turn_id: metadata["client_turn_id"] = request.client_turn_id - if request.interaction_mode == "scheduled_task": - metadata["interaction_mode"] = "scheduled_task" + if request.interaction_mode != "normal": + metadata["interaction_mode"] = request.interaction_mode if request.model_config_id: metadata["model_config_id"] = request.model_config_id if request.attachments: diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 3dfbb768..99afd2f3 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -145,6 +145,8 @@ def _migrate_sqlite_skill_schema() -> None: conn.execute(text("ALTER TABLE sessions ADD COLUMN channel_binding_id VARCHAR")) if "channel_account_key" not in session_columns: conn.execute(text("ALTER TABLE sessions ADD COLUMN channel_account_key VARCHAR")) + if "team_id" not in session_columns: + conn.execute(text("ALTER TABLE sessions ADD COLUMN team_id VARCHAR")) # SQLite 唯一索引中 NULL 互不相等,web 会话(channel 为空)不受约束; # 含 channel_binding_id 以隔离同企业多 Bot(老三列索引先 DROP 再按新四列重建) session_index_columns = { @@ -181,6 +183,8 @@ def _migrate_sqlite_skill_schema() -> None: binding_columns = {column["name"] for column in inspector.get_columns("channel_bindings")} if "last_connected_at" not in binding_columns: conn.execute(text("ALTER TABLE channel_bindings ADD COLUMN last_connected_at DATETIME")) + if "team_id" not in binding_columns: + conn.execute(text("ALTER TABLE channel_bindings ADD COLUMN team_id VARCHAR")) if "channel_deliveries" in tables: delivery_columns = {column["name"] for column in inspector.get_columns("channel_deliveries")} diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 321bc075..25ba7ee7 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -762,6 +762,8 @@ class ChatSession(SQLModel, table=True): channel_binding_id: Optional[str] = None # 渠道外部账号稳定键:绑定删除后仍保留,仅允许同一外部 Bot 精确认领历史会话 channel_account_key: Optional[str] = Field(default=None, index=True) + # 团队会话挂接:非空表示该会话属于某团队(TL 对话/任务执行等) + team_id: Optional[str] = Field(default=None, index=True) created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) @@ -773,6 +775,9 @@ class ChannelBinding(SQLModel, table=True): tenant_id: str = Field(index=True) agent_id: str = Field(index=True) channel: str = Field(default="wechat", index=True) + # 团队绑定:非空表示该渠道接入某团队(与员工挂载互斥),入站消息直路由团队 TL; + # 存 team_id 不存 leader,换帅自动跟随 + team_id: Optional[str] = Field(default=None, index=True) # pending/active/expired/disabled status: str = Field(default="pending", index=True) # Fernet 加密后的渠道凭证(如微信 bot_token),绝不回传明文 @@ -1253,3 +1258,142 @@ class MemoryRecord(SQLModel, table=True): metadata_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) created_at: datetime = Field(default_factory=utc_now) updated_at: datetime = Field(default_factory=utc_now) + + +class Team(SQLModel, table=True): + """多 Agent 团队:一名 TL(leader 角色成员)+ 若干成员(数字员工)。""" + + __tablename__ = "teams" + __table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_team_tenant_name"),) + + id: str = Field(default_factory=lambda: new_id("team"), primary_key=True) + tenant_id: str = Field(index=True) + name: str + description: Optional[str] = None + owner_user_id: str = Field(index=True) + # 预留:并发策略/竞标等团队级配置 + config_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + status: str = Field(default="active", index=True) + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + +class TeamMember(SQLModel, table=True): + __tablename__ = "team_members" + __table_args__ = (UniqueConstraint("team_id", "agent_id", name="uq_team_member_agent"),) + + id: str = Field(default_factory=lambda: new_id("team_member"), primary_key=True) + team_id: str = Field(index=True) + agent_id: str = Field(index=True) + # role: leader(TL,每团队至多一名)/ member + role: str = Field(default="member", index=True) + created_at: datetime = Field(default_factory=utc_now) + + +class TeamTask(SQLModel, table=True): + """团队任务:pending -> in_progress -> review -> done/rework/escalated; + + rework -> in_progress 重入;pending -> bidding -> pending 为任务池竞标链路。 + """ + + __tablename__ = "team_tasks" + + id: str = Field(default_factory=lambda: new_id("team_task"), primary_key=True) + team_id: str = Field(index=True) + tenant_id: str = Field(index=True) + parent_task_id: Optional[str] = Field(default=None, index=True) + title: str + description: Optional[str] = None + priority: str = Field(default="normal", index=True) + status: str = Field(default="pending", index=True) + created_by_user_id: Optional[str] = Field(default=None, index=True) + created_by_tl: bool = False + assignee_agent_id: Optional[str] = Field(default=None, index=True) + session_id: Optional[str] = Field(default=None, index=True) + report_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + review_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + # 乐观锁版本号,人改判/验收并发时防覆盖 + version: int = 0 + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + +class TeamTaskEvent(SQLModel, table=True): + """团队任务审计流水。""" + + __tablename__ = "team_task_events" + + id: str = Field(default_factory=lambda: new_id("team_task_event"), primary_key=True) + task_id: str = Field(index=True) + team_id: str = Field(index=True) + # actor_type: user / agent / system + actor_type: str = Field(index=True) + actor_id: Optional[str] = Field(default=None, index=True) + event_type: str = Field(index=True) + payload_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + created_at: datetime = Field(default_factory=utc_now) + + +class TeamWakeEvent(SQLModel, table=True): + """团队唤醒事件:任务派发/报告/退回时唤醒对应成员或 TL 的后台会话。""" + + __tablename__ = "team_wake_events" + + id: str = Field(default_factory=lambda: new_id("team_wake"), primary_key=True) + team_id: str = Field(index=True) + tenant_id: str = Field(index=True) + target_agent_id: str = Field(index=True) + # trigger_type: task_assigned / task_report / task_rework / tl_message 等 + trigger_type: str = Field(index=True) + payload_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + # status: pending -> claimed -> done / failed + status: str = Field(default="pending", index=True) + error: Optional[str] = None + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + +class TeamBlackboardEntry(SQLModel, table=True): + """团队黑板:团队工作记忆,TL 裁决写入/人直写,按团队隔离。""" + + __tablename__ = "team_blackboard_entries" + + id: str = Field(default_factory=lambda: new_id("bbentry"), primary_key=True) + team_id: str = Field(index=True) + tenant_id: str = Field(index=True) + content: str + tags_json: list[str] = Field(default_factory=list, sa_column=Column(JSON)) + # source_type: member(TL 裁决的成员建议) / leader / human(人直写) + source_type: str = Field(default="human", index=True) + source_agent_id: Optional[str] = Field(default=None, index=True) + source_task_id: Optional[str] = Field(default=None, index=True) + # 引用回链:如 {"task_id": ..., "task_title": ...} + citation_json: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON)) + # status: active / archived + status: str = Field(default="active", index=True) + pinned: bool = False + created_at: datetime = Field(default_factory=utc_now) + updated_at: datetime = Field(default_factory=utc_now) + + +class TeamTaskBid(SQLModel, table=True): + """团队任务竞标:候选成员的方案陈述(round=1)/反驳(round=2)及 TL 打分。""" + + __tablename__ = "team_task_bids" + __table_args__ = ( + UniqueConstraint("task_id", "agent_id", "round", "kind", name="uq_team_task_bid_round"), + ) + + id: str = Field(default_factory=lambda: new_id("bid"), primary_key=True) + task_id: str = Field(index=True) + team_id: str = Field(index=True) + tenant_id: str = Field(index=True) + agent_id: str = Field(index=True) + round: int = 1 + # kind: statement(方案陈述) / rebuttal(反驳) + kind: str = Field(default="statement", index=True) + content: str + # TL 裁决后回写的分数与理由,未裁决前为 None + score: Optional[float] = None + score_rationale: Optional[str] = None + created_at: datetime = Field(default_factory=utc_now) diff --git a/backend/app/main.py b/backend/app/main.py index 9ecf6e71..7b16fd29 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -21,6 +21,7 @@ scheduled_tasks, sessions, skills, + teams, tools, traces, ui_config, @@ -30,12 +31,13 @@ from app.config import get_settings from app.db import engine, init_db from app.db.seed import seed_demo_data -from app.scheduled_tasks.worker import start_background_worker, stop_background_worker from app.public_api import create_public_api_app from app.public_api.jobs import cleanup_public_api_records, recover_public_jobs -from app.public_api.webhooks import enqueue_due_webhook_deliveries from app.public_api.maintenance import start_public_api_maintenance, stop_public_api_maintenance +from app.public_api.webhooks import enqueue_due_webhook_deliveries from app.runtime_lock import acquire_runtime_instance_lock, release_runtime_instance_lock +from app.scheduled_tasks.worker import start_background_worker, stop_background_worker +from app.teams.sweeper import start_timeout_sweeper, stop_timeout_sweeper from app.version import app_version settings = get_settings() @@ -66,6 +68,7 @@ def on_startup() -> None: seed_demo_data(db) start_background_worker() start_channel_services() + start_timeout_sweeper() if settings.public_api_enabled: recover_public_jobs() cleanup_public_api_records() @@ -82,6 +85,7 @@ def on_shutdown() -> None: stop_public_api_maintenance() stop_channel_services() stop_background_worker() + stop_timeout_sweeper() shutdown_async_jobs() finally: release_runtime_instance_lock() @@ -112,6 +116,8 @@ def health() -> dict[str, str]: app.include_router(scheduled_tasks.chat_draft_router) app.include_router(ui_config.enterprise_router) app.include_router(channels.router) +app.include_router(teams.router) +app.include_router(teams.threads_router) app.include_router(tools.router) app.include_router(tools.mcp_router) app.include_router(sessions.router) diff --git a/backend/app/session/session_schema.py b/backend/app/session/session_schema.py index 276a1993..d54aac1e 100644 --- a/backend/app/session/session_schema.py +++ b/backend/app/session/session_schema.py @@ -210,7 +210,7 @@ class ChatTurnRequest(BaseModel): message: str attachments: list["ChatAttachmentRead"] = Field(default_factory=list) channel: str = "web" - interaction_mode: Literal["normal", "scheduled_task"] = "normal" + interaction_mode: Literal["normal", "scheduled_task", "team_task", "team_tl"] = "normal" client_timezone: Optional[str] = None debug: bool = False @@ -264,6 +264,8 @@ class ChatSessionRead(BaseModel): summary: Optional[str] last_agent_question: Optional[str] is_scheduled: bool = False + team_id: Optional[str] = None + team_name: Optional[str] = None created_at: str updated_at: str diff --git a/backend/app/teams/__init__.py b/backend/app/teams/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/teams/schema.py b/backend/app/teams/schema.py new file mode 100644 index 00000000..ae463f62 --- /dev/null +++ b/backend/app/teams/schema.py @@ -0,0 +1,275 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +TeamRole = Literal["leader", "member"] +ReviewVerdict = Literal["approve", "rework", "escalate"] + + +class TeamCreateRequest(BaseModel): + tenant_id: str + name: str + description: str | None = None + config: dict[str, Any] = Field(default_factory=dict) + + +class TeamUpdateRequest(BaseModel): + tenant_id: str + name: str | None = None + description: str | None = None + status: Literal["active", "archived"] | None = None + config: dict[str, Any] | None = None + + +class TeamMemberAddRequest(BaseModel): + tenant_id: str + agent_id: str + role: TeamRole = "member" + + +class TeamLeaderUpdateRequest(BaseModel): + tenant_id: str + agent_id: str + + +class TeamMemberRead(BaseModel): + id: str + team_id: str + agent_id: str + role: str + agent_name: str | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TeamRead(BaseModel): + id: str + tenant_id: str + name: str + description: str | None = None + owner_user_id: str + config: dict[str, Any] = Field(default_factory=dict) + status: str + members: list[TeamMemberRead] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TeamTaskEventRead(BaseModel): + id: str + task_id: str + team_id: str + actor_type: str + actor_id: str | None = None + event_type: str + payload: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TeamTaskBidRead(BaseModel): + id: str + task_id: str + agent_id: str + agent_name: str | None = None + round: int + kind: str + content: str + score: float | None = None + score_rationale: str | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TeamTaskRead(BaseModel): + id: str + team_id: str + tenant_id: str + parent_task_id: str | None = None + title: str + description: str | None = None + priority: str + status: str + created_by_user_id: str | None = None + created_by_tl: bool + assignee_agent_id: str | None = None + session_id: str | None = None + report: dict[str, Any] = Field(default_factory=dict) + review: dict[str, Any] = Field(default_factory=dict) + version: int + events: list[TeamTaskEventRead] = Field(default_factory=list) + bids: list[TeamTaskBidRead] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TeamTaskCreateRequest(BaseModel): + tenant_id: str + title: str + description: str | None = None + priority: str = "normal" + assignee_agent_id: str | None = None + + +class TeamTLChatRequest(BaseModel): + tenant_id: str + message: str + session_id: str | None = None + + +class TeamTLChatResponse(BaseModel): + reply: str + session_id: str + created_tasks: list[TeamTaskRead] = Field(default_factory=list) + + +class TeamTLSessionRequest(BaseModel): + tenant_id: str + + +class TeamTLSessionResponse(BaseModel): + session_id: str + + +class ReviewOverrideRequest(BaseModel): + tenant_id: str + verdict: ReviewVerdict + comment: str | None = None + + +class AwardOverrideRequest(BaseModel): + tenant_id: str + agent_id: str + comment: str | None = None + + +class TeamBlackboardEntryCreateRequest(BaseModel): + tenant_id: str + content: str + tags: list[str] = Field(default_factory=list) + + +class TeamBlackboardEntryUpdateRequest(BaseModel): + tenant_id: str + content: str | None = None + tags: list[str] | None = None + pinned: bool | None = None + + +class TeamBlackboardEntryArchiveRequest(BaseModel): + tenant_id: str + + +class TeamBlackboardEntryRead(BaseModel): + id: str + team_id: str + tenant_id: str + content: str + tags: list[str] = Field(default_factory=list) + source_type: str + source_agent_id: str | None = None + source_task_id: str | None = None + citation: dict[str, Any] = Field(default_factory=dict) + status: str + pinned: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TeamBlackboardWriteResponse(BaseModel): + entries: list[TeamBlackboardEntryRead] = Field(default_factory=list) + skipped: list[str] = Field(default_factory=list) + + +class TeamEventRead(BaseModel): + """团队级审计事件(全团队 task_events 聚合视图,含任务标题便于展示)。""" + + id: str + task_id: str + team_id: str + task_title: str | None = None + actor_type: str + actor_id: str | None = None + event_type: str + payload: dict[str, Any] = Field(default_factory=dict) + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TeamThreadRead(BaseModel): + """跨团队统一线程:TL 对话会话(tl_chat)或任务执行会话(task)。""" + + team_id: str + team_name: str + kind: Literal["tl_chat", "task"] + session_id: str + task_id: str | None = None + title: str + task_status: str | None = None + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TeamBlackboardPromoteRequest(BaseModel): + tenant_id: str + + +class TeamBlackboardPromoteResponse(BaseModel): + entry: TeamBlackboardEntryRead + knowledge_base_id: str + ingest_job_id: str + already_promoted: bool = False + + +TeamConversationKind = Literal["tl_chat", "member_task", "member_bid", "tl_review"] + + +class TeamConversationTLRead(BaseModel): + """团队 TL 信息:session_id 为已有 TL 对话会话,无则 None(前端再调 tl/session 创建)。""" + + agent_id: str + agent_name: str | None = None + session_id: str | None = None + + +class TeamConversationRead(BaseModel): + """团队维度会话条目:kind 由会话标题前缀判定(见 teams.py 注释),preview 为末条消息截取。""" + + session_id: str + kind: TeamConversationKind + agent_id: str | None = None + agent_name: str | None = None + task_id: str | None = None + title: str + preview: str = "" + updated_at: datetime + + +class TeamConversationsResponse(BaseModel): + team_id: str + team_name: str + tl: TeamConversationTLRead | None = None + conversations: list[TeamConversationRead] = Field(default_factory=list) + + +class TeamConversationMessageRead(BaseModel): + id: str + role: str + content: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/teams/service.py b/backend/app/teams/service.py new file mode 100644 index 00000000..58c4013c --- /dev/null +++ b/backend/app/teams/service.py @@ -0,0 +1,675 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from app.db.models import ( + AgentProfile, + Team, + TeamBlackboardEntry, + TeamMember, + TeamTask, + TeamTaskBid, + TeamTaskEvent, + TeamWakeEvent, + utc_now, +) + +TEAM_MEMBER_ROLES = {"leader", "member"} + +# 任务状态机:bidding 由竞标流程驱动,bidding -> pending 为中标/改判后待执行。 +TASK_STATUSES = {"pending", "bidding", "in_progress", "review", "done", "rework", "escalated"} +TASK_TRANSITIONS: dict[str, set[str]] = { + "pending": {"bidding", "in_progress", "escalated"}, + "in_progress": {"review", "escalated"}, + "review": {"done", "rework", "escalated"}, + "rework": {"in_progress", "escalated"}, + "bidding": {"pending", "escalated"}, + "done": set(), + "escalated": set(), +} + +# 人/TL 验收结论 -> 目标状态 +VERDICT_TARGET_STATUS = {"approve": "done", "rework": "rework", "escalate": "escalated"} + +_JSON_BLOCK_RE = re.compile(r"```json\s*\n?(.*?)```", re.DOTALL) + + +class TeamTaskTransitionError(ValueError): + pass + + +def extract_json_blocks(text: str) -> list[dict[str, Any]]: + """从回复中提取所有 ```json 围栏代码块,坏 JSON 块直接跳过。""" + blocks: list[dict[str, Any]] = [] + for match in _JSON_BLOCK_RE.finditer(text or ""): + try: + data = json.loads(match.group(1)) + except (json.JSONDecodeError, ValueError): + continue + if isinstance(data, dict): + blocks.append(data) + return blocks + + +def strip_json_blocks(text: str) -> str: + """剔除回复中的 ```json 围栏块,用于对人展示。""" + return _JSON_BLOCK_RE.sub("", text or "").strip() + + +def parse_tl_task_assignments(reply: str) -> list[dict[str, str]]: + """解析 TL 派任务块:{"team_tasks": [{title, description?, assignee_agent_id?}...]}。 + + assignee_agent_id 缺省/为空表示投入任务池由成员竞标;无块或结构非法时 + 返回空列表(纯对话,不改状态)。 + """ + tasks: list[dict[str, str]] = [] + for block in extract_json_blocks(reply): + raw_tasks = block.get("team_tasks") + if not isinstance(raw_tasks, list): + continue + for item in raw_tasks: + if not isinstance(item, dict): + continue + title = str(item.get("title") or "").strip() + if not title: + continue + task: dict[str, str] = {"title": title} + assignee = str(item.get("assignee_agent_id") or "").strip() + if assignee: + task["assignee_agent_id"] = assignee + description = str(item.get("description") or "").strip() + if description: + task["description"] = description + tasks.append(task) + return tasks + + +_DISPATCH_KEYWORDS = ("派", "任务", "安排", "分配", "指派") + + +def looks_like_dispatch_intent(user_message: str, reply: str) -> bool: + """启发式判断本轮是否意在派发任务;命中才值得补一次格式纠错 turn。""" + text = f"{user_message}\n{reply}" + return any(keyword in text for keyword in _DISPATCH_KEYWORDS) + + +def parse_bid(reply: str) -> dict[str, str] | None: + """解析竞标块:{"bid": {"plan": "...", "estimated_cost"?: ..., "confidence"?: ...}}。 + + 无块或 plan 为空时返回 None(调用方以整条回复作为竞标内容兜底)。 + """ + for block in extract_json_blocks(reply): + raw = block.get("bid") + if not isinstance(raw, dict): + continue + plan = str(raw.get("plan") or "").strip() + if not plan: + continue + bid: dict[str, str] = {"plan": plan} + for key in ("estimated_cost", "confidence"): + value = str(raw.get(key) or "").strip() + if value: + bid[key] = value + return bid + return None + + +def parse_bid_scores(reply: str, candidate_ids: set[str]) -> dict[str, dict[str, Any]] | None: + """解析 TL 每轮打分块:{"bid_scores": {"agent_id": {"score": 8.5, "rationale": "..."}}}。 + + 分数截断到 0-10;至少一名候选有合法分数才视为解析成功, + 否则返回 None(交由格式纠错重试)。 + """ + for block in extract_json_blocks(reply): + raw = block.get("bid_scores") + if not isinstance(raw, dict): + continue + scores: dict[str, dict[str, Any]] = {} + for agent_id, item in raw.items(): + if str(agent_id) not in candidate_ids or not isinstance(item, dict): + continue + try: + score = float(item.get("score")) + except (TypeError, ValueError): + continue + scores[str(agent_id)] = { + "score": min(10.0, max(0.0, score)), + "rationale": str(item.get("rationale") or "").strip(), + } + if scores: + return scores + return None + + +def parse_bid_award(reply: str, candidate_ids: set[str]) -> dict[str, Any] | None: + """解析 TL 竞标裁决块:{"bid_award": {"winner_agent_id", "scores"?, "comment"?}}。 + + winner 必须来自候选集,否则视为未解析(交由格式纠错重试)。 + """ + for block in extract_json_blocks(reply): + raw = block.get("bid_award") + if not isinstance(raw, dict): + continue + winner = str(raw.get("winner_agent_id") or "").strip() + if winner not in candidate_ids: + continue + scores: dict[str, dict[str, Any]] = {} + raw_scores = raw.get("scores") + if isinstance(raw_scores, dict): + for agent_id, item in raw_scores.items(): + if not isinstance(item, dict): + continue + try: + score = float(item.get("score")) + except (TypeError, ValueError): + continue + scores[str(agent_id)] = { + "score": score, + "rationale": str(item.get("rationale") or "").strip(), + } + return { + "winner_agent_id": winner, + "scores": scores, + "comment": str(raw.get("comment") or "").strip(), + } + return None + + +def parse_tl_review(reply: str) -> dict[str, Any] | None: + """解析 TL 验收块:{"team_review": {"verdict": "approve|rework|escalate", ...}}。 + + 无块或 verdict 非法时返回 None(不改状态)。team_review 里可选的 + blackboard_writes(TL 裁决认可的黑板条目)在存在时一并返回。 + """ + for block in extract_json_blocks(reply): + raw_review = block.get("team_review") + if not isinstance(raw_review, dict): + continue + verdict = str(raw_review.get("verdict") or "").strip() + if verdict not in VERDICT_TARGET_STATUS: + continue + result: dict[str, Any] = { + "verdict": verdict, + "comment": str(raw_review.get("comment") or "").strip(), + } + raw_writes = raw_review.get("blackboard_writes") + if isinstance(raw_writes, list): + result["blackboard_writes"] = [ + item for item in raw_writes if isinstance(item, dict) + ] + return result + return None + + +def parse_blackboard_suggestions(reply: str) -> list[dict[str, Any]]: + """解析成员报告末尾的黑板建议块:{"blackboard_suggestions": [{content, tags?}...]}。""" + suggestions: list[dict[str, Any]] = [] + for block in extract_json_blocks(reply): + raw = block.get("blackboard_suggestions") + if not isinstance(raw, list): + continue + for item in raw: + if not isinstance(item, dict): + continue + content = str(item.get("content") or "").strip() + if not content: + continue + suggestion: dict[str, Any] = {"content": content} + tags = item.get("tags") + if isinstance(tags, list): + suggestion["tags"] = [str(tag) for tag in tags] + suggestions.append(suggestion) + return suggestions + + +def get_team(db: Session, tenant_id: str, team_id: str) -> Team: + team = db.get(Team, team_id) + if team is None or team.tenant_id != tenant_id: + raise HTTPException(status_code=404, detail="Team not found") + return team + + +def list_team_members(db: Session, team_id: str) -> list[TeamMember]: + return list(db.exec(select(TeamMember).where(TeamMember.team_id == team_id)).all()) + + +def get_team_leader(db: Session, team_id: str) -> TeamMember | None: + return db.exec( + select(TeamMember).where(TeamMember.team_id == team_id, TeamMember.role == "leader") + ).first() + + +def create_team( + db: Session, + *, + tenant_id: str, + name: str, + description: str | None, + owner_user_id: str, + config: dict[str, Any] | None = None, +) -> Team: + name = name.strip() + if not name: + raise HTTPException(status_code=400, detail="Team name cannot be empty") + existing = db.exec( + select(Team).where(Team.tenant_id == tenant_id, Team.name == name) + ).first() + if existing: + raise HTTPException(status_code=409, detail="Team name already exists") + team = Team( + tenant_id=tenant_id, + name=name, + description=description, + owner_user_id=owner_user_id, + config_json=dict(config or {}), + status="active", + ) + db.add(team) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="Team name already exists") from exc + db.refresh(team) + return team + + +def delete_team(db: Session, team: Team) -> None: + """删除团队并级联清理成员/任务/竞标/审计/唤醒事件/黑板。""" + members = list_team_members(db, team.id) + tasks = list(db.exec(select(TeamTask).where(TeamTask.team_id == team.id)).all()) + bids = list(db.exec(select(TeamTaskBid).where(TeamTaskBid.team_id == team.id)).all()) + events = list(db.exec(select(TeamTaskEvent).where(TeamTaskEvent.team_id == team.id)).all()) + wakes = list(db.exec(select(TeamWakeEvent).where(TeamWakeEvent.team_id == team.id)).all()) + entries = list( + db.exec(select(TeamBlackboardEntry).where(TeamBlackboardEntry.team_id == team.id)).all() + ) + for row in [*members, *tasks, *bids, *events, *wakes, *entries]: + db.delete(row) + db.delete(team) + db.commit() + + +def _ensure_team_agent(db: Session, team: Team, agent_id: str) -> AgentProfile: + agent = db.get(AgentProfile, agent_id) + if agent is None or agent.tenant_id != team.tenant_id: + raise HTTPException(status_code=404, detail="Agent not found in this tenant") + if agent.status != "active": + raise HTTPException(status_code=400, detail="Agent is not active") + return agent + + +def add_member(db: Session, team: Team, *, agent_id: str, role: str = "member") -> TeamMember: + if role not in TEAM_MEMBER_ROLES: + raise HTTPException(status_code=400, detail=f"Invalid team member role: {role}") + _ensure_team_agent(db, team, agent_id) + existing = db.exec( + select(TeamMember).where( + TeamMember.team_id == team.id, TeamMember.agent_id == agent_id + ) + ).first() + if existing: + raise HTTPException(status_code=409, detail="Agent is already a team member") + member = TeamMember(team_id=team.id, agent_id=agent_id, role="member") + db.add(member) + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=409, detail="Agent is already a team member") from exc + db.refresh(member) + if role == "leader": + # 一个团队至多一名 TL:新 leader 上任即换任 + set_leader(db, team, agent_id) + db.refresh(member) + return member + + +def remove_member(db: Session, team: Team, agent_id: str) -> None: + member = db.exec( + select(TeamMember).where( + TeamMember.team_id == team.id, TeamMember.agent_id == agent_id + ) + ).first() + if member is None: + raise HTTPException(status_code=404, detail="Team member not found") + db.delete(member) + db.commit() + + +def set_leader(db: Session, team: Team, agent_id: str) -> TeamMember: + """换任 TL:原 leader 降为 member,保证一个团队至多一名 leader。""" + member = db.exec( + select(TeamMember).where( + TeamMember.team_id == team.id, TeamMember.agent_id == agent_id + ) + ).first() + if member is None: + raise HTTPException(status_code=404, detail="Agent is not a team member") + current = get_team_leader(db, team.id) + if current and current.agent_id != agent_id: + current.role = "member" + db.add(current) + member.role = "leader" + db.add(member) + db.commit() + db.refresh(member) + return member + + +def record_task_event( + db: Session, + *, + team_id: str, + task_id: str, + actor_type: str, + actor_id: str | None, + event_type: str, + payload: dict[str, Any] | None = None, +) -> TeamTaskEvent: + event = TeamTaskEvent( + task_id=task_id, + team_id=team_id, + actor_type=actor_type, + actor_id=actor_id, + event_type=event_type, + payload_json=dict(payload or {}), + ) + db.add(event) + return event + + +def apply_task_transition( + db: Session, + task: TeamTask, + new_status: str, + *, + actor_type: str, + actor_id: str | None, + event_type: str, + payload: dict[str, Any] | None = None, +) -> TeamTask: + """按状态机迁移任务状态并写审计;非法流转抛 TeamTaskTransitionError。""" + if new_status not in TASK_STATUSES: + raise TeamTaskTransitionError(f"未知任务状态: {new_status}") + if new_status != task.status and new_status not in TASK_TRANSITIONS.get(task.status, set()): + raise TeamTaskTransitionError(f"任务不允许从 {task.status} 流转到 {new_status}") + previous = task.status + task.status = new_status + task.version += 1 + task.updated_at = utc_now() + db.add(task) + record_task_event( + db, + team_id=task.team_id, + task_id=task.id, + actor_type=actor_type, + actor_id=actor_id, + event_type=event_type, + payload={"from_status": previous, "to_status": new_status, **dict(payload or {})}, + ) + return task + + +def team_roster_lines(db: Session, team: Team) -> list[str]: + """团队花名册文本行:agent_id、名称、角色、能力标签,供注入 TL/成员上下文。""" + lines: list[str] = [] + for member in list_team_members(db, team.id): + agent = db.get(AgentProfile, member.agent_id) + if agent is None: + continue + metadata = agent.metadata_json if isinstance(agent.metadata_json, dict) else {} + tags = metadata.get("expertise_tags") + tags_text = ",".join(str(tag) for tag in tags) if isinstance(tags, list) else "" + role_text = "TL" if member.role == "leader" else "成员" + line = f"- agent_id={agent.id} 名称={agent.name} 角色={role_text}" + if tags_text: + line += f" 能力标签={tags_text}" + lines.append(line) + return lines + + +def open_tasks_summary(db: Session, team: Team) -> list[str]: + """当前未闭环任务摘要,供 TL 对话上下文。""" + rows = db.exec( + select(TeamTask).where( + TeamTask.team_id == team.id, + TeamTask.status.in_(["pending", "in_progress", "review", "rework"]), + ) + ).all() + return [ + f"- task_id={row.id} 标题={row.title} 状态={row.status} 负责人={row.assignee_agent_id or '未指派'}" + for row in rows + ] + + +# ---------- 团队黑板 ---------- + +BLACKBOARD_SOURCE_TYPES = {"member", "leader", "human"} +BLACKBOARD_STATUSES = {"active", "archived"} +BLACKBOARD_INJECTION_LIMIT = 10 + + +def normalize_blackboard_content(content: Any) -> str: + """黑板内容规范化:压缩全部空白,供去重/合并比较。""" + return " ".join(str(content or "").split()) + + +def normalize_blackboard_tags(tags: Any) -> list[str]: + """黑板标签规范化:小写、去空白、去重,只保留字符串。""" + if not isinstance(tags, list): + return [] + seen: set[str] = set() + result: list[str] = [] + for tag in tags: + text = str(tag).strip().lower() + if text and text not in seen: + seen.add(text) + result.append(text) + return result + + +def write_blackboard_entries( + db: Session, + *, + team: Team, + entries: list[dict[str, Any]], + source_type: str, + source_agent_id: str | None = None, + source_task_id: str | None = None, +) -> tuple[list[TeamBlackboardEntry], list[str]]: + """轻量黑板写入流水线:规范化 -> 去重合并 -> 结构化写入(带引用)。 + + 黑板是活文档:与同团队既有 active 条目完全相同或为其子串时不新增; + 新内容是既有条目超集时合并更新既有条目(content/tags/citation/updated_at)。 + 调用方负责 commit。返回 (写入/更新的条目列表, 跳过原因列表)。 + """ + if source_type not in BLACKBOARD_SOURCE_TYPES: + raise ValueError(f"未知黑板来源类型: {source_type}") + citation: dict[str, Any] = {} + if source_task_id: + citation["task_id"] = source_task_id + task = db.get(TeamTask, source_task_id) + if task is not None: + citation["task_title"] = task.title + existing_rows = list( + db.exec( + select(TeamBlackboardEntry).where( + TeamBlackboardEntry.team_id == team.id, + TeamBlackboardEntry.status == "active", + ) + ).all() + ) + existing_by_norm = {normalize_blackboard_content(row.content): row for row in existing_rows} + written: list[TeamBlackboardEntry] = [] + skipped: list[str] = [] + seen_in_batch: set[str] = set() + for raw in entries: + if not isinstance(raw, dict): + skipped.append("条目结构非法,已跳过") + continue + content = normalize_blackboard_content(raw.get("content")) + if not content: + skipped.append("内容为空,已跳过") + continue + if content in seen_in_batch: + skipped.append(f"与本批次内其他条目重复: {content[:50]}") + continue + seen_in_batch.add(content) + tags = normalize_blackboard_tags(raw.get("tags")) + if content in existing_by_norm: + skipped.append(f"与黑板既有条目重复: {content[:50]}") + continue + # 子串关系:新内容是既有条目的子串 -> 跳过;既有条目是新内容的子串 -> 合并更新 + is_substring = any(content in norm for norm in existing_by_norm) + if is_substring: + skipped.append(f"黑板已有更完整条目: {content[:50]}") + continue + superseded: TeamBlackboardEntry | None = None + superseded_norm = "" + for norm, row in existing_by_norm.items(): + if norm in content and len(norm) > len(superseded_norm): + superseded = row + superseded_norm = norm + if superseded is not None: + superseded.content = content + superseded.tags_json = normalize_blackboard_tags([*superseded.tags_json, *tags]) + if citation: + superseded.citation_json = dict(citation) + superseded.updated_at = utc_now() + db.add(superseded) + del existing_by_norm[superseded_norm] + existing_by_norm[content] = superseded + written.append(superseded) + continue + entry = TeamBlackboardEntry( + team_id=team.id, + tenant_id=team.tenant_id, + content=content, + tags_json=tags, + source_type=source_type, + source_agent_id=source_agent_id, + source_task_id=source_task_id, + citation_json=dict(citation), + ) + db.add(entry) + existing_by_norm[content] = entry + written.append(entry) + return written, skipped + + +def blackboard_context_lines( + db: Session, team: Team, query_text: str, *, limit: int = BLACKBOARD_INJECTION_LIMIT +) -> list[str]: + """团队黑板 top-K 注入行:按 query 与条目 tags 的关键词重叠打分, + + 再按 pinned 优先、updated_at 倒序;无 active 条目时返回空列表(不注入该区)。 + """ + rows = list( + db.exec( + select(TeamBlackboardEntry).where( + TeamBlackboardEntry.team_id == team.id, + TeamBlackboardEntry.status == "active", + ) + ).all() + ) + if not rows: + return [] + query = (query_text or "").lower() + + def sort_key(entry: TeamBlackboardEntry) -> tuple[int, bool, float]: + score = sum( + 1 for tag in entry.tags_json if isinstance(tag, str) and tag and tag in query + ) + return (-score, not entry.pinned, -entry.updated_at.timestamp()) + + rows.sort(key=sort_key) + lines: list[str] = [] + for entry in rows[: max(1, limit)]: + tags_text = ",".join(entry.tags_json) + lines.append(f"- [{tags_text}] {entry.content}" if tags_text else f"- {entry.content}") + return lines + + +# ---------- 任务池竞标 ---------- + +DEFAULT_MEMBER_CONCURRENCY = 1 + + +def member_concurrency(team: Team) -> int: + """团队成员执行并发上限:默认 1(同团队内同一成员串行),非法配置回退默认。""" + config = team.config_json if isinstance(team.config_json, dict) else {} + try: + return max(1, int(config.get("member_concurrency", DEFAULT_MEMBER_CONCURRENCY))) + except (TypeError, ValueError): + return DEFAULT_MEMBER_CONCURRENCY + + +BID_CANDIDATE_LIMIT = 3 +# 竞标总轮数:round 1 = 陈述,round 2..N = 反驳;默认 3(1 陈述 + 2 反驳)。 +# 0/1 均表示关闭辩论,陈述后直接由 TL 裁决(兼容旧配置读取)。 +DEFAULT_BID_REBUTTAL_ROUNDS = 3 + +# 竞标血条:HP 初始 100,每轮结束后按 TL 打分扣减 (10 - 得分) x 3,下限 0,归零淘汰 +BID_HP_INITIAL = 100 +BID_HP_LOSS_PER_POINT = 3 +# TL 打分解析失败(含纠错重试)时的兜底分,不阻塞竞标流程 +BID_SCORE_FALLBACK = 5.0 + + +def candidate_hp(bids: list[TeamTaskBid]) -> dict[str, int]: + """由各轮打分计算候选血条:HP = 100 - Σ(10 - 每轮得分) x 3,下限 0。 + + 只统计已打分(score 非空)的 bid;未打分候选不在返回中(调用方按初始 HP 处理)。 + """ + hp: dict[str, float] = {} + for bid in sorted(bids, key=lambda item: (item.round, item.created_at)): + if bid.score is None: + continue + current = hp.get(bid.agent_id, float(BID_HP_INITIAL)) + loss = max(0.0, 10.0 - bid.score) * BID_HP_LOSS_PER_POINT + hp[bid.agent_id] = max(0.0, current - loss) + return {agent_id: round(value) for agent_id, value in hp.items()} + + +def bid_rebuttal_rounds(team: Team) -> int: + """团队配置的竞标总轮数:默认 3,0/1 表示关闭辩论、陈述后直接裁决。""" + config = team.config_json if isinstance(team.config_json, dict) else {} + try: + return max(0, int(config.get("bid_rebuttal_rounds", DEFAULT_BID_REBUTTAL_ROUNDS))) + except (TypeError, ValueError): + return DEFAULT_BID_REBUTTAL_ROUNDS + + +def select_bid_candidates(db: Session, team: Team, task: TeamTask) -> list[str]: + """选竞标候选:按成员 expertise_tags 与任务文本的子串重叠计分,排除 TL,封顶 3 人。 + + 全部 0 分时取除 TL 外全部成员(仍封顶 3);无成员可选时返回空列表。 + """ + leader = get_team_leader(db, team.id) + leader_agent_id = leader.agent_id if leader else None + query = f"{task.title}\n{task.description or ''}".lower() + scored: list[tuple[int, str]] = [] + for member in list_team_members(db, team.id): + if member.agent_id == leader_agent_id: + continue + agent = db.get(AgentProfile, member.agent_id) + if agent is None: + continue + metadata = agent.metadata_json if isinstance(agent.metadata_json, dict) else {} + raw_tags = metadata.get("expertise_tags") + tags = [str(tag).lower() for tag in raw_tags] if isinstance(raw_tags, list) else [] + score = sum(1 for tag in tags if tag and tag in query) + scored.append((score, member.agent_id)) + if not scored: + return [] + scored.sort(key=lambda item: (-item[0], item[1])) + positive = [agent_id for score, agent_id in scored if score > 0] + pool = positive if positive else [agent_id for _, agent_id in scored] + return pool[:BID_CANDIDATE_LIMIT] diff --git a/backend/app/teams/sweeper.py b/backend/app/teams/sweeper.py new file mode 100644 index 00000000..0f9b8edb --- /dev/null +++ b/backend/app/teams/sweeper.py @@ -0,0 +1,113 @@ +"""团队任务超时清扫:周期把滞留在 bidding/in_progress/review 的任务升级给人。 + +守护线程仅由 main.py on_startup 启动(单端口应用进程内单实例); +测试直接调用 sweep_timed_out_tasks,不起线程。 +""" + +from __future__ import annotations + +import logging +import threading +from datetime import datetime, timedelta + +from sqlmodel import Session, select + +from app.db import engine +from app.db.models import Team, TeamTask, TeamWakeEvent, utc_now +from app.teams.service import apply_task_transition +from app.teams.wakeup import _drain_member_queue + +logger = logging.getLogger(__name__) + +SWEEP_INTERVAL_SECONDS = 60.0 +DEFAULT_TASK_TIMEOUT_MINUTES = 30.0 +# 参与超时判定的任务状态:终态(done/escalated)与待启动(pending/rework)不扫 +TIMEOUT_SCAN_STATUSES = ("bidding", "in_progress", "review") + + +def task_timeout_minutes(team: Team) -> float: + """团队任务超时阈值(分钟):默认 30,非法配置(非 dict/非数字/非正数)回退默认。""" + config = team.config_json if isinstance(team.config_json, dict) else {} + try: + value = float(config.get("task_timeout_minutes", DEFAULT_TASK_TIMEOUT_MINUTES)) + except (TypeError, ValueError): + return DEFAULT_TASK_TIMEOUT_MINUTES + return value if value > 0 else DEFAULT_TASK_TIMEOUT_MINUTES + + +def sweep_timed_out_tasks(db: Session, *, now: datetime | None = None) -> list[TeamTask]: + """扫一轮超时任务:置 escalated 记审计,关联 pending 唤醒标记 failed(error=timeout)。 + + in_progress 任务超时后释放成员执行额度,尝试出队该成员的排队唤醒。 + 返回本次被升级的任务列表。 + """ + now = now or utc_now() + rows = db.exec( + select(TeamTask).where(TeamTask.status.in_(list(TIMEOUT_SCAN_STATUSES))) + ).all() + escalated: list[TeamTask] = [] + for task in rows: + team = db.get(Team, task.team_id) + if team is None: + continue + timeout_minutes = task_timeout_minutes(team) + if now - task.updated_at < timedelta(minutes=timeout_minutes): + continue + previous = task.status + apply_task_transition( + db, + task, + "escalated", + actor_type="system", + actor_id=None, + event_type="task_escalated", + payload={"reason": "timeout", "timeout_minutes": timeout_minutes}, + ) + for wake in db.exec( + select(TeamWakeEvent).where( + TeamWakeEvent.team_id == team.id, TeamWakeEvent.status == "pending" + ) + ).all(): + payload = wake.payload_json if isinstance(wake.payload_json, dict) else {} + if str(payload.get("task_id") or "") != task.id: + continue + wake.status = "failed" + wake.error = "timeout" + wake.updated_at = utc_now() + db.add(wake) + db.commit() + escalated.append(task) + if previous == "in_progress" and task.assignee_agent_id: + _drain_member_queue(db, team, task.assignee_agent_id) + return escalated + + +_stop_event = threading.Event() +_sweeper_thread: threading.Thread | None = None + + +def _sweep_loop(interval_seconds: float) -> None: + while not _stop_event.wait(max(1.0, interval_seconds)): + try: + with Session(engine) as db: + sweep_timed_out_tasks(db) + except Exception: # 后台清扫不让异常杀死守护线程 + logger.exception("团队任务超时清扫失败") + + +def start_timeout_sweeper(*, interval_seconds: float = SWEEP_INTERVAL_SECONDS) -> None: + global _sweeper_thread + if _sweeper_thread and _sweeper_thread.is_alive(): + return + _stop_event.clear() + _sweeper_thread = threading.Thread( + target=_sweep_loop, + args=(interval_seconds,), + name="team-task-timeout-sweeper", + daemon=True, + ) + _sweeper_thread.start() + + +def stop_timeout_sweeper() -> None: + _stop_event.set() diff --git a/backend/app/teams/wakeup.py b/backend/app/teams/wakeup.py new file mode 100644 index 00000000..5066c83a --- /dev/null +++ b/backend/app/teams/wakeup.py @@ -0,0 +1,1737 @@ +from __future__ import annotations + +import threading +from typing import Literal + +from sqlalchemy import update +from sqlmodel import Session, select + +from app.core import AgentLoop +from app.db import engine +from app.db.models import ( + AgentProfile, + ChatSession, + HarnessTaskFrameRecord, + HarnessTurnRecord, + Team, + TeamTask, + TeamTaskBid, + TeamTaskEvent, + TeamWakeEvent, + User, + new_id, + utc_now, +) +from app.session.session_schema import ChatTurnRequest, ChatTurnResponse +from app.teams.service import ( + BID_HP_INITIAL, + BID_SCORE_FALLBACK, + VERDICT_TARGET_STATUS, + apply_task_transition, + bid_rebuttal_rounds, + blackboard_context_lines, + candidate_hp, + get_team_leader, + list_team_members, + looks_like_dispatch_intent, + member_concurrency, + open_tasks_summary, + parse_bid, + parse_bid_award, + parse_bid_scores, + parse_blackboard_suggestions, + parse_tl_review, + parse_tl_task_assignments, + record_task_event, + select_bid_candidates, + team_roster_lines, + write_blackboard_entries, +) + +TL_ASSIGNMENT_INSTRUCTION = ( + "派发任务的唯一方式是输出一个围栏代码块 ```json,内容形如:" + '{"team_tasks": [{"title": "任务标题", "description": "任务描述", ' + '"assignee_agent_id": "成员的 agent_id"}]}。' + "assignee_agent_id 必须来自上面的花名册;可以一次派多个任务。" + "省略 assignee_agent_id 即把任务投入任务池,由成员竞标、你裁决后中标者执行。" + "注意:只有输出该 JSON 代码块,任务才会被真正创建并交给成员执行;" + "只用自然语言宣布『已派发』是无效的,系统不会创建任何任务。" + "如果只是与人讨论、不需要派任务,就不要输出该代码块。" +) + +TL_REVIEW_INSTRUCTION = ( + "验收结论的唯一生效方式是输出一个围栏代码块 ```json,内容形如:" + '{"team_review": {"verdict": "approve", "comment": "验收意见"}}。' + "verdict 只能是 approve(通过)/ rework(退回重做)/ escalate(升级给人处理)。" + "注意:只有输出该 JSON 代码块,验收结论才会生效;口头宣布结论是无效的。" +) + +TL_ASSIGNMENT_REPAIR_MESSAGE = ( + "系统提示:你的上一条回复没有包含规定的 ```json 任务代码块,因此没有创建任何任务。" + "如果你刚才的意图是派发任务,请立即输出规定的 JSON 代码块(可只输出代码块);" + "如果不需要派发任务,请回复「无需派任务」。" +) + +TL_REVIEW_REPAIR_MESSAGE = ( + "系统提示:你的上一条回复没有包含规定的 ```json 验收代码块,验收结论未生效。" + "请立即输出规定的 team_review JSON 代码块(可只输出代码块)。" +) + +BID_INSTRUCTION = ( + "请为该任务提交竞标陈述,唯一生效方式是输出一个围栏代码块 ```json,内容形如:" + '{"bid": {"plan": "执行思路", "estimated_cost": "粗估成本", ' + '"confidence": "high|medium|low"}}。' + "注意:只有输出该 JSON 代码块,你的竞标才会被正式记录并进入 TL 裁决。" +) + +BID_REBUTTAL_NOTE = ( + "以上是各候选的当前血条与上一轮其他存活候选的发言。本轮是反驳轮:你可以针对其他候选的" + "发言进行反驳、指出其方案的风险,或补强自己的方案;输出格式与上一轮相同(bid 代码块语义不变)。" +) + +TL_BID_SCORE_INSTRUCTION = ( + "请按任务理解/可行性/能力匹配/预估成本四个维度,为本轮每位候选发言打分(0-10)。" + "打分的唯一生效方式是输出一个围栏代码块 ```json,内容形如:" + '{"bid_scores": {"agent_id": {"score": 8.5, "rationale": "打分理由"}}}。' + "bid_scores 必须覆盖本轮每位候选;分数会扣减候选血条(HP),HP 归零即淘汰。" + "本轮只打分、不裁决中标者。" +) + +TL_BID_SCORE_REPAIR_MESSAGE = ( + "系统提示:你的上一条回复没有包含规定的 ```json 打分代码块,本轮打分未生效。" + "请立即输出规定的 bid_scores JSON 代码块(可只输出代码块)。" +) + +TL_BID_JUDGE_INSTRUCTION = ( + "请按任务理解/可行性/能力匹配/预估成本四个维度为每位候选打分(0-10),并选出中标者。" + "裁决的唯一生效方式是输出一个围栏代码块 ```json,内容形如:" + '{"bid_award": {"winner_agent_id": "候选的 agent_id", ' + '"scores": {"agent_id": {"score": 8.5, "rationale": "打分理由"}}, "comment": "裁决说明"}}。' + "winner_agent_id 必须来自血条列出的存活候选;只有输出该 JSON 代码块,裁决才会生效。" +) + +TL_BID_JUDGE_REPAIR_MESSAGE = ( + "系统提示:你的上一条回复没有包含规定的 ```json 裁决代码块," + "或 winner_agent_id 不在候选列表中,裁决未生效。" + "请立即输出规定的 bid_award JSON 代码块(可只输出代码块)。" +) + + +def build_tl_chat_message(db: Session, team: Team, user_message: str) -> str: + """TL 对话上下文注入:团队信息 + 花名册 + 未闭环任务 + 黑板 + 派任务输出格式。""" + roster = team_roster_lines(db, team) + open_tasks = open_tasks_summary(db, team) + lines = [f"你是团队「{team.name}」的 TL(团队负责人),负责拆解需求并指派给团队成员。"] + if team.description: + lines.append(f"团队简介:{team.description}") + lines.append("团队花名册:") + lines.extend(roster or ["- (暂无成员)"]) + lines.append("当前未闭环任务:") + lines.extend(open_tasks or ["- (暂无)"]) + blackboard = blackboard_context_lines(db, team, user_message) + if blackboard: + lines.append("团队黑板(相关工作记忆):") + lines.extend(blackboard) + lines.append(f"人的需求:{user_message}") + lines.append(TL_ASSIGNMENT_INSTRUCTION) + return "\n".join(lines) + + +def build_member_task_message(db: Session, team: Team, task: TeamTask, *, rework: bool) -> str: + """成员执行上下文注入:任务描述(+ 退回意见)+ 黑板 + 报告与黑板建议要求。""" + lines = [f"你是团队「{team.name}」的成员,请完成以下团队任务。"] + lines.append(f"任务标题:{task.title}") + if task.description: + lines.append(f"任务描述:{task.description}") + if rework: + comment = str((task.review_json or {}).get("comment") or "").strip() + lines.append("该任务已被退回重做。" + (f"退回意见:{comment}" if comment else "")) + query_text = f"{task.title}\n{task.description or ''}" + blackboard = blackboard_context_lines(db, team, query_text) + if blackboard: + lines.append("团队黑板(相关工作记忆):") + lines.extend(blackboard) + lines.append("完成后请输出结构化完成报告,包含:结论、过程要点、交付物。") + lines.append( + "如果你在执行中发现了值得全团队记住的信息(关键结论/约定口径/容易踩的坑)," + "请在报告末尾额外输出一个围栏代码块 ```json,内容形如:" + '{"blackboard_suggestions": [{"content": "值得记住的事", "tags": ["标签"]}]}。' + "建议由 TL 验收时裁决后才会真正写入团队黑板;没有值得记录的信息就不要输出该代码块。" + ) + return "\n".join(lines) + + +def build_tl_review_message(db: Session, team: Team, task: TeamTask) -> str: + """TL 验收上下文注入:任务描述 + 成员报告 + 黑板 + 验收输出格式(+ 黑板裁决)。""" + report = task.report_json if isinstance(task.report_json, dict) else {} + report_text = str(report.get("full_reply") or report.get("summary") or "(成员未提交报告内容)") + lines = [f"你是团队「{team.name}」的 TL,请验收成员提交的任务报告。"] + lines.append(f"任务标题:{task.title}") + if task.description: + lines.append(f"任务描述:{task.description}") + query_text = f"{task.title}\n{task.description or ''}" + blackboard = blackboard_context_lines(db, team, query_text) + if blackboard: + lines.append("团队黑板(相关工作记忆):") + lines.extend(blackboard) + lines.append(f"成员报告:{report_text}") + lines.append(TL_REVIEW_INSTRUCTION) + suggestions = report.get("blackboard_suggestions") + if isinstance(suggestions, list) and suggestions: + lines.append("成员随报告提交了以下黑板建议(认为值得全团队记住的信息):") + for index, item in enumerate(suggestions, 1): + if not isinstance(item, dict): + continue + tags = item.get("tags") + tags_text = ",".join(str(tag) for tag in tags) if isinstance(tags, list) else "" + line = f"{index}. {item.get('content') or ''}" + if tags_text: + line += f"(标签: {tags_text})" + lines.append(line) + lines.append( + "请逐条裁决:认可的条目(可修改措辞后)放进同一个 ```json 块的 team_review 里," + '增加可选字段 "blackboard_writes": [{"content": "...", "tags": ["..."]}];' + "未写入 blackboard_writes 的建议即视为拒绝。" + ) + return "\n".join(lines) + + +def build_bid_request_message( + db: Session, team: Team, task: TeamTask, agent: AgentProfile, *, round_: int +) -> str: + """竞标上下文注入:任务描述 + 黑板 + 竞标指令;反驳轮附各候选血条与上一轮其他存活候选的发言。""" + lines = [f"你是团队「{team.name}」的成员,以下团队任务正在任务池中开放竞标。"] + lines.append(f"任务标题:{task.title}") + if task.description: + lines.append(f"任务描述:{task.description}") + query_text = f"{task.title}\n{task.description or ''}" + blackboard = blackboard_context_lines(db, team, query_text) + if blackboard: + lines.append("团队黑板(相关工作记忆):") + lines.extend(blackboard) + if round_ >= 2: + bids = list(db.exec(select(TeamTaskBid).where(TeamTaskBid.task_id == task.id)).all()) + hp = candidate_hp(bids) + candidate_ids = [bid.agent_id for bid in bids if bid.round == 1] + if candidate_ids: + lines.append("各候选当前血条(HP,初始 100,归零淘汰):") + for candidate_id in dict.fromkeys(candidate_ids): + other = db.get(AgentProfile, candidate_id) + name = other.name if other else candidate_id + lines.append(f"- {name}(agent_id={candidate_id}):HP={hp.get(candidate_id, BID_HP_INITIAL)}") + others = [ + bid + for bid in bids + if bid.round == round_ - 1 and bid.agent_id != agent.id + ] + if others: + lines.append(f"第 {round_ - 1} 轮其他候选的发言:") + for bid in others: + other = db.get(AgentProfile, bid.agent_id) + name = other.name if other else bid.agent_id + lines.append(f"- {name}:{bid.content}") + lines.append(BID_REBUTTAL_NOTE) + lines.append(BID_INSTRUCTION) + return "\n".join(lines) + + +def build_bid_score_message( + db: Session, team: Team, task: TeamTask, bids: list[TeamTaskBid], *, round_: int +) -> str: + """TL 每轮打分上下文注入:任务描述 + 本轮各候选发言(标注名称与 agent_id)+ 打分指令。""" + lines = [f"你是团队「{team.name}」的 TL,以下任务竞标的第 {round_} 轮已结束,请为本轮候选打分。"] + lines.append(f"任务标题:{task.title}") + if task.description: + lines.append(f"任务描述:{task.description}") + lines.append(f"第 {round_} 轮候选发言:") + for bid in bids: + agent = db.get(AgentProfile, bid.agent_id) + name = agent.name if agent else bid.agent_id + lines.append(f"- {name}(agent_id={bid.agent_id}):{bid.content}") + lines.append(TL_BID_SCORE_INSTRUCTION) + return "\n".join(lines) + + +def build_bid_judge_message( + db: Session, + team: Team, + task: TeamTask, + bids: list[TeamTaskBid], + alive_candidate_ids: list[str], +) -> str: + """TL 竞标裁决上下文注入:任务描述 + 各候选发言(标注名称与 agent_id)+ 血条 + 裁决指令。""" + lines = [f"你是团队「{team.name}」的 TL,以下任务的竞标已结束,请裁决中标者。"] + lines.append(f"任务标题:{task.title}") + if task.description: + lines.append(f"任务描述:{task.description}") + hp = candidate_hp(bids) + if alive_candidate_ids: + lines.append("各候选当前血条(HP,初始 100,归零淘汰):") + for candidate_id in alive_candidate_ids: + agent = db.get(AgentProfile, candidate_id) + name = agent.name if agent else candidate_id + lines.append(f"- {name}(agent_id={candidate_id}):HP={hp.get(candidate_id, BID_HP_INITIAL)}") + lines.append("候选竞标记录:") + for bid in bids: + agent = db.get(AgentProfile, bid.agent_id) + name = agent.name if agent else bid.agent_id + round_text = "陈述" if bid.kind == "statement" else "反驳" + score_text = f"(第 {bid.round} 轮得分 {bid.score})" if bid.score is not None else "" + lines.append(f"- {name}(agent_id={bid.agent_id})的{round_text}{score_text}:{bid.content}") + lines.append(TL_BID_JUDGE_INSTRUCTION) + return "\n".join(lines) + + +def enqueue_wake_event( + db: Session, + *, + team: Team, + target_agent_id: str, + trigger_type: str, + payload: dict | None = None, +) -> TeamWakeEvent: + event = TeamWakeEvent( + team_id=team.id, + tenant_id=team.tenant_id, + target_agent_id=target_agent_id, + trigger_type=trigger_type, + payload_json=dict(payload or {}), + status="pending", + ) + db.add(event) + db.flush() + return event + + +def claim_wake_event(db: Session, wake_event_id: str) -> bool: + """原子认领 pending 唤醒事件;并发/重复触发下只生效一次。""" + result = db.exec( + update(TeamWakeEvent) + .where(TeamWakeEvent.id == wake_event_id, TeamWakeEvent.status == "pending") + .values(status="claimed", updated_at=utc_now()) + ) + db.commit() + return result.rowcount == 1 + + +def start_wakeup_async(wake_event_id: str) -> None: + threading.Thread( + target=_execute_wakeup_in_background, + args=(wake_event_id,), + daemon=True, + ).start() + + +def _execute_wakeup_in_background(wake_event_id: str) -> None: + with Session(engine) as db: + if not claim_wake_event(db, wake_event_id): + return + event = db.get(TeamWakeEvent, wake_event_id) + if event is None: + return + execute_wake_event(db, event) + + +def _ensure_wake_target_agent(db: Session, event: TeamWakeEvent) -> AgentProfile: + agent = db.get(AgentProfile, event.target_agent_id) + if agent is None or agent.tenant_id != event.tenant_id or agent.status != "active": + raise RuntimeError("唤醒目标员工已不可用;请检查团队配置。") + return agent + + +# ---------- 成员执行串行排队 ---------- + +# 执行类唤醒:占用成员执行额度;竞标/裁决属轻量 turn,直接放行不占额度 +EXECUTION_WAKE_TYPES = {"task_assigned", "task_rework"} + +# 进程内成员执行额度计数:与 DB in_progress 计数互补,覆盖落库前的并发窗口 +_member_slot_counts: dict[tuple[str, str], int] = {} +_member_slot_guard = threading.Lock() + + +def _try_acquire_member_slot(team_id: str, agent_id: str, limit: int) -> bool: + with _member_slot_guard: + running = _member_slot_counts.get((team_id, agent_id), 0) + if running >= limit: + return False + _member_slot_counts[(team_id, agent_id)] = running + 1 + return True + + +def _release_member_slot(team_id: str, agent_id: str) -> None: + with _member_slot_guard: + running = _member_slot_counts.get((team_id, agent_id), 0) + if running <= 1: + _member_slot_counts.pop((team_id, agent_id), None) + else: + _member_slot_counts[(team_id, agent_id)] = running - 1 + + +def _member_in_progress_count(db: Session, team: Team, agent_id: str) -> int: + return len( + db.exec( + select(TeamTask).where( + TeamTask.team_id == team.id, + TeamTask.assignee_agent_id == agent_id, + TeamTask.status == "in_progress", + ) + ).all() + ) + + +def _record_wake_queued( + db: Session, event: TeamWakeEvent, team: Team, agent: AgentProfile +) -> None: + """执行类唤醒排队审计:关联任务存在才记任务事件。""" + task_id = str(event.payload_json.get("task_id") or "") + if not task_id or db.get(TeamTask, task_id) is None: + return + record_task_event( + db, + team_id=team.id, + task_id=task_id, + actor_type="system", + actor_id=None, + event_type="wake_queued", + payload={"wake_event_id": event.id, "agent_id": agent.id}, + ) + + +def _drain_member_queue(db: Session, team: Team, agent_id: str) -> None: + """成员任务终态后出队:有空闲额度时拉起该成员本团队最老的 pending 执行类唤醒。""" + if _member_in_progress_count(db, team, agent_id) >= member_concurrency(team): + return + wake = db.exec( + select(TeamWakeEvent) + .where( + TeamWakeEvent.team_id == team.id, + TeamWakeEvent.target_agent_id == agent_id, + TeamWakeEvent.status == "pending", + TeamWakeEvent.trigger_type.in_(sorted(EXECUTION_WAKE_TYPES)), + ) + .order_by(TeamWakeEvent.created_at) + ).first() + if wake is not None: + start_wakeup_async(wake.id) + + +def _team_harness_outcome( + db: Session, + *, + tenant_id: str, + session_id: str, + client_turn_id: str, +) -> str: + """按 Harness v2 持久记录判定唤醒 turn 结果(仿定时任务)。 + + 返回 "completed" / "needs_input"(成员在等补充信息,合法状态); + 其余未完成形态(失败/取消等)抛异常。 + """ + receipt = db.exec( + select(HarnessTurnRecord).where( + HarnessTurnRecord.tenant_id == tenant_id, + HarnessTurnRecord.session_id == session_id, + HarnessTurnRecord.client_turn_id == client_turn_id, + ) + ).first() + if receipt is None or not receipt.user_message_id: + raise RuntimeError("团队任务未进入 Harness v2,已拒绝按旧链路判定成功。") + frames = db.exec( + select(HarnessTaskFrameRecord).where( + HarnessTaskFrameRecord.tenant_id == tenant_id, + HarnessTaskFrameRecord.session_id == session_id, + HarnessTaskFrameRecord.source_turn_id == receipt.user_message_id, + ) + ).all() + if not frames: + raise RuntimeError("Harness v2 未生成 TaskFrame,团队任务不能判定为成功。") + statuses: list[str] = [] + for frame in frames: + result = frame.result_json if isinstance(frame.result_json, dict) else {} + statuses.append(str(result.get("status") or "").strip() or frame.status) + if all(status == "completed" for status in statuses): + return "completed" + if any(status in {"awaiting_user", "needs_input"} for status in statuses): + return "needs_input" + raise RuntimeError("一个或多个 TaskFrame 未完成,团队任务执行失败。") + + +def collect_turn_reply_fragments( + db: Session, + *, + tenant_id: str, + session_id: str, + client_turn_id: str, +) -> list[str]: + """取一次 harness turn 内各 TaskFrame 的 reply_fragment。 + + 最终回复由 ResponseGenerator 面向用户改写,可能丢失围栏 JSON 块; + frame 级 reply_fragment 保留原始输出,TL 的 JSON 块协议以两者并集为准。 + """ + receipt = db.exec( + select(HarnessTurnRecord).where( + HarnessTurnRecord.tenant_id == tenant_id, + HarnessTurnRecord.session_id == session_id, + HarnessTurnRecord.client_turn_id == client_turn_id, + ) + ).first() + if receipt is None or not receipt.user_message_id: + return [] + frames = db.exec( + select(HarnessTaskFrameRecord).where( + HarnessTaskFrameRecord.tenant_id == tenant_id, + HarnessTaskFrameRecord.session_id == session_id, + HarnessTaskFrameRecord.source_turn_id == receipt.user_message_id, + ) + ).all() + fragments: list[str] = [] + for frame in frames: + result = frame.result_json if isinstance(frame.result_json, dict) else {} + fragment = str(result.get("reply_fragment") or "").strip() + if fragment: + fragments.append(fragment) + return fragments + + +def run_agent_turn( + db: Session, + *, + team: Team, + agent: AgentProfile, + session_id: str, + wake_event_id: str, + message: str, + interaction_mode: Literal["team_task", "team_tl"], + client_turn_id: str | None = None, + allow_needs_input: bool = False, +) -> str: + """在独立会话里执行一轮 agent turn,返回回复文本;执行失败抛异常。 + + allow_needs_input=True 时,turn 落在 awaiting_user/needs_input 不抛异常, + 由调用方决定如何安置(如成员任务转人工补充信息)。 + """ + turn_id = client_turn_id or wake_event_id + request = ChatTurnRequest( + tenant_id=team.tenant_id, + session_id=session_id, + agent_id=agent.id, + client_turn_id=turn_id, + user_id=team.owner_user_id, + message=message, + channel="team", + interaction_mode=interaction_mode, + ) + result: ChatTurnResponse | None = None + for item in AgentLoop(db).handle_turn_stream(request): + if item.get("event") in {"complete", "done"} and isinstance(item.get("data"), dict): + result = ChatTurnResponse.model_validate(item["data"]) + if result is None: + raise RuntimeError("团队唤醒执行未返回完整结果") + outcome = _team_harness_outcome( + db, + tenant_id=team.tenant_id, + session_id=session_id, + client_turn_id=turn_id, + ) + if outcome == "needs_input" and not allow_needs_input: + raise RuntimeError("agent 需要补充信息才能继续,当前场景不支持挂起等待。") + return result.reply + + +def execute_wake_event(db: Session, event: TeamWakeEvent) -> TeamWakeEvent: + """唤醒执行体;任务执行/验收失败会把关联任务置为 escalated,不静默丢任务。 + + 竞标(bid_request)失败例外:只记 bid_failed 审计并尝试推进竞标,不升级任务。 + 执行类唤醒(task_assigned/task_rework)受成员串行约束:占不到执行额度时 + 事件保持 pending 直接返回(记 wake_queued 审计),由终态出队重新拉起。 + """ + team: Team | None = None + agent: AgentProfile | None = None + slot_acquired = False + try: + team = db.get(Team, event.team_id) + if team is None: + raise RuntimeError("唤醒事件所属团队不存在") + agent = _ensure_wake_target_agent(db, event) + if event.trigger_type in EXECUTION_WAKE_TYPES: + # 成员串行排队:进程内额度 + DB in_progress 计数双重判定, + # 覆盖落库前的并发窗口与进程重启后的存量执行中任务 + limit = member_concurrency(team) + slot_acquired = _try_acquire_member_slot(team.id, agent.id, limit) + if not slot_acquired or _member_in_progress_count(db, team, agent.id) >= limit: + if slot_acquired: + _release_member_slot(team.id, agent.id) + slot_acquired = False + event.status = "pending" + _record_wake_queued(db, event, team, agent) + return event + _execute_member_task(db, event, team, agent) + elif event.trigger_type == "task_report": + _execute_tl_review(db, event, team, agent) + elif event.trigger_type == "bid_request": + _execute_bid_request(db, event, team, agent) + elif event.trigger_type == "bid_judge": + _execute_bid_judge(db, event, team, agent) + else: + raise RuntimeError(f"未知唤醒触发类型: {event.trigger_type}") + event.status = "done" + except Exception as exc: + if event.trigger_type == "bid_request": + _handle_bid_failure(db, event, exc) + else: + _escalate_task_on_failure(db, event, exc) + event.status = "failed" + event.error = str(exc) + finally: + event.updated_at = utc_now() + db.add(event) + db.commit() + db.refresh(event) + if slot_acquired and team is not None and agent is not None: + _release_member_slot(team.id, agent.id) + # 执行额度随终态释放,出队拉起该成员最老的排队唤醒 + _drain_member_queue(db, team, agent.id) + return event + + +def _escalate_task_on_failure(db: Session, event: TeamWakeEvent, exc: Exception) -> None: + task_id = str(event.payload_json.get("task_id") or "") + if not task_id: + return + task = db.get(TeamTask, task_id) + if task is None or task.status in {"done", "escalated"}: + return + try: + apply_task_transition( + db, + task, + "escalated", + actor_type="system", + actor_id=None, + event_type="task_escalated", + payload={"reason": str(exc), "wake_event_id": event.id}, + ) + db.commit() + except Exception: + db.rollback() + + +def _execute_member_task(db: Session, event: TeamWakeEvent, team: Team, agent: AgentProfile) -> None: + task_id = str(event.payload_json.get("task_id") or "") + task = db.get(TeamTask, task_id) + if task is None or task.team_id != team.id: + raise RuntimeError("唤醒事件关联的团队任务不存在") + if task.assignee_agent_id and task.assignee_agent_id != agent.id: + raise RuntimeError("唤醒目标与任务指派成员不一致") + rework = event.trigger_type == "task_rework" + session = ChatSession( + id=new_id("session"), + tenant_id=team.tenant_id, + user_id=team.owner_user_id, + agent_id=agent.id, + title=f"团队任务:{task.title}", + status="active", + team_id=team.id, + ) + db.add(session) + db.flush() + task.session_id = session.id + apply_task_transition( + db, + task, + "in_progress", + actor_type="agent", + actor_id=agent.id, + event_type="task_rework_started" if rework else "task_started", + payload={"wake_event_id": event.id}, + ) + db.commit() + message = build_member_task_message(db, team, task, rework=rework) + reply = run_agent_turn( + db, + team=team, + agent=agent, + session_id=session.id, + wake_event_id=event.id, + message=message, + interaction_mode="team_task", + allow_needs_input=True, + ) + outcome = _team_harness_outcome( + db, + tenant_id=team.tenant_id, + session_id=session.id, + client_turn_id=event.id, + ) + task.report_json = { + "summary": reply[:500], + "full_reply": reply, + "finished_at": utc_now().isoformat(), + } + if outcome == "needs_input": + # 成员需要补充信息(如索要合同文本):保留提问,升级给人; + # 人可通过改判/退回重做把补充信息带回给成员(rework 通道即答复通道) + task.report_json["needs_input"] = True + db.add(task) + apply_task_transition( + db, + task, + "escalated", + actor_type="agent", + actor_id=agent.id, + event_type="task_needs_input", + payload={"wake_event_id": event.id, "question": reply[:500]}, + ) + db.commit() + return + # 成员黑板建议:最终回复 + frame 级 reply_fragment 并集解析;裁决权在 TL,此处只暂存 + suggestions = parse_blackboard_suggestions(reply) + if not suggestions: + for fragment in collect_turn_reply_fragments( + db, + tenant_id=team.tenant_id, + session_id=session.id, + client_turn_id=event.id, + ): + suggestions = parse_blackboard_suggestions(fragment) + if suggestions: + break + if suggestions: + task.report_json["blackboard_suggestions"] = suggestions + apply_task_transition( + db, + task, + "review", + actor_type="agent", + actor_id=agent.id, + event_type="task_reported", + payload={"wake_event_id": event.id}, + ) + db.add(task) + leader = get_team_leader(db, team.id) + if leader is None: + apply_task_transition( + db, + task, + "escalated", + actor_type="system", + actor_id=None, + event_type="task_escalated", + payload={"reason": "团队缺少 TL,无法验收"}, + ) + db.commit() + return + db.commit() + wake = enqueue_wake_event( + db, + team=team, + target_agent_id=leader.agent_id, + trigger_type="task_report", + payload={"task_id": task.id}, + ) + db.commit() + start_wakeup_async(wake.id) + + +def _execute_tl_review(db: Session, event: TeamWakeEvent, team: Team, agent: AgentProfile) -> None: + task_id = str(event.payload_json.get("task_id") or "") + task = db.get(TeamTask, task_id) + if task is None or task.team_id != team.id: + raise RuntimeError("唤醒事件关联的团队任务不存在") + if task.status != "review": + # 任务可能已被人改判或退回,迟到/重复的验收唤醒直接跳过 + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="system", + actor_id=None, + event_type="tl_review_skipped", + payload={"wake_event_id": event.id, "task_status": task.status}, + ) + db.commit() + return + session = ChatSession( + id=new_id("session"), + tenant_id=team.tenant_id, + user_id=team.owner_user_id, + agent_id=agent.id, + title=f"团队任务验收:{task.title}", + status="active", + team_id=team.id, + ) + db.add(session) + db.commit() + message = build_tl_review_message(db, team, task) + reply = run_agent_turn( + db, + team=team, + agent=agent, + session_id=session.id, + wake_event_id=event.id, + message=message, + interaction_mode="team_tl", + ) + verdict = parse_tl_review(reply) + if verdict is None: + # 最终回复被 ResponseGenerator 改写时,JSON 块可能只存在于 frame 级输出 + for fragment in collect_turn_reply_fragments( + db, + tenant_id=team.tenant_id, + session_id=session.id, + client_turn_id=event.id, + ): + verdict = parse_tl_review(fragment) + if verdict is not None: + reply = fragment + break + if verdict is None: + # TL 未输出验收代码块:在原会话内补跑一次格式纠错 turn,再解析一次 + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=agent.id, + event_type="tl_review_unparsed", + payload={"wake_event_id": event.id}, + ) + db.commit() + repair_reply = run_agent_turn( + db, + team=team, + agent=agent, + session_id=session.id, + wake_event_id=event.id, + client_turn_id=f"{event.id}-repair", + message=f"{message}\n\n{TL_REVIEW_REPAIR_MESSAGE}", + interaction_mode="team_tl", + ) + verdict = parse_tl_review(repair_reply) + if verdict is None: + for fragment in collect_turn_reply_fragments( + db, + tenant_id=team.tenant_id, + session_id=session.id, + client_turn_id=f"{event.id}-repair", + ): + verdict = parse_tl_review(fragment) + if verdict is not None: + repair_reply = fragment + break + if verdict is not None: + reply = repair_reply + if verdict is None: + # 纠错后仍无结论:不改状态,等待人介入 + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=agent.id, + event_type="tl_review_repair_failed", + payload={"wake_event_id": event.id}, + ) + db.commit() + return + target = VERDICT_TARGET_STATUS[verdict["verdict"]] + task.review_json = { + "verdict": verdict["verdict"], + "comment": verdict["comment"], + "raw_reply": reply, + "reviewed_at": utc_now().isoformat(), + } + apply_task_transition( + db, + task, + target, + actor_type="agent", + actor_id=agent.id, + event_type=f"tl_review_{verdict['verdict']}", + payload={"comment": verdict["comment"], "wake_event_id": event.id}, + ) + # TL 裁决认可的黑板条目并入本次验收落库,零额外唤醒;未认可的建议即视为拒绝 + blackboard_writes = verdict.get("blackboard_writes") or [] + if blackboard_writes: + written, skipped = write_blackboard_entries( + db, + team=team, + entries=blackboard_writes, + source_type="member", + source_agent_id=task.assignee_agent_id, + source_task_id=task.id, + ) + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=agent.id, + event_type="blackboard_written", + payload={ + "wake_event_id": event.id, + "written": len(written), + "entry_ids": [entry.id for entry in written], + "skipped": skipped, + }, + ) + db.add(task) + db.commit() + if verdict["verdict"] == "rework" and task.assignee_agent_id: + wake = enqueue_wake_event( + db, + team=team, + target_agent_id=task.assignee_agent_id, + trigger_type="task_rework", + payload={"task_id": task.id}, + ) + db.commit() + start_wakeup_async(wake.id) + + +# ---------- 任务池竞标 ---------- + + +def start_bidding(db: Session, team: Team, task: TeamTask) -> None: + """开启任务池竞标:选候选 -> 置 bidding -> 为每个候选入队 round=1 竞标唤醒。 + + 无候选时任务直接升级给人,不入队任何唤醒。 + """ + candidates = select_bid_candidates(db, team, task) + if not candidates: + apply_task_transition( + db, + task, + "escalated", + actor_type="system", + actor_id=None, + event_type="task_escalated", + payload={"reason": "任务池竞标无候选成员"}, + ) + db.commit() + return + apply_task_transition( + db, + task, + "bidding", + actor_type="system", + actor_id=None, + event_type="task_bidding_started", + payload={"candidate_agent_ids": candidates}, + ) + db.commit() + wakes = [ + enqueue_wake_event( + db, + team=team, + target_agent_id=agent_id, + trigger_type="bid_request", + payload={"task_id": task.id, "round": 1}, + ) + for agent_id in candidates + ] + db.commit() + for wake in wakes: + start_wakeup_async(wake.id) + + +def _bidding_candidates(db: Session, task: TeamTask) -> list[str]: + """从 task_bidding_started 审计 payload 取候选列表(推进/裁决的候选集依据)。""" + rows = db.exec( + select(TeamTaskEvent) + .where( + TeamTaskEvent.task_id == task.id, + TeamTaskEvent.event_type == "task_bidding_started", + ) + .order_by(TeamTaskEvent.created_at) + ).all() + if not rows: + return [] + payload = rows[-1].payload_json if isinstance(rows[-1].payload_json, dict) else {} + return [str(item) for item in payload.get("candidate_agent_ids") or []] + + +def _bid_failed_agents(db: Session, task: TeamTask, round_: int) -> set[str]: + """指定轮次竞标执行失败的候选集合(以 bid_failed 审计为准)。""" + rows = db.exec( + select(TeamTaskEvent).where( + TeamTaskEvent.task_id == task.id, TeamTaskEvent.event_type == "bid_failed" + ) + ).all() + failed: set[str] = set() + for row in rows: + payload = row.payload_json if isinstance(row.payload_json, dict) else {} + if int(payload.get("round") or 1) == round_ and row.actor_id: + failed.add(str(row.actor_id)) + return failed + + +def _wake_pending_for_task( + db: Session, + team: Team, + task_id: str, + trigger_type: str, + round_: int | None = None, + mode: str | None = None, +) -> bool: + """同类唤醒是否已入队(pending/claimed);竞标推进的幂等护栏,防并发重复入队。""" + rows = db.exec( + select(TeamWakeEvent).where( + TeamWakeEvent.team_id == team.id, TeamWakeEvent.trigger_type == trigger_type + ) + ).all() + for row in rows: + payload = row.payload_json if isinstance(row.payload_json, dict) else {} + if str(payload.get("task_id") or "") != task_id: + continue + if round_ is not None and int(payload.get("round") or 1) != round_: + continue + if mode is not None and str(payload.get("mode") or "award") != mode: + continue + if row.status in {"pending", "claimed"}: + return True + return False + + +def _enqueue_bid_judge( + db: Session, team: Team, task: TeamTask, *, mode: str, round_: int | None = None +) -> None: + """入队 TL 裁决唤醒:mode=score(第 round_ 轮打分)/ award(最终裁决);幂等护栏防重复。""" + if _wake_pending_for_task(db, team, task.id, "bid_judge", round_=round_, mode=mode): + return + leader = get_team_leader(db, team.id) + if leader is None: + apply_task_transition( + db, + task, + "escalated", + actor_type="system", + actor_id=None, + event_type="task_escalated", + payload={"reason": "团队缺少 TL,无法裁决竞标"}, + ) + db.commit() + return + payload: dict = {"task_id": task.id, "mode": mode} + if round_ is not None: + payload["round"] = round_ + wake = enqueue_wake_event( + db, + team=team, + target_agent_id=leader.agent_id, + trigger_type="bid_judge", + payload=payload, + ) + db.commit() + start_wakeup_async(wake.id) + + +def _alive_bid_candidates(candidates: list[str], bids: list[TeamTaskBid]) -> list[str]: + """存活候选:陈述轮已应标且血条未归零(HP>0)。""" + stated = {bid.agent_id for bid in bids if bid.round == 1} + hp = candidate_hp(bids) + return [ + agent_id + for agent_id in candidates + if agent_id in stated and hp.get(agent_id, BID_HP_INITIAL) > 0 + ] + + +def _maybe_advance_bidding(db: Session, team: Team, task: TeamTask) -> None: + """竞标推进检查:每轮存活候选全部应答(或失败)后,TL 打分(非末轮)/裁决(末轮)。 + + 轮次语义:round 1 = 陈述,round 2..N = 反驳(N=bid_rebuttal_rounds); + 每轮打分扣减血条,存活 ≤1 人时直接进裁决;无人应标则升级给人。 + """ + if task.status != "bidding": + return + candidates = _bidding_candidates(db, task) + if not candidates: + return + bids = list(db.exec(select(TeamTaskBid).where(TeamTaskBid.task_id == task.id)).all()) + + stated = {bid.agent_id for bid in bids if bid.round == 1} + failed_r1 = _bid_failed_agents(db, task, 1) + if not all(agent_id in stated or agent_id in failed_r1 for agent_id in candidates): + return # 陈述轮未齐,继续等待其余候选 + valid = [agent_id for agent_id in candidates if agent_id in stated] + if not valid: + apply_task_transition( + db, + task, + "escalated", + actor_type="system", + actor_id=None, + event_type="task_escalated", + payload={"reason": "任务池竞标无人应标"}, + ) + db.commit() + return + total_rounds = bid_rebuttal_rounds(team) + alive = _alive_bid_candidates(candidates, bids) + # 辩论关闭(0/1 轮)或有效应标不足两人:陈述后直接裁决(兼容旧行为) + if total_rounds <= 1 or len(valid) < 2: + _enqueue_bid_judge(db, team, task, mode="award") + return + for round_ in range(1, total_rounds + 1): + if round_ > 1: + answered = {bid.agent_id for bid in bids if bid.round == round_} + failed = _bid_failed_agents(db, task, round_) + if not all(agent_id in answered or agent_id in failed for agent_id in alive): + # 本轮未齐:首次集齐上轮打分后为存活候选入队本轮竞标 + if not _wake_pending_for_task(db, team, task.id, "bid_request", round_=round_): + wakes = [ + enqueue_wake_event( + db, + team=team, + target_agent_id=agent_id, + trigger_type="bid_request", + payload={"task_id": task.id, "round": round_}, + ) + for agent_id in alive + if agent_id not in answered and agent_id not in failed + ] + db.commit() + for wake in wakes: + start_wakeup_async(wake.id) + return + if round_ == total_rounds: + # 末轮已齐:直接裁决 + _enqueue_bid_judge(db, team, task, mode="award") + return + round_bids = [ + bid for bid in bids if bid.round == round_ and bid.agent_id in set(alive) + ] + if round_bids and not all(bid.score is not None for bid in round_bids): + # 非末轮已齐未打分:先入队 TL 打分,打分落库后由打分执行体再次推进 + _enqueue_bid_judge(db, team, task, mode="score", round_=round_) + return + # 本轮打分完成:重算血条,存活 ≤1 人提前进裁决 + alive = _alive_bid_candidates(candidates, bids) + if len(alive) <= 1: + _enqueue_bid_judge(db, team, task, mode="award") + return + + +def _handle_bid_failure(db: Session, event: TeamWakeEvent, exc: Exception) -> None: + """竞标候选执行失败:记 bid_failed 审计并尝试推进竞标,不升级任务。""" + task_id = str(event.payload_json.get("task_id") or "") + if not task_id: + return + task = db.get(TeamTask, task_id) + team = db.get(Team, event.team_id) + if task is None or team is None: + return + if task.status != "bidding": + # 竞标期间已被人改判/裁决,迟到的失败只留跳过审计 + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="system", + actor_id=None, + event_type="bid_skipped", + payload={"wake_event_id": event.id, "task_status": task.status}, + ) + db.commit() + return + try: + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=event.target_agent_id, + event_type="bid_failed", + payload={ + "wake_event_id": event.id, + "round": int(event.payload_json.get("round") or 1), + "reason": str(exc), + }, + ) + db.commit() + _maybe_advance_bidding(db, team, task) + except Exception: + db.rollback() + + +def _execute_bid_request( + db: Session, event: TeamWakeEvent, team: Team, agent: AgentProfile +) -> None: + """候选竞标执行体:独立会话输出竞标块,落 team_task_bids 并推进竞标。""" + task_id = str(event.payload_json.get("task_id") or "") + round_ = int(event.payload_json.get("round") or 1) + kind = "statement" if round_ <= 1 else "rebuttal" + task = db.get(TeamTask, task_id) + if task is None or task.team_id != team.id: + raise RuntimeError("唤醒事件关联的团队任务不存在") + if task.status != "bidding": + # 任务已被人改判或已完成裁决,迟到的竞标唤醒直接跳过 + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="system", + actor_id=None, + event_type="bid_skipped", + payload={"wake_event_id": event.id, "task_status": task.status, "round": round_}, + ) + db.commit() + return + duplicate = db.exec( + select(TeamTaskBid).where( + TeamTaskBid.task_id == task.id, + TeamTaskBid.agent_id == agent.id, + TeamTaskBid.round == round_, + TeamTaskBid.kind == kind, + ) + ).first() + if duplicate is not None: + # 重复触发:同轮同类型竞标已落库,直接跳过 + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="system", + actor_id=None, + event_type="bid_skipped", + payload={"wake_event_id": event.id, "reason": "duplicate", "round": round_}, + ) + db.commit() + return + session = ChatSession( + id=new_id("session"), + tenant_id=team.tenant_id, + user_id=team.owner_user_id, + agent_id=agent.id, + title=f"团队竞标:{task.title}", + status="active", + team_id=team.id, + ) + db.add(session) + db.commit() + message = build_bid_request_message(db, team, task, agent, round_=round_) + reply = run_agent_turn( + db, + team=team, + agent=agent, + session_id=session.id, + wake_event_id=event.id, + message=message, + interaction_mode="team_task", + ) + bid = parse_bid(reply) + if bid is None: + # 最终回复被 ResponseGenerator 改写时,竞标块可能只存在于 frame 级输出 + for fragment in collect_turn_reply_fragments( + db, + tenant_id=team.tenant_id, + session_id=session.id, + client_turn_id=event.id, + ): + bid = parse_bid(fragment) + if bid is not None: + break + content = bid["plan"] if bid else reply.strip() + if not content: + raise RuntimeError("候选未给出任何竞标内容") + db.add( + TeamTaskBid( + task_id=task.id, + team_id=team.id, + tenant_id=team.tenant_id, + agent_id=agent.id, + round=round_, + kind=kind, + content=content, + ) + ) + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=agent.id, + event_type="bid_submitted", + payload={"wake_event_id": event.id, "round": round_, "kind": kind}, + ) + db.commit() + _maybe_advance_bidding(db, team, task) + + +def _parse_bid_award_with_fragments( + db: Session, + *, + team: Team, + session_id: str, + client_turn_id: str, + reply: str, + candidate_ids: set[str], +) -> dict | None: + """裁决块解析:最终回复优先,缺失时回退 frame 级 reply_fragment 并集。""" + award = parse_bid_award(reply, candidate_ids) + if award is not None: + return award + for fragment in collect_turn_reply_fragments( + db, + tenant_id=team.tenant_id, + session_id=session_id, + client_turn_id=client_turn_id, + ): + award = parse_bid_award(fragment, candidate_ids) + if award is not None: + return award + return None + + +def _parse_bid_scores_with_fragments( + db: Session, + *, + team: Team, + session_id: str, + client_turn_id: str, + reply: str, + candidate_ids: set[str], +) -> dict | None: + """打分块解析:最终回复优先,缺失时回退 frame 级 reply_fragment 并集。""" + scores = parse_bid_scores(reply, candidate_ids) + if scores is not None: + return scores + for fragment in collect_turn_reply_fragments( + db, + tenant_id=team.tenant_id, + session_id=session_id, + client_turn_id=client_turn_id, + ): + scores = parse_bid_scores(fragment, candidate_ids) + if scores is not None: + return scores + return None + + +def _execute_bid_judge( + db: Session, event: TeamWakeEvent, team: Team, agent: AgentProfile +) -> None: + """TL 竞标裁决入口:按 payload.mode 分发——score(每轮打分)/ award(最终裁决)。""" + task_id = str(event.payload_json.get("task_id") or "") + task = db.get(TeamTask, task_id) + if task is None or task.team_id != team.id: + raise RuntimeError("唤醒事件关联的团队任务不存在") + if task.status != "bidding": + # 任务已被人改判,迟到的裁决唤醒直接跳过 + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="system", + actor_id=None, + event_type="bid_skipped", + payload={"wake_event_id": event.id, "task_status": task.status}, + ) + db.commit() + return + mode = str(event.payload_json.get("mode") or "award") + if mode == "score": + _execute_bid_score(db, event, team, agent, task) + return + _execute_bid_award(db, event, team, agent, task) + + +def _execute_bid_score( + db: Session, event: TeamWakeEvent, team: Team, agent: AgentProfile, task: TeamTask +) -> None: + """TL 每轮打分执行体:分数写回该轮 bid,血条归零审计淘汰,再推进竞标。 + + 打分解析失败补一次格式纠错 turn;再失败全员记兜底分(5 分)并审计 + bid_score_fallback,不阻塞流程。 + """ + round_ = int(event.payload_json.get("round") or 1) + round_bids = list( + db.exec( + select(TeamTaskBid) + .where(TeamTaskBid.task_id == task.id, TeamTaskBid.round == round_) + .order_by(TeamTaskBid.created_at) + ).all() + ) + if not round_bids: + # 本轮无人应标(全部失败):无分可打,直接推进 + _maybe_advance_bidding(db, team, task) + return + if all(bid.score is not None for bid in round_bids): + # 重复触发:本轮已打分,直接跳过 + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="system", + actor_id=None, + event_type="bid_skipped", + payload={"wake_event_id": event.id, "reason": "duplicate_score", "round": round_}, + ) + db.commit() + return + candidate_ids = {bid.agent_id for bid in round_bids} + session = ChatSession( + id=new_id("session"), + tenant_id=team.tenant_id, + user_id=team.owner_user_id, + agent_id=agent.id, + title=f"团队竞标打分:{task.title}", + status="active", + team_id=team.id, + ) + db.add(session) + db.commit() + message = build_bid_score_message(db, team, task, round_bids, round_=round_) + reply = run_agent_turn( + db, + team=team, + agent=agent, + session_id=session.id, + wake_event_id=event.id, + message=message, + interaction_mode="team_tl", + ) + scores = _parse_bid_scores_with_fragments( + db, + team=team, + session_id=session.id, + client_turn_id=event.id, + reply=reply, + candidate_ids=candidate_ids, + ) + if scores is None: + # 未输出有效打分块:带完整上下文补一次格式纠错 turn + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=agent.id, + event_type="bid_score_unparsed", + payload={"wake_event_id": event.id, "round": round_}, + ) + db.commit() + repair_reply = run_agent_turn( + db, + team=team, + agent=agent, + session_id=session.id, + wake_event_id=event.id, + client_turn_id=f"{event.id}-repair", + message=f"{message}\n\n{TL_BID_SCORE_REPAIR_MESSAGE}", + interaction_mode="team_tl", + ) + scores = _parse_bid_scores_with_fragments( + db, + team=team, + session_id=session.id, + client_turn_id=f"{event.id}-repair", + reply=repair_reply, + candidate_ids=candidate_ids, + ) + if scores is None: + # 纠错后仍无有效打分:全员记兜底分,审计兜底,不阻塞竞标流程 + scores = {} + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="system", + actor_id=None, + event_type="bid_score_fallback", + payload={"wake_event_id": event.id, "round": round_, "score": BID_SCORE_FALLBACK}, + ) + for bid in round_bids: + scored = scores.get(bid.agent_id) or {"score": BID_SCORE_FALLBACK, "rationale": ""} + bid.score = scored["score"] + bid.score_rationale = scored["rationale"] or None + db.add(bid) + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=agent.id, + event_type="bid_scored", + payload={ + "wake_event_id": event.id, + "round": round_, + "scores": {bid.agent_id: bid.score for bid in round_bids}, + }, + ) + # 血条归零淘汰审计(同一候选只记一次) + all_bids = list(db.exec(select(TeamTaskBid).where(TeamTaskBid.task_id == task.id)).all()) + hp = candidate_hp(all_bids) + eliminated_rows = db.exec( + select(TeamTaskEvent).where( + TeamTaskEvent.task_id == task.id, TeamTaskEvent.event_type == "bid_eliminated" + ) + ).all() + already_eliminated = {str(row.actor_id) for row in eliminated_rows if row.actor_id} + for agent_id, value in hp.items(): + if value > 0 or agent_id in already_eliminated: + continue + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=agent_id, + event_type="bid_eliminated", + payload={"round": round_, "hp": value, "wake_event_id": event.id}, + ) + db.commit() + _maybe_advance_bidding(db, team, task) + + +def _execute_bid_award( + db: Session, event: TeamWakeEvent, team: Team, agent: AgentProfile, task: TeamTask +) -> None: + """TL 最终裁决执行体:末轮打分写回,中标者(须在存活候选中)走 task_assigned 链路。""" + candidates = _bidding_candidates(db, task) + bids = list( + db.exec( + select(TeamTaskBid) + .where(TeamTaskBid.task_id == task.id) + .order_by(TeamTaskBid.round, TeamTaskBid.created_at) + ).all() + ) + alive = _alive_bid_candidates(candidates, bids) + if not alive: + # 存活候选为空(全部淘汰/无人应标):升级给人,不静默丢任务 + apply_task_transition( + db, + task, + "escalated", + actor_type="system", + actor_id=None, + event_type="task_escalated", + payload={"reason": "竞标无存活候选,无法裁决", "wake_event_id": event.id}, + ) + db.commit() + return + session = ChatSession( + id=new_id("session"), + tenant_id=team.tenant_id, + user_id=team.owner_user_id, + agent_id=agent.id, + title=f"团队竞标裁决:{task.title}", + status="active", + team_id=team.id, + ) + db.add(session) + db.commit() + message = build_bid_judge_message(db, team, task, bids, alive) + reply = run_agent_turn( + db, + team=team, + agent=agent, + session_id=session.id, + wake_event_id=event.id, + message=message, + interaction_mode="team_tl", + ) + award = _parse_bid_award_with_fragments( + db, + team=team, + session_id=session.id, + client_turn_id=event.id, + reply=reply, + candidate_ids=set(alive), + ) + if award is None: + # 未输出有效裁决块(含 winner 不在存活候选):带完整上下文补一次格式纠错 turn + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=agent.id, + event_type="bid_award_unparsed", + payload={"wake_event_id": event.id}, + ) + db.commit() + repair_reply = run_agent_turn( + db, + team=team, + agent=agent, + session_id=session.id, + wake_event_id=event.id, + client_turn_id=f"{event.id}-repair", + message=f"{message}\n\n{TL_BID_JUDGE_REPAIR_MESSAGE}", + interaction_mode="team_tl", + ) + award = _parse_bid_award_with_fragments( + db, + team=team, + session_id=session.id, + client_turn_id=f"{event.id}-repair", + reply=repair_reply, + candidate_ids=set(alive), + ) + if award is None: + # 纠错后仍无有效裁决:升级给人,不静默丢任务 + apply_task_transition( + db, + task, + "escalated", + actor_type="system", + actor_id=None, + event_type="task_escalated", + payload={"reason": "TL 竞标裁决失败", "wake_event_id": event.id}, + ) + db.commit() + return + winner = award["winner_agent_id"] + for bid in bids: + # 各轮打分已在 score 模式写回,裁决分数只补未打分的 bid(通常是末轮) + if bid.score is not None: + continue + scored = award["scores"].get(bid.agent_id) + if scored is None: + continue + bid.score = scored["score"] + bid.score_rationale = scored["rationale"] or None + db.add(bid) + task.assignee_agent_id = winner + apply_task_transition( + db, + task, + "pending", + actor_type="agent", + actor_id=agent.id, + event_type="task_awarded", + payload={ + "winner_agent_id": winner, + "comment": award["comment"], + "wake_event_id": event.id, + }, + ) + db.add(task) + db.commit() + wake = enqueue_wake_event( + db, + team=team, + target_agent_id=winner, + trigger_type="task_assigned", + payload={"task_id": task.id}, + ) + db.commit() + start_wakeup_async(wake.id) + + +# ---------- TL 对话轮次后处理(tl_chat 端点与主聊天端共用) ---------- + + +def process_tl_reply( + db: Session, + *, + team: Team, + session: ChatSession, + user: User, + user_message: str, + reply: str, + client_turn_id: str | None, +) -> list[TeamTask]: + """TL 对话轮次后处理:解析派任务块并创建任务(直派唤醒/投池竞标)。 + + 最终回复被 ResponseGenerator 改写时回退 frame 级 reply_fragment; + 意在派发但未输出代码块时,带完整上下文补一次格式纠错 turn。 + 返回本次创建的任务列表(纯对话时为空)。 + """ + leader = get_team_leader(db, team.id) + if leader is None: + return [] + tl_agent = db.get(AgentProfile, leader.agent_id) + if tl_agent is None: + return [] + assignments = parse_tl_task_assignments(reply) + if not assignments: + # 最终回复被 ResponseGenerator 改写时,JSON 块可能只存在于 frame 级输出 + for fragment in collect_turn_reply_fragments( + db, + tenant_id=team.tenant_id, + session_id=session.id, + client_turn_id=client_turn_id or "", + ): + assignments = parse_tl_task_assignments(fragment) + if assignments: + break + if not assignments and looks_like_dispatch_intent(user_message, reply): + # TL 口头声称派发但未输出 JSON 代码块:带完整上下文补一次格式纠错 turn + repair_turn = ChatTurnRequest( + tenant_id=team.tenant_id, + session_id=session.id, + agent_id=tl_agent.id, + client_turn_id=new_id("teamturn"), + user_id=user.id, + message=f"{build_tl_chat_message(db, team, user_message)}\n\n{TL_ASSIGNMENT_REPAIR_MESSAGE}", + channel="team", + interaction_mode="team_tl", + ) + repair_reply = AgentLoop(db).handle_turn(repair_turn).reply or "" + repair_assignments = parse_tl_task_assignments(repair_reply) + if not repair_assignments: + for fragment in collect_turn_reply_fragments( + db, + tenant_id=team.tenant_id, + session_id=session.id, + client_turn_id=repair_turn.client_turn_id, + ): + repair_assignments = parse_tl_task_assignments(fragment) + if repair_assignments: + break + if repair_assignments: + assignments = repair_assignments + member_ids = {item.agent_id for item in list_team_members(db, team.id)} + created: list[TeamTask] = [] + wake_ids: list[str] = [] + bidding_tasks: list[TeamTask] = [] + for item in assignments: + assignee = item.get("assignee_agent_id") or "" + if assignee and assignee not in member_ids: + # TL 指派的 agent 不在花名册:跳过,不建任务 + continue + task = TeamTask( + team_id=team.id, + tenant_id=team.tenant_id, + title=item["title"], + description=item.get("description"), + status="pending", + created_by_user_id=user.id, + created_by_tl=True, + assignee_agent_id=assignee or None, + ) + db.add(task) + db.flush() + record_task_event( + db, + team_id=team.id, + task_id=task.id, + actor_type="agent", + actor_id=tl_agent.id, + event_type="task_created", + payload={"title": task.title, "assignee_agent_id": assignee or None}, + ) + if assignee: + wake = enqueue_wake_event( + db, + team=team, + target_agent_id=assignee, + trigger_type="task_assigned", + payload={"task_id": task.id}, + ) + wake_ids.append(wake.id) + else: + # 未指定负责人:投入任务池,走竞标流程 + bidding_tasks.append(task) + created.append(task) + db.commit() + for task in created: + db.refresh(task) + for wake_id in wake_ids: + start_wakeup_async(wake_id) + for task in bidding_tasks: + start_bidding(db, team, task) + return created diff --git a/backend/tests/test_channel_team_binding.py b/backend/tests/test_channel_team_binding.py new file mode 100644 index 00000000..643a5149 --- /dev/null +++ b/backend/tests/test_channel_team_binding.py @@ -0,0 +1,492 @@ +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, inspect, text +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, select +from sqlmodel import create_engine as sqlmodel_create_engine + +import app.channels.service_intake as intake_module +import app.core.agent_loop as agent_loop_module +from app.api import channels as channels_api +from app.channels.service_intake import process_inbound +from app.db import database, get_session +from app.db.models import ( + AgentProfile, + ChannelBinding, + ChannelBindingAgent, + ChannelDelivery, + ChannelInboundEvent, + ChatSession, + Message, + Team, + TeamTask, + Tenant, + User, + new_id, +) +from app.security.auth import create_access_token +from app.teams import wakeup +from app.teams.service import add_member, create_team, set_leader + + +def _test_engine(): + engine = sqlmodel_create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return engine + + +def _p2p_message(event_id: str = "evt_1", text: str = "你好") -> dict: + return { + "message_id": event_id, + "from_user_id": "user_ab12cd34@im.wechat", + "to_user_id": "bot_1@im.bot", + "client_id": f"wx-{event_id}", + "session_id": "user_ab12cd34@im.wechat#bot_1@im.bot", + "message_type": 1, + "message_state": 2, + "context_token": f"ctx_{event_id}", + "item_list": [{"type": 1, "text_item": {"text": text}}], + } + + +def _seed_team(db: Session, *, with_leader: bool = True) -> Team: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.add(AgentProfile(id="agent_tl", tenant_id="tenant_demo", name="TL 小队长")) + db.add(AgentProfile(id="agent_worker", tenant_id="tenant_demo", name="工人甲")) + db.add(AgentProfile(id="agent_worker2", tenant_id="tenant_demo", name="工人乙")) + db.commit() + team = create_team( + db, + tenant_id="tenant_demo", + name="增长团队", + description=None, + owner_user_id="user_admin", + ) + if with_leader: + add_member(db, team, agent_id="agent_tl", role="leader") + add_member(db, team, agent_id="agent_worker") + add_member(db, team, agent_id="agent_worker2") + return team + + +def _seed_team_binding(engine, team_id: str | None, **overrides) -> str: + with Session(engine) as db: + values = { + "tenant_id": "tenant_demo", + "agent_id": "agent_tl", + "channel": "wechat", + "status": "active", + "config_json": {"ilink_bot_id": "bot_1@im.bot"}, + "team_id": team_id, + } + values.update(overrides) + binding = ChannelBinding(**values) + db.add(binding) + db.commit() + return binding.id + + +def _load_binding(engine, binding_id: str) -> ChannelBinding: + with Session(engine) as db: + binding = db.get(ChannelBinding, binding_id) + db.expunge(binding) + return binding + + +class RecordingAgentLoop: + """替代真实 AgentLoop:记录请求、模拟消息落库并返回固定回复。""" + + calls: list = [] + reply_text = "自动回复" + + def __init__(self, db): + self.db = db + + def handle_turn(self, request): + type(self).calls.append(request) + self.db.add( + Message( + id=new_id("msg"), + tenant_id=request.tenant_id, + session_id=request.session_id, + role="user", + content=request.message, + metadata_json={"client_turn_id": request.client_turn_id or ""}, + ) + ) + self.db.add( + Message( + id=new_id("msg"), + tenant_id=request.tenant_id, + session_id=request.session_id, + role="assistant", + content=type(self).reply_text, + metadata_json={}, + ) + ) + self.db.commit() + return SimpleNamespace(reply=type(self).reply_text, session_id=request.session_id) + + +@pytest.fixture(autouse=True) +def _fake_agent_loop(monkeypatch): + RecordingAgentLoop.calls = [] + RecordingAgentLoop.reply_text = "自动回复" + monkeypatch.setattr(agent_loop_module, "AgentLoop", RecordingAgentLoop) + monkeypatch.setattr( + intake_module, "_send_wechat_typing", lambda *args, **kwargs: None + ) + yield + + +# ---------- 入站:团队绑定直路由 TL ---------- + + +def test_team_binding_inbound_routes_to_tl() -> None: + engine = _test_engine() + with Session(engine) as db: + team = _seed_team(db) + team_id = team.id + binding_id = _seed_team_binding(engine, team_id) + binding = _load_binding(engine, binding_id) + + assert process_inbound(binding, _p2p_message("evt_1"), db_engine=engine) is True + assert len(RecordingAgentLoop.calls) == 1 + request = RecordingAgentLoop.calls[0] + # 团队分支:interaction_mode=team_tl,消息被 build_tl_chat_message 包装(含花名册) + assert request.interaction_mode == "team_tl" + assert request.agent_id == "agent_tl" + assert "团队「增长团队」的 TL" in request.message + assert "团队花名册:" in request.message + assert "人的需求:你好" in request.message + + with Session(engine) as db: + chat_session = db.get(ChatSession, request.session_id) + # 会话落 team_id 且标题命中 TL 对话识别三条件之一 + assert chat_session.team_id == team_id + assert chat_session.title == "团队 增长团队 · TL 对话" + assert chat_session.channel_binding_id == binding_id + + +def test_team_binding_follows_leader_change() -> None: + engine = _test_engine() + with Session(engine) as db: + team = _seed_team(db) + team_id = team.id + binding_id = _seed_team_binding(engine, team_id) + binding = _load_binding(engine, binding_id) + + assert process_inbound(binding, _p2p_message("evt_1"), db_engine=engine) is True + assert RecordingAgentLoop.calls[0].agent_id == "agent_tl" + + # 换帅:新消息路由给新 TL,并按新 agent_id 锚定另起团队会话 + with Session(engine) as db: + team = db.get(Team, team_id) + set_leader(db, team, "agent_worker2") + assert process_inbound(binding, _p2p_message("evt_2", "新需求"), db_engine=engine) is True + assert len(RecordingAgentLoop.calls) == 2 + request = RecordingAgentLoop.calls[1] + assert request.agent_id == "agent_worker2" + assert request.interaction_mode == "team_tl" + assert request.session_id != RecordingAgentLoop.calls[0].session_id + + with Session(engine) as db: + new_session = db.get(ChatSession, request.session_id) + assert new_session.team_id == team_id + assert new_session.agent_id == "agent_worker2" + assert "TL 对话" in (new_session.title or "") + + +def test_team_binding_tl_reply_creates_tasks(monkeypatch) -> None: + engine = _test_engine() + with Session(engine) as db: + team = _seed_team(db) + team_id = team.id + binding_id = _seed_team_binding(engine, team_id) + binding = _load_binding(engine, binding_id) + + started: list[str] = [] + monkeypatch.setattr(wakeup, "start_wakeup_async", started.append) + RecordingAgentLoop.reply_text = ( + "好的,马上安排。\n" + "```json\n" + '{"team_tasks": [{"title": "调研竞品", "description": "输出调研报告", ' + '"assignee_agent_id": "agent_worker"}]}\n' + "```" + ) + + assert process_inbound(binding, _p2p_message("evt_1", "帮我调研一下竞品"), db_engine=engine) is True + + with Session(engine) as db: + tasks = db.exec(select(TeamTask).where(TeamTask.team_id == team_id)).all() + assert len(tasks) == 1 + assert tasks[0].title == "调研竞品" + assert tasks[0].assignee_agent_id == "agent_worker" + assert tasks[0].created_by_tl is True + # 直派任务触发成员唤醒(测试里同步收集,不起线程) + assert len(started) == 1 + + +def test_team_binding_without_leader_replies_notice() -> None: + engine = _test_engine() + with Session(engine) as db: + team = _seed_team(db, with_leader=False) + team_id = team.id + binding_id = _seed_team_binding(engine, team_id) + binding = _load_binding(engine, binding_id) + + # 团队未设 TL:回复提示文案,不进 AgentLoop + assert process_inbound(binding, _p2p_message("evt_1"), db_engine=engine) is False + assert RecordingAgentLoop.calls == [] + + with Session(engine) as db: + event = db.exec(select(ChannelInboundEvent)).one() + assert event.status == "done" + notices = db.exec( + select(ChannelDelivery).where(ChannelDelivery.kind == "notice") + ).all() + assert len(notices) == 1 + assert "暂未设置 TL" in notices[0].text + + +def test_team_binding_switch_command_rejected() -> None: + engine = _test_engine() + with Session(engine) as db: + team = _seed_team(db) + team_id = team.id + binding_id = _seed_team_binding(engine, team_id) + binding = _load_binding(engine, binding_id) + + # 员工切换类指令在团队绑定下不可用,只回提示不进对话 + assert process_inbound(binding, _p2p_message("evt_1", "/员工"), db_engine=engine) is False + assert RecordingAgentLoop.calls == [] + + with Session(engine) as db: + notices = db.exec( + select(ChannelDelivery).where(ChannelDelivery.kind == "notice") + ).all() + assert len(notices) == 1 + assert "团队 TL" in notices[0].text + + +# ---------- 创建/更新绑定 API ---------- + + +def _make_client(engine): + app = FastAPI() + app.include_router(channels_api.router) + + def override_get_session(): + with Session(engine) as session: + yield session + + app.dependency_overrides[get_session] = override_get_session + return TestClient(app) + + +def _auth(user: User) -> dict[str, str]: + return {"Authorization": f"Bearer {create_access_token(user)}"} + + +def _seed_api_users(engine) -> dict[str, User]: + with Session(engine) as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.add(Tenant(id="tenant_other", name="Other")) + admin = User( + id="user_admin", tenant_id="tenant_demo", username="admin", + role="admin", password_hash="x", + ) + member = User( + id="user_member", tenant_id="tenant_demo", username="member", password_hash="x", + ) + db.add(AgentProfile(id="agent_tl", tenant_id="tenant_demo", name="TL 小队长")) + db.add(AgentProfile(id="agent_worker", tenant_id="tenant_demo", name="工人甲")) + db.add(AgentProfile(id="agent_outside", tenant_id="tenant_other", name="外部 TL")) + db.add_all([admin, member]) + db.commit() + for user in (admin, member): + db.refresh(user) + db.expunge(user) + return {"admin": admin, "member": member} + + +def _create_api_team(engine, *, tenant_id: str = "tenant_demo", with_leader: bool = True) -> str: + with Session(engine) as db: + team = create_team( + db, + tenant_id=tenant_id, + name=f"团队-{tenant_id}", + description=None, + owner_user_id="user_admin", + ) + leader_id = "agent_tl" if tenant_id == "tenant_demo" else "agent_outside" + if with_leader: + add_member(db, team, agent_id=leader_id, role="leader") + return team.id + + +def test_create_team_binding_success() -> None: + engine = _test_engine() + users = _seed_api_users(engine) + team_id = _create_api_team(engine) + client = _make_client(engine) + + response = client.post( + "/api/enterprise/channels", + json={"tenant_id": "tenant_demo", "team_id": team_id, "channel": "wechat"}, + headers=_auth(users["admin"]), + ) + assert response.status_code == 200 + payload = response.json() + assert payload["team_id"] == team_id + assert payload["team_name"] == "团队-tenant_demo" + # 遗留 agent_id 列回写现任 TL + assert payload["agent_id"] == "agent_tl" + + with Session(engine) as db: + # 团队绑定不写员工挂载行 + mounts = db.exec( + select(ChannelBindingAgent).where( + ChannelBindingAgent.binding_id == payload["id"] + ) + ).all() + assert mounts == [] + + +def test_create_binding_agent_and_team_mutually_exclusive() -> None: + engine = _test_engine() + users = _seed_api_users(engine) + team_id = _create_api_team(engine) + client = _make_client(engine) + + both = client.post( + "/api/enterprise/channels", + json={ + "tenant_id": "tenant_demo", + "agent_id": "agent_tl", + "team_id": team_id, + "channel": "wechat", + }, + headers=_auth(users["admin"]), + ) + assert both.status_code == 400 + + neither = client.post( + "/api/enterprise/channels", + json={"tenant_id": "tenant_demo", "channel": "wechat"}, + headers=_auth(users["admin"]), + ) + assert neither.status_code == 400 + + +def test_create_team_binding_requires_leader() -> None: + engine = _test_engine() + users = _seed_api_users(engine) + team_id = _create_api_team(engine, with_leader=False) + client = _make_client(engine) + + response = client.post( + "/api/enterprise/channels", + json={"tenant_id": "tenant_demo", "team_id": team_id, "channel": "wechat"}, + headers=_auth(users["admin"]), + ) + assert response.status_code == 400 + assert "TL" in response.json()["detail"] + + +def test_create_team_binding_rejects_cross_tenant_team() -> None: + engine = _test_engine() + users = _seed_api_users(engine) + team_id = _create_api_team(engine, tenant_id="tenant_other") + client = _make_client(engine) + + response = client.post( + "/api/enterprise/channels", + json={"tenant_id": "tenant_demo", "team_id": team_id, "channel": "wechat"}, + headers=_auth(users["admin"]), + ) + assert response.status_code == 404 + + +def test_create_team_binding_requires_tl_manager_permission() -> None: + engine = _test_engine() + users = _seed_api_users(engine) + team_id = _create_api_team(engine) + client = _make_client(engine) + + # 普通成员不是 TL 员工的管理者:复用员工绑定同款守卫,拒绝创建 + response = client.post( + "/api/enterprise/channels", + json={"tenant_id": "tenant_demo", "team_id": team_id, "channel": "wechat"}, + headers=_auth(users["member"]), + ) + assert response.status_code == 403 + + +def test_update_team_binding_rejects_agents_replacement() -> None: + engine = _test_engine() + users = _seed_api_users(engine) + team_id = _create_api_team(engine) + client = _make_client(engine) + + created = client.post( + "/api/enterprise/channels", + json={"tenant_id": "tenant_demo", "team_id": team_id, "channel": "wechat"}, + headers=_auth(users["admin"]), + ) + assert created.status_code == 200 + binding_id = created.json()["id"] + + response = client.put( + f"/api/enterprise/channels/{binding_id}?tenant_id=tenant_demo", + json={"agents": [{"agent_id": "agent_worker", "is_default": True}]}, + headers=_auth(users["admin"]), + ) + assert response.status_code == 400 + assert "团队绑定" in response.json()["detail"] + + +# ---------- SQLite 迁移 ---------- + + +def test_channel_binding_team_id_migration_is_idempotent(monkeypatch, tmp_path) -> None: + db_path = tmp_path / "migrate.db" + engine = create_engine(f"sqlite:///{db_path}") + with engine.begin() as conn: + # 老库 channel_bindings 无 team_id 列 + conn.execute( + text( + """ + CREATE TABLE channel_bindings ( + id VARCHAR PRIMARY KEY, + tenant_id VARCHAR, + agent_id VARCHAR, + channel VARCHAR, + status VARCHAR, + credentials_enc VARCHAR, + config_json JSON, + connected BOOLEAN, + created_by_user_id VARCHAR, + created_at DATETIME, + updated_at DATETIME + ) + """ + ) + ) + + monkeypatch.setattr(database, "database_url", f"sqlite:///{db_path}") + monkeypatch.setattr(database, "engine", engine) + + database._migrate_sqlite_skill_schema() + columns = {column["name"] for column in inspect(engine).get_columns("channel_bindings")} + assert "team_id" in columns + + # 重复执行不炸(列已存在) + database._migrate_sqlite_skill_schema() diff --git a/backend/tests/test_teams_api.py b/backend/tests/test_teams_api.py new file mode 100644 index 00000000..001cd55c --- /dev/null +++ b/backend/tests/test_teams_api.py @@ -0,0 +1,616 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine, select + +from app.api import teams as teams_api +from app.core import AgentLoop +from app.db.models import AgentProfile, Team, TeamTask, TeamWakeEvent, Tenant, User +from app.session.session_schema import ChatTurnResponse, SessionPublic +from app.teams import wakeup +from app.teams.schema import ( + ReviewOverrideRequest, + TeamCreateRequest, + TeamLeaderUpdateRequest, + TeamMemberAddRequest, + TeamTLChatRequest, + TeamUpdateRequest, +) +from app.teams.service import ( + TeamTaskTransitionError, + add_member, + apply_task_transition, + create_team, + parse_tl_review, + parse_tl_task_assignments, + set_leader, +) +from app.teams.wakeup import claim_wake_event, enqueue_wake_event, execute_wake_event + + +def _test_session() -> Session: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + return Session(engine) + + +def _admin_user() -> User: + return User( + id="user_admin", tenant_id="tenant_demo", username="ops", role="admin", password_hash="test" + ) + + +def _member_user() -> User: + return User( + id="user_member", + tenant_id="tenant_demo", + username="member", + role="member", + password_hash="test", + ) + + +def _seed_agents(db: Session) -> None: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.add(Tenant(id="tenant_other", name="Other")) + db.add(AgentProfile(id="agent_tl", tenant_id="tenant_demo", name="TL")) + db.add(AgentProfile(id="agent_worker", tenant_id="tenant_demo", name="Worker")) + db.add(AgentProfile(id="agent_worker2", tenant_id="tenant_demo", name="Worker2")) + db.add(AgentProfile(id="agent_outside", tenant_id="tenant_other", name="Outsider")) + db.commit() + + +def _seed_team(db: Session) -> Team: + _seed_agents(db) + team = create_team( + db, + tenant_id="tenant_demo", + name="增长团队", + description=None, + owner_user_id="user_admin", + ) + add_member(db, team, agent_id="agent_tl", role="leader") + add_member(db, team, agent_id="agent_worker") + return team + + +def _make_task(db: Session, team: Team, *, status: str = "pending") -> TeamTask: + task = TeamTask( + team_id=team.id, + tenant_id=team.tenant_id, + title="调研竞品", + description="输出调研报告", + status=status, + created_by_user_id="user_admin", + created_by_tl=True, + assignee_agent_id="agent_worker", + ) + db.add(task) + db.commit() + db.refresh(task) + return task + + +def _stub_start_wakeup(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """把异步唤醒改成同步收集,避免测试里真的起线程。""" + started: list[str] = [] + monkeypatch.setattr(wakeup, "start_wakeup_async", started.append) + monkeypatch.setattr(teams_api, "start_wakeup_async", started.append) + return started + + +# ---------- 团队与成员 CRUD ---------- + + +def test_team_crud_and_unique_name() -> None: + with _test_session() as db: + _seed_agents(db) + admin = _admin_user() + team = teams_api.create_team_endpoint( + TeamCreateRequest(tenant_id="tenant_demo", name="增长团队"), db, admin + ) + assert team.owner_user_id == "user_admin" + assert team.status == "active" + + with pytest.raises(HTTPException) as exc_info: + teams_api.create_team_endpoint( + TeamCreateRequest(tenant_id="tenant_demo", name="增长团队"), db, admin + ) + assert exc_info.value.status_code == 409 + + teams = teams_api.list_teams("tenant_demo", db, admin) + assert [item.name for item in teams] == ["增长团队"] + + detail = teams_api.get_team_endpoint(team.id, "tenant_demo", db, admin) + assert detail.id == team.id + + updated = teams_api.update_team_endpoint( + team.id, + TeamUpdateRequest(tenant_id="tenant_demo", description="负责增长"), + db, + admin, + ) + assert updated.description == "负责增长" + + assert teams_api.delete_team_endpoint(team.id, "tenant_demo", db, admin) == {"ok": True} + assert teams_api.list_teams("tenant_demo", db, admin) == [] + + +def test_delete_team_cascades_members_and_tasks() -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + enqueue_wake_event( + db, team=team, target_agent_id="agent_worker", + trigger_type="task_assigned", payload={"task_id": task.id}, + ) + db.commit() + assert teams_api.delete_team_endpoint(team.id, "tenant_demo", db, _admin_user()) == { + "ok": True + } + assert db.get(TeamTask, task.id) is None + assert db.exec(select(TeamWakeEvent)).all() == [] + + +def test_member_add_remove_and_constraints() -> None: + with _test_session() as db: + team = _seed_team(db) + admin = _admin_user() + + with pytest.raises(HTTPException) as exc_info: + teams_api.add_member_endpoint( + team.id, + TeamMemberAddRequest(tenant_id="tenant_demo", agent_id="agent_worker"), + db, + admin, + ) + assert exc_info.value.status_code == 409 + + with pytest.raises(HTTPException) as exc_info: + teams_api.add_member_endpoint( + team.id, + TeamMemberAddRequest(tenant_id="tenant_demo", agent_id="agent_outside"), + db, + admin, + ) + assert exc_info.value.status_code == 404 + + member = teams_api.add_member_endpoint( + team.id, + TeamMemberAddRequest(tenant_id="tenant_demo", agent_id="agent_worker2"), + db, + admin, + ) + assert member.role == "member" + assert member.agent_name == "Worker2" + + assert teams_api.remove_member_endpoint( + team.id, "agent_worker2", "tenant_demo", db, admin + ) == {"ok": True} + + +def test_leader_uniqueness_and_reassign() -> None: + with _test_session() as db: + team = _seed_team(db) + add_member(db, team, agent_id="agent_worker2") + admin = _admin_user() + + leader = teams_api.set_leader_endpoint( + team.id, + TeamLeaderUpdateRequest(tenant_id="tenant_demo", agent_id="agent_worker2"), + db, + admin, + ) + assert leader.role == "leader" + + detail = teams_api.get_team_endpoint(team.id, "tenant_demo", db, admin) + leaders = [item for item in detail.members if item.role == "leader"] + assert len(leaders) == 1 + assert leaders[0].agent_id == "agent_worker2" + old_tl = next(item for item in detail.members if item.agent_id == "agent_tl") + assert old_tl.role == "member" + + # 换任不存在的成员 -> 404 + with pytest.raises(HTTPException) as exc_info: + set_leader(db, team, "agent_outside") + assert exc_info.value.status_code == 404 + + +def test_manage_permission_owner_or_admin() -> None: + with _test_session() as db: + _seed_agents(db) + team = teams_api.create_team_endpoint( + TeamCreateRequest(tenant_id="tenant_demo", name="增长团队"), db, _member_user() + ) + # 非 owner 非 admin 不可管 + with pytest.raises(HTTPException) as exc_info: + teams_api.update_team_endpoint( + team.id, + TeamUpdateRequest(tenant_id="tenant_demo", description="x"), + db, + _admin_user_other(), + ) + assert exc_info.value.status_code == 403 + # owner(普通成员角色)可管 + updated = teams_api.update_team_endpoint( + team.id, + TeamUpdateRequest(tenant_id="tenant_demo", description="y"), + db, + _member_user(), + ) + assert updated.description == "y" + + +def _admin_user_other() -> User: + return User( + id="user_other", + tenant_id="tenant_demo", + username="other", + role="member", + password_hash="test", + ) + + +# ---------- 任务状态机 ---------- + + +def test_task_state_machine_transitions() -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + kwargs = {"actor_type": "agent", "actor_id": "agent_worker", "event_type": "test"} + + with pytest.raises(TeamTaskTransitionError): + apply_task_transition(db, task, "done", **kwargs) + # pending -> bidding 为任务池竞标入口(增量 3 激活) + apply_task_transition(db, task, "bidding", **kwargs) + apply_task_transition(db, task, "pending", **kwargs) + + apply_task_transition(db, task, "in_progress", **kwargs) + apply_task_transition(db, task, "review", **kwargs) + apply_task_transition(db, task, "rework", **kwargs) + apply_task_transition(db, task, "in_progress", **kwargs) + apply_task_transition(db, task, "review", **kwargs) + apply_task_transition(db, task, "done", **kwargs) + assert task.version == 8 + with pytest.raises(TeamTaskTransitionError): + apply_task_transition(db, task, "in_progress", **kwargs) + + +# ---------- TL 结构化输出解析 ---------- + + +def test_parse_tl_task_assignments_ok() -> None: + reply = ( + "好的,我来拆解。\n" + '```json\n{"team_tasks": [{"title": "调研", "description": "竞品分析", ' + '"assignee_agent_id": "agent_worker"}]}\n```' + ) + tasks = parse_tl_task_assignments(reply) + assert tasks == [ + {"title": "调研", "description": "竞品分析", "assignee_agent_id": "agent_worker"} + ] + + +def test_parse_tl_task_assignments_no_block_or_bad_json() -> None: + assert parse_tl_task_assignments("随便聊聊,没有代码块") == [] + assert parse_tl_task_assignments('```json\n{"team_tasks": [坏掉的\n```') == [] + # title 缺失的条目被跳过;assignee 缺省表示投入任务池竞标(增量 3) + reply = '```json\n{"team_tasks": [{"title": "", "assignee_agent_id": "a"}, {"title": "x"}]}\n```' + assert parse_tl_task_assignments(reply) == [{"title": "x"}] + + +def test_parse_tl_review() -> None: + reply = '验收完毕\n```json\n{"team_review": {"verdict": "rework", "comment": "重做"}}\n```' + assert parse_tl_review(reply) == {"verdict": "rework", "comment": "重做"} + assert parse_tl_review("没有块") is None + assert parse_tl_review('```json\n{"team_review": {"verdict": "unknown"}}\n```') is None + + +# ---------- TL 对话入口 ---------- + + +def test_tl_chat_creates_tasks_and_wakes(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + started = _stub_start_wakeup(monkeypatch) + + def fake_handle_turn(self, request): + assert request.interaction_mode == "team_tl" + # 团队上下文注入:花名册含成员 agent_id + assert "agent_worker" in request.message + reply = ( + "收到,派给 Worker。\n" + '```json\n{"team_tasks": [{"title": "竞品调研", ' + '"assignee_agent_id": "agent_worker"}, ' + '{"title": "外人任务", "assignee_agent_id": "agent_outside"}]}\n```' + ) + return ChatTurnResponse( + reply=reply, + session_id=request.session_id, + session_state=SessionPublic( + session_id=request.session_id, tenant_id=request.tenant_id + ), + ) + + monkeypatch.setattr(AgentLoop, "handle_turn", fake_handle_turn) + + response = teams_api.tl_chat_endpoint( + team.id, + TeamTLChatRequest(tenant_id="tenant_demo", message="帮我调研竞品"), + db, + _admin_user(), + ) + # 非成员 agent 的指派被跳过 + assert len(response.created_tasks) == 1 + task = response.created_tasks[0] + assert task.status == "pending" + assert task.assignee_agent_id == "agent_worker" + assert task.created_by_tl is True + assert "```json" not in response.reply + + wakes = db.exec(select(TeamWakeEvent)).all() + assert len(wakes) == 1 + assert wakes[0].trigger_type == "task_assigned" + assert wakes[0].status == "pending" + assert wakes[0].payload_json["task_id"] == task.id + assert started == [wakes[0].id] + + # 审计流水 + detail = teams_api.get_team_task(team.id, task.id, "tenant_demo", db, _admin_user()) + assert [item.event_type for item in detail.events] == ["task_created"] + + +def test_tl_chat_requires_leader() -> None: + with _test_session() as db: + _seed_agents(db) + team = create_team( + db, tenant_id="tenant_demo", name="无TL团队", + description=None, owner_user_id="user_admin", + ) + with pytest.raises(HTTPException) as exc_info: + teams_api.tl_chat_endpoint( + team.id, + TeamTLChatRequest(tenant_id="tenant_demo", message="hi"), + db, + _admin_user(), + ) + assert exc_info.value.status_code == 400 + + +# ---------- 唤醒链路:成员执行 + TL 验收 ---------- + + +def test_member_execution_report_then_tl_wake(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_worker", + trigger_type="task_assigned", payload={"task_id": task.id}, + ) + db.commit() + started = _stub_start_wakeup(monkeypatch) + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *args, **kwargs: "完成报告:已交付") + monkeypatch.setattr(wakeup, "_team_harness_outcome", lambda *args, **kw: "completed") + + assert claim_wake_event(db, wake.id) is True + execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + + db.refresh(task) + assert task.status == "review" + assert task.report_json["full_reply"] == "完成报告:已交付" + assert task.session_id is not None + + tl_wakes = db.exec( + select(TeamWakeEvent).where(TeamWakeEvent.trigger_type == "task_report") + ).all() + assert len(tl_wakes) == 1 + assert tl_wakes[0].target_agent_id == "agent_tl" + assert tl_wakes[0].status == "pending" + assert started == [tl_wakes[0].id] + assert db.get(TeamWakeEvent, wake.id).status == "done" + + +def test_tl_review_verdicts(monkeypatch: pytest.MonkeyPatch) -> None: + cases = [ + ("approve", "done", None), + ("escalate", "escalated", None), + ("rework", "rework", "task_rework"), + ] + for verdict, expected_status, expected_wake in cases: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team, status="review") + task.report_json = {"summary": "报告", "full_reply": "报告全文"} + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_tl", + trigger_type="task_report", payload={"task_id": task.id}, + ) + db.commit() + started = _stub_start_wakeup(monkeypatch) + reply = f'```json\n{{"team_review": {{"verdict": "{verdict}", "comment": "意见"}}}}\n```' + monkeypatch.setattr( + wakeup, "run_agent_turn", lambda *args, _reply=reply, **kw: _reply + ) + + assert claim_wake_event(db, wake.id) is True + execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + + db.refresh(task) + assert task.status == expected_status + assert task.review_json["verdict"] == verdict + assert task.review_json["comment"] == "意见" + if expected_wake: + rework_wakes = db.exec( + select(TeamWakeEvent).where(TeamWakeEvent.trigger_type == expected_wake) + ).all() + assert len(rework_wakes) == 1 + assert rework_wakes[0].target_agent_id == "agent_worker" + assert started == [rework_wakes[0].id] + else: + assert started == [] + + +def test_tl_review_without_verdict_keeps_review(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team, status="review") + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_tl", + trigger_type="task_report", payload={"task_id": task.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *args, **kw: "我还需要想想") + + assert claim_wake_event(db, wake.id) is True + execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + db.refresh(task) + assert task.status == "review" + assert db.get(TeamWakeEvent, wake.id).status == "done" + + +def test_failed_turn_escalates_task(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_worker", + trigger_type="task_assigned", payload={"task_id": task.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + + def boom(*args, **kwargs): + raise RuntimeError("LLM 不可用") + + monkeypatch.setattr(wakeup, "run_agent_turn", boom) + assert claim_wake_event(db, wake.id) is True + execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + + db.refresh(task) + assert task.status == "escalated" + failed = db.get(TeamWakeEvent, wake.id) + assert failed.status == "failed" + assert "LLM 不可用" in (failed.error or "") + + +# ---------- 人改判(HITL override) ---------- + + +def test_override_verdicts(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team, status="review") + started = _stub_start_wakeup(monkeypatch) + admin = _admin_user() + + result = teams_api.override_task_review( + team.id, task.id, + ReviewOverrideRequest(tenant_id="tenant_demo", verdict="approve", comment="通过"), + db, admin, + ) + assert result.status == "done" + assert result.review["overridden_by_user_id"] == "user_admin" + + # done 之后不可再改判 + with pytest.raises(HTTPException) as exc_info: + teams_api.override_task_review( + team.id, task.id, + ReviewOverrideRequest(tenant_id="tenant_demo", verdict="rework"), + db, admin, + ) + assert exc_info.value.status_code == 409 + + # pending 状态不可改判 + task2 = _make_task(db, team) + with pytest.raises(HTTPException) as exc_info: + teams_api.override_task_review( + team.id, task2.id, + ReviewOverrideRequest(tenant_id="tenant_demo", verdict="approve"), + db, admin, + ) + assert exc_info.value.status_code == 409 + + # rework:任务退回并唤醒成员 + task3 = _make_task(db, team, status="review") + result = teams_api.override_task_review( + team.id, task3.id, + ReviewOverrideRequest(tenant_id="tenant_demo", verdict="rework", comment="重做"), + db, admin, + ) + assert result.status == "rework" + rework_wakes = db.exec( + select(TeamWakeEvent).where(TeamWakeEvent.trigger_type == "task_rework") + ).all() + assert len(rework_wakes) == 1 + assert rework_wakes[0].payload_json["task_id"] == task3.id + assert started == [rework_wakes[0].id] + + # escalated 任务也可被人改判回 done + task4 = _make_task(db, team, status="escalated") + result = teams_api.override_task_review( + team.id, task4.id, + ReviewOverrideRequest(tenant_id="tenant_demo", verdict="approve"), + db, admin, + ) + assert result.status == "done" + + +def test_override_requires_manager() -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team, status="review") + with pytest.raises(HTTPException) as exc_info: + teams_api.override_task_review( + team.id, task.id, + ReviewOverrideRequest(tenant_id="tenant_demo", verdict="approve"), + db, _admin_user_other(), + ) + assert exc_info.value.status_code == 403 + + +# ---------- 唤醒事件原子认领 ---------- + + +def test_claim_wake_event_only_once() -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_worker", + trigger_type="task_assigned", payload={"task_id": task.id}, + ) + db.commit() + assert claim_wake_event(db, wake.id) is True + # 重复认领(并发/重试)只生效一次 + assert claim_wake_event(db, wake.id) is False + assert db.get(TeamWakeEvent, wake.id).status == "claimed" + + +# ---------- 任务列表 ---------- + + +def test_list_tasks_filter_by_status() -> None: + with _test_session() as db: + team = _seed_team(db) + _make_task(db, team) + _make_task(db, team, status="review") + admin = _admin_user() + + all_tasks = teams_api.list_team_tasks(team.id, "tenant_demo", None, db, admin) + assert len(all_tasks) == 2 + review_tasks = teams_api.list_team_tasks(team.id, "tenant_demo", "review", db, admin) + assert len(review_tasks) == 1 + assert review_tasks[0].status == "review" + + with pytest.raises(HTTPException) as exc_info: + teams_api.list_team_tasks(team.id, "tenant_demo", "nonsense", db, admin) + assert exc_info.value.status_code == 400 diff --git a/backend/tests/test_teams_bidding.py b/backend/tests/test_teams_bidding.py new file mode 100644 index 00000000..841445c6 --- /dev/null +++ b/backend/tests/test_teams_bidding.py @@ -0,0 +1,848 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from sqlmodel import Session, select +from test_teams_api import ( + _admin_user, + _admin_user_other, + _stub_start_wakeup, + _test_session, +) + +from app.api import teams as teams_api +from app.db.models import ( + AgentProfile, + Team, + TeamTask, + TeamTaskBid, + TeamTaskEvent, + TeamWakeEvent, + Tenant, +) +from app.teams import wakeup +from app.teams.schema import AwardOverrideRequest, TeamTaskCreateRequest +from app.teams.service import ( + add_member, + candidate_hp, + create_team, + parse_bid, + parse_bid_award, + parse_bid_scores, + select_bid_candidates, +) + + +def _seed_pool_team(db: Session, *, config: dict | None = None) -> Team: + """TL(也带匹配标签)+ 4 名能力标签各异的成员,用于候选选择与竞标流程。""" + db.add(Tenant(id="tenant_demo", name="Demo")) + db.add( + AgentProfile( + id="agent_tl", tenant_id="tenant_demo", name="TL", + metadata_json={"expertise_tags": ["调研", "竞品"]}, + ) + ) + db.add( + AgentProfile( + id="agent_a", tenant_id="tenant_demo", name="甲", + metadata_json={"expertise_tags": ["调研", "竞品"]}, + ) + ) + db.add( + AgentProfile( + id="agent_b", tenant_id="tenant_demo", name="乙", + metadata_json={"expertise_tags": ["调研"]}, + ) + ) + db.add( + AgentProfile( + id="agent_c", tenant_id="tenant_demo", name="丙", + metadata_json={"expertise_tags": ["定价"]}, + ) + ) + db.add(AgentProfile(id="agent_d", tenant_id="tenant_demo", name="丁")) + db.commit() + team = create_team( + db, + tenant_id="tenant_demo", + name="竞标团队", + description=None, + owner_user_id="user_admin", + config=config, + ) + add_member(db, team, agent_id="agent_tl", role="leader") + for agent_id in ("agent_a", "agent_b", "agent_c", "agent_d"): + add_member(db, team, agent_id=agent_id) + return team + + +def _make_pool_task( + db: Session, team: Team, *, title: str = "调研竞品定价", description: str | None = "输出调研报告" +) -> TeamTask: + task = TeamTask( + team_id=team.id, + tenant_id=team.tenant_id, + title=title, + description=description, + status="pending", + created_by_user_id="user_admin", + created_by_tl=True, + ) + db.add(task) + db.commit() + db.refresh(task) + return task + + +def _run_wake(db: Session, wake_id: str) -> TeamWakeEvent: + assert wakeup.claim_wake_event(db, wake_id) is True + event = db.get(TeamWakeEvent, wake_id) + wakeup.execute_wake_event(db, event) + return db.get(TeamWakeEvent, wake_id) + + +def _pending_wakes(db: Session, trigger_type: str) -> list[TeamWakeEvent]: + rows = db.exec( + select(TeamWakeEvent).where( + TeamWakeEvent.trigger_type == trigger_type, TeamWakeEvent.status == "pending" + ) + ).all() + return sorted(rows, key=lambda row: row.target_agent_id) + + +def _events(db: Session, task_id: str, event_type: str) -> list[TeamTaskEvent]: + return list( + db.exec( + select(TeamTaskEvent).where( + TeamTaskEvent.task_id == task_id, TeamTaskEvent.event_type == event_type + ) + ).all() + ) + + +def _bids(db: Session, task_id: str) -> list[TeamTaskBid]: + return list( + db.exec(select(TeamTaskBid).where(TeamTaskBid.task_id == task_id)).all() + ) + + +def _bid_reply(plan: str) -> str: + return f'竞标陈述\n```json\n{{"bid": {{"plan": "{plan}", "confidence": "high"}}}}\n```' + + +def _award_reply(winner: str) -> str: + return ( + "```json\n" + f'{{"bid_award": {{"winner_agent_id": "{winner}", ' + '"scores": {"agent_a": {"score": 8.5, "rationale": "理解到位"}, ' + '"agent_b": {"score": 7.0, "rationale": "方案一般"}}, ' + '"comment": "甲更匹配"}}\n```' + ) + + +def _score_reply(*agent_scores: tuple[str, float]) -> str: + inner = ", ".join( + f'"{agent_id}": {{"score": {score}, "rationale": "打分理由"}}' + for agent_id, score in agent_scores + ) + return f"```json\n{{\"bid_scores\": {{{inner}}}}}\n```" + + +# ---------- 结构化输出解析 ---------- + + +def test_parse_bid_block_and_fallback() -> None: + reply = '说明\n```json\n{"bid": {"plan": "先调研再写", "estimated_cost": "2h"}}\n```' + assert parse_bid(reply) == {"plan": "先调研再写", "estimated_cost": "2h"} + assert parse_bid("没有代码块") is None + assert parse_bid('```json\n{"bid": {"plan": ""}}\n```') is None + + +def test_parse_bid_award_requires_candidate_winner() -> None: + candidates = {"agent_a", "agent_b"} + reply = _award_reply("agent_a") + award = parse_bid_award(reply, candidates) + assert award is not None + assert award["winner_agent_id"] == "agent_a" + assert award["scores"]["agent_a"] == {"score": 8.5, "rationale": "理解到位"} + assert award["comment"] == "甲更匹配" + # winner 不在候选集 -> 视为未解析 + assert parse_bid_award(_award_reply("agent_outside"), candidates) is None + assert parse_bid_award("没有块", candidates) is None + + +def test_parse_bid_scores() -> None: + candidates = {"agent_a", "agent_b"} + scores = parse_bid_scores(_score_reply(("agent_a", 9.0), ("agent_b", 8.0)), candidates) + assert scores == { + "agent_a": {"score": 9.0, "rationale": "打分理由"}, + "agent_b": {"score": 8.0, "rationale": "打分理由"}, + } + # 无块/无合法分数 -> None(交由纠错重试) + assert parse_bid_scores("没有块", candidates) is None + assert parse_bid_scores(_score_reply(("agent_outside", 9.0)), candidates) is None + # 分数截断到 0-10 + clamped = parse_bid_scores(_score_reply(("agent_a", 12.0)), candidates) + assert clamped is not None + assert clamped["agent_a"]["score"] == 10.0 + + +# ---------- 血条(HP)计算 ---------- + + +def _scored_bid(agent_id: str, round_: int, score: float | None) -> TeamTaskBid: + return TeamTaskBid( + task_id="task_1", + team_id="team_1", + tenant_id="tenant_demo", + agent_id=agent_id, + round=round_, + kind="statement" if round_ == 1 else "rebuttal", + content="方案", + score=score, + ) + + +def test_candidate_hp_calculation() -> None: + bids = [ + _scored_bid("agent_a", 1, 10.0), # 满分不扣 + _scored_bid("agent_b", 1, 8.0), # 扣 6 + _scored_bid("agent_b", 2, 5.0), # 再扣 15,累计 79 + _scored_bid("agent_c", 1, 0.0), + _scored_bid("agent_c", 2, 0.0), + _scored_bid("agent_c", 3, 0.0), + _scored_bid("agent_c", 4, 0.0), # 扣 120,下限 0 + _scored_bid("agent_d", 1, None), # 未打分不计 + ] + hp = candidate_hp(bids) + assert hp["agent_a"] == 100 + assert hp["agent_b"] == 79 + assert hp["agent_c"] == 0 + assert "agent_d" not in hp + + +# ---------- 候选选择 ---------- + + +def test_select_candidates_tag_ranking_excludes_tl_and_caps3() -> None: + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team) # 标题:调研竞品定价 + candidates = select_bid_candidates(db, team, task) + # agent_a(调研+竞品=2) 第一;调研/定价 各 1 分按 agent_id 序;TL 排除;封顶 3 + assert candidates == ["agent_a", "agent_b", "agent_c"] + assert "agent_tl" not in candidates + + +def test_select_candidates_zero_match_fallback() -> None: + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="完全无关的xyz任务", description=None) + # 全员 0 分 -> 除 TL 外全部成员,仍封顶 3 + assert select_bid_candidates(db, team, task) == ["agent_a", "agent_b", "agent_c"] + + +def test_start_bidding_without_candidates_escalates() -> None: + with _test_session() as db: + db.add(Tenant(id="tenant_demo", name="Demo")) + db.add(AgentProfile(id="agent_tl", tenant_id="tenant_demo", name="TL")) + db.commit() + team = create_team( + db, tenant_id="tenant_demo", name="光杆团队", + description=None, owner_user_id="user_admin", + ) + add_member(db, team, agent_id="agent_tl", role="leader") + task = _make_pool_task(db, team) + + wakeup.start_bidding(db, team, task) + + db.refresh(task) + assert task.status == "escalated" + escalated = _events(db, task.id, "task_escalated") + assert len(escalated) == 1 + assert "无候选" in escalated[0].payload_json["reason"] + assert db.exec(select(TeamWakeEvent)).all() == [] + + +def test_start_bidding_enqueues_round1_wakes(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="调研") # 候选: agent_a, agent_b + started = _stub_start_wakeup(monkeypatch) + + wakeup.start_bidding(db, team, task) + + db.refresh(task) + assert task.status == "bidding" + started_events = _events(db, task.id, "task_bidding_started") + assert started_events[0].payload_json["candidate_agent_ids"] == ["agent_a", "agent_b"] + wakes = _pending_wakes(db, "bid_request") + assert [wake.target_agent_id for wake in wakes] == ["agent_a", "agent_b"] + assert all(wake.payload_json["round"] == 1 for wake in wakes) + assert started == [wake.id for wake in wakes] + + +# ---------- 陈述/反驳/裁决全流程 ---------- + + +def _reach_judge( + db: Session, + team: Team, + task: TeamTask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """快进(辩论关闭):两名候选完成陈述后,bid_judge(award)唤醒处于 pending。""" + _stub_start_wakeup(monkeypatch) + replies = {"agent_a": _bid_reply("甲的方案"), "agent_b": "乙的纯文本方案(无代码块)"} + monkeypatch.setattr( + wakeup, "run_agent_turn", lambda *args, **kw: replies[kw["agent"].id] + ) + wakeup.start_bidding(db, team, task) + for wake in _pending_wakes(db, "bid_request"): + assert wake.payload_json["round"] == 1 + _run_wake(db, wake.id) + + +def test_bidding_full_flow_three_rounds_hp(monkeypatch: pytest.MonkeyPatch) -> None: + """默认 3 轮血条赛制:陈述 -> 打分 -> 反驳 -> 打分 -> 反驳 -> 裁决。""" + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="调研") + started = _stub_start_wakeup(monkeypatch) + messages: dict[str, list[str]] = {"agent_a": [], "agent_b": [], "agent_tl": []} + replies = {"agent_a": _bid_reply("甲的方案"), "agent_b": "乙的纯文本方案(无代码块)"} + + def fake_turn(*args, **kw): + agent_id = kw["agent"].id + messages[agent_id].append(kw["message"]) + if agent_id == "agent_tl": + if "bid_scores" in kw["message"]: + return _score_reply(("agent_a", 9.0), ("agent_b", 8.0)) + return _award_reply("agent_a") + return replies[agent_id] + + monkeypatch.setattr(wakeup, "run_agent_turn", fake_turn) + + wakeup.start_bidding(db, team, task) + # 陈述轮:第一个候选完成后不推进,第二个完成后入队第 1 轮打分 + first, second = _pending_wakes(db, "bid_request") + _run_wake(db, first.id) + assert _pending_wakes(db, "bid_judge") == [] + assert len(_pending_wakes(db, "bid_request")) == 1 + _run_wake(db, second.id) + + db.refresh(task) + assert task.status == "bidding" + bids = _bids(db, task.id) + assert {(bid.agent_id, bid.round, bid.kind) for bid in bids} == { + ("agent_a", 1, "statement"), + ("agent_b", 1, "statement"), + } + contents = {bid.agent_id: bid.content for bid in bids} + assert contents["agent_a"] == "甲的方案" # 有 bid 块用 plan + assert contents["agent_b"] == "乙的纯文本方案(无代码块)" # 无块用整条回复 + assert len(_events(db, task.id, "bid_submitted")) == 2 + + # 第 1 轮打分:分数写回该轮 bid + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + assert judge[0].target_agent_id == "agent_tl" + assert judge[0].payload_json["mode"] == "score" + assert judge[0].payload_json["round"] == 1 + _run_wake(db, judge[0].id) + scores = {bid.agent_id: bid.score for bid in _bids(db, task.id)} + assert scores == {"agent_a": 9.0, "agent_b": 8.0} + scored_events = _events(db, task.id, "bid_scored") + assert len(scored_events) == 1 + assert scored_events[0].payload_json["round"] == 1 + + # 第 2 轮反驳:消息附各候选血条与上一轮其他候选的发言 + rebuttal_wakes = _pending_wakes(db, "bid_request") + assert len(rebuttal_wakes) == 2 + assert all(wake.payload_json["round"] == 2 for wake in rebuttal_wakes) + for wake in rebuttal_wakes: + _run_wake(db, wake.id) + assert any("HP=97" in msg for msg in messages["agent_a"]) # 甲 9 分 -> 97 + assert any("HP=94" in msg for msg in messages["agent_a"]) # 乙 8 分 -> 94 + assert any("乙的纯文本方案" in msg for msg in messages["agent_a"]) + assert any("反驳轮" in msg for msg in messages["agent_a"]) + bids = _bids(db, task.id) + assert len([bid for bid in bids if bid.round == 2 and bid.kind == "rebuttal"]) == 2 + + # 第 2 轮打分 + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + assert judge[0].payload_json["mode"] == "score" + assert judge[0].payload_json["round"] == 2 + _run_wake(db, judge[0].id) + + # 第 3 轮反驳(末轮) + round3_wakes = _pending_wakes(db, "bid_request") + assert len(round3_wakes) == 2 + assert all(wake.payload_json["round"] == 3 for wake in round3_wakes) + for wake in round3_wakes: + _run_wake(db, wake.id) + bids = _bids(db, task.id) + assert len([bid for bid in bids if bid.round == 3 and bid.kind == "rebuttal"]) == 2 + + # 末轮已齐:直接裁决(不再打分) + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + assert judge[0].payload_json["mode"] == "award" + _run_wake(db, judge[0].id) + + db.refresh(task) + assert task.status == "pending" + assert task.assignee_agent_id == "agent_a" + awarded = _events(db, task.id, "task_awarded") + assert awarded[0].payload_json["winner_agent_id"] == "agent_a" + assert awarded[0].payload_json["comment"] == "甲更匹配" + # 前两轮分数保留 score 模式打分;末轮 bid 由裁决分数补写 + round_scores = {(bid.agent_id, bid.round): bid.score for bid in _bids(db, task.id)} + assert round_scores[("agent_a", 1)] == 9.0 + assert round_scores[("agent_a", 2)] == 9.0 + assert round_scores[("agent_a", 3)] == 8.5 + assert round_scores[("agent_b", 3)] == 7.0 + # 裁决消息包含候选 agent_id、陈述与血条 + judge_msg = messages["agent_tl"][-1] + assert "agent_id=agent_a" in judge_msg and "甲的方案" in judge_msg + assert "HP=" in judge_msg + # 中标者走增量 1 的 task_assigned 链路 + assigned_wakes = _pending_wakes(db, "task_assigned") + assert len(assigned_wakes) == 1 + assert assigned_wakes[0].target_agent_id == "agent_a" + assert started[-1] == assigned_wakes[0].id + + # 任务详情带竞标记录,按 round/created_at 排序 + detail = teams_api.get_team_task(team.id, task.id, "tenant_demo", db, _admin_user()) + assert [(bid.round, bid.kind) for bid in detail.bids] == [ + (1, "statement"), + (1, "statement"), + (2, "rebuttal"), + (2, "rebuttal"), + (3, "rebuttal"), + (3, "rebuttal"), + ] + names = {bid.agent_id: bid.agent_name for bid in detail.bids} + assert names["agent_a"] == "甲" + assert detail.bids[0].score == 9.0 + assert detail.bids[0].score_rationale == "打分理由" + + +def test_bidding_elimination_skips_to_award(monkeypatch: pytest.MonkeyPatch) -> None: + """血条归零淘汰:5 轮配置下乙方四轮 0 分 HP 归零,审计淘汰并提前进裁决。""" + with _test_session() as db: + team = _seed_pool_team(db, config={"bid_rebuttal_rounds": 5}) + task = _make_pool_task(db, team, title="调研") + _stub_start_wakeup(monkeypatch) + + def fake_turn(*args, **kw): + agent_id = kw["agent"].id + if agent_id == "agent_tl": + if "bid_scores" in kw["message"]: + return _score_reply(("agent_a", 10.0), ("agent_b", 0.0)) + return _award_reply("agent_a") + return _bid_reply(f"{agent_id}的方案") + + monkeypatch.setattr(wakeup, "run_agent_turn", fake_turn) + + wakeup.start_bidding(db, team, task) + for wake in _pending_wakes(db, "bid_request"): + _run_wake(db, wake.id) + # 第 1-4 轮:打分后乙 HP 70 -> 40 -> 10 -> 0 + for expected_round in (1, 2, 3, 4): + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + assert judge[0].payload_json["mode"] == "score" + assert judge[0].payload_json["round"] == expected_round + _run_wake(db, judge[0].id) + if expected_round < 4: + wakes = _pending_wakes(db, "bid_request") + assert len(wakes) == 2 + assert all(wake.payload_json["round"] == expected_round + 1 for wake in wakes) + for wake in wakes: + _run_wake(db, wake.id) + + # 乙淘汰:审计 bid_eliminated,不再入队第 5 轮,直接进裁决 + eliminated = _events(db, task.id, "bid_eliminated") + assert [row.actor_id for row in eliminated] == ["agent_b"] + assert eliminated[0].payload_json["hp"] == 0 + assert _pending_wakes(db, "bid_request") == [] + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + assert judge[0].payload_json["mode"] == "award" + _run_wake(db, judge[0].id) + + db.refresh(task) + assert task.status == "pending" + assert task.assignee_agent_id == "agent_a" + # 乙的 bid 保留分数,HP 由前端从 bids 计算 + assert candidate_hp(_bids(db, task.id))["agent_b"] == 0 + + +def test_bid_score_fallback_five_points(monkeypatch: pytest.MonkeyPatch) -> None: + """打分解析失败(含纠错重试)兜底 5 分并审计,不阻塞竞标流程。""" + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="调研") + _stub_start_wakeup(monkeypatch) + monkeypatch.setattr(wakeup, "collect_turn_reply_fragments", lambda *a, **kw: []) + + def fake_turn(*args, **kw): + if kw["agent"].id == "agent_tl": + return "我打不了分" # 首轮与纠错轮都无打分块 + return _bid_reply(f"{kw['agent'].id}的方案") + + monkeypatch.setattr(wakeup, "run_agent_turn", fake_turn) + + wakeup.start_bidding(db, team, task) + for wake in _pending_wakes(db, "bid_request"): + _run_wake(db, wake.id) + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + _run_wake(db, judge[0].id) + + # 兜底:该轮全员 5 分,审计 fallback 与 unparsed,流程推进到第 2 轮 + scores = {bid.agent_id: bid.score for bid in _bids(db, task.id)} + assert scores == {"agent_a": 5.0, "agent_b": 5.0} + assert len(_events(db, task.id, "bid_score_fallback")) == 1 + assert len(_events(db, task.id, "bid_score_unparsed")) == 1 + db.refresh(task) + assert task.status == "bidding" + wakes = _pending_wakes(db, "bid_request") + assert len(wakes) == 2 + assert all(wake.payload_json["round"] == 2 for wake in wakes) + + +def test_bidding_config_one_statement_then_award(monkeypatch: pytest.MonkeyPatch) -> None: + """config=1 兼容旧行为:陈述后直接裁决(无打分、无反驳轮)。""" + with _test_session() as db: + team = _seed_pool_team(db, config={"bid_rebuttal_rounds": 1}) + task = _make_pool_task(db, team, title="调研") + _reach_judge(db, team, task, monkeypatch) + + assert all(bid.kind == "statement" for bid in _bids(db, task.id)) + assert _pending_wakes(db, "bid_request") == [] + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + assert judge[0].payload_json["mode"] == "award" + + +def test_bidding_skips_rebuttal_when_config_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db, config={"bid_rebuttal_rounds": 0}) + task = _make_pool_task(db, team, title="调研") + _reach_judge(db, team, task, monkeypatch) + + assert all(bid.kind == "statement" for bid in _bids(db, task.id)) + judge_wakes = _pending_wakes(db, "bid_judge") + assert len(judge_wakes) == 1 + + +def test_bidding_partial_failure_advances_with_material( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="调研") + started = _stub_start_wakeup(monkeypatch) + + def fake_turn(*args, **kw): + if kw["agent"].id == "agent_a": + raise RuntimeError("LLM 不可用") + return _bid_reply("乙的方案") + + monkeypatch.setattr(wakeup, "run_agent_turn", fake_turn) + + wakeup.start_bidding(db, team, task) + wakes = _pending_wakes(db, "bid_request") + failed_wake = _run_wake(db, wakes[0].id) # agent_a 失败 + assert failed_wake.status == "failed" + db.refresh(task) + # 候选失败不升级任务(区别于任务执行失败) + assert task.status == "bidding" + failed_events = _events(db, task.id, "bid_failed") + assert len(failed_events) == 1 + assert failed_events[0].actor_id == "agent_a" + assert _events(db, task.id, "task_escalated") == [] + + _run_wake(db, wakes[1].id) # agent_b 成功 -> 有效陈述 1 <2,直接裁决 + db.refresh(task) + assert task.status == "bidding" + assert _pending_wakes(db, "bid_request") == [] + judge_wakes = _pending_wakes(db, "bid_judge") + assert len(judge_wakes) == 1 + assert started[-1] == judge_wakes[0].id + + +def test_bidding_no_valid_statements_escalates(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="调研") + _stub_start_wakeup(monkeypatch) + + def boom(*args, **kw): + raise RuntimeError("LLM 不可用") + + monkeypatch.setattr(wakeup, "run_agent_turn", boom) + wakeup.start_bidding(db, team, task) + for wake in _pending_wakes(db, "bid_request"): + _run_wake(db, wake.id) + + db.refresh(task) + assert task.status == "escalated" + escalated = _events(db, task.id, "task_escalated") + assert "无人应标" in escalated[0].payload_json["reason"] + assert _pending_wakes(db, "bid_judge") == [] + + +# ---------- TL 裁决解析路径 ---------- + + +def test_bid_judge_parses_award_from_fragment(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db, config={"bid_rebuttal_rounds": 0}) + task = _make_pool_task(db, team, title="调研") + _reach_judge(db, team, task, monkeypatch) + # 最终回复被改写丢块,裁决块只存在于 frame 级 reply_fragment + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *args, **kw: "我选甲(已改写)") + monkeypatch.setattr( + wakeup, "collect_turn_reply_fragments", + lambda *args, **kw: [_award_reply("agent_a")], + ) + + _run_wake(db, _pending_wakes(db, "bid_judge")[0].id) + + db.refresh(task) + assert task.status == "pending" + assert task.assignee_agent_id == "agent_a" + + +def test_bid_judge_repair_retry_then_success(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db, config={"bid_rebuttal_rounds": 0}) + task = _make_pool_task(db, team, title="调研") + _reach_judge(db, team, task, monkeypatch) + monkeypatch.setattr(wakeup, "collect_turn_reply_fragments", lambda *a, **kw: []) + tl_replies = iter(["我还要想想", _award_reply("agent_b")]) + monkeypatch.setattr( + wakeup, "run_agent_turn", lambda *args, **kw: next(tl_replies) + ) + + _run_wake(db, _pending_wakes(db, "bid_judge")[0].id) + + db.refresh(task) + assert task.status == "pending" + assert task.assignee_agent_id == "agent_b" + assert len(_events(db, task.id, "bid_award_unparsed")) == 1 + + +def test_bid_judge_invalid_winner_escalates(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db, config={"bid_rebuttal_rounds": 0}) + task = _make_pool_task(db, team, title="调研") + _reach_judge(db, team, task, monkeypatch) + monkeypatch.setattr(wakeup, "collect_turn_reply_fragments", lambda *a, **kw: []) + # 首轮与纠错轮都给出非候选 winner + monkeypatch.setattr( + wakeup, "run_agent_turn", lambda *args, **kw: _award_reply("agent_outside") + ) + + _run_wake(db, _pending_wakes(db, "bid_judge")[0].id) + + db.refresh(task) + assert task.status == "escalated" + escalated = _events(db, task.id, "task_escalated") + assert "裁决失败" in escalated[0].payload_json["reason"] + assert task.assignee_agent_id is None + + +# ---------- 人推翻判罚 ---------- + + +def test_award_override_during_bidding_and_late_wakes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="调研") + started = _stub_start_wakeup(monkeypatch) + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *a, **kw: _bid_reply("方案")) + wakeup.start_bidding(db, team, task) + + result = teams_api.override_task_award( + team.id, task.id, + AwardOverrideRequest( + tenant_id="tenant_demo", agent_id="agent_c", comment="人指定丙" + ), + db, _admin_user(), + ) + assert result.status == "pending" + assert result.assignee_agent_id == "agent_c" + overridden = _events(db, task.id, "award_overridden") + assert overridden[0].actor_type == "user" + assert overridden[0].payload_json["previous_assignee_agent_id"] is None + assigned_wakes = _pending_wakes(db, "task_assigned") + assert [wake.target_agent_id for wake in assigned_wakes] == ["agent_c"] + assert started[-1] == assigned_wakes[0].id + + # 迟到的竞标唤醒:任务已非 bidding,落地即跳过且不写 bid + late_bid_wake = _pending_wakes(db, "bid_request")[0] + finished = _run_wake(db, late_bid_wake.id) + assert finished.status == "done" + assert _bids(db, task.id) == [] + skipped = _events(db, task.id, "bid_skipped") + assert skipped[-1].payload_json["task_status"] == "pending" + + # 迟到的裁决唤醒同样跳过 + late_judge = wakeup.enqueue_wake_event( + db, team=team, target_agent_id="agent_tl", + trigger_type="bid_judge", payload={"task_id": task.id}, + ) + db.commit() + _run_wake(db, late_judge.id) + db.refresh(task) + assert task.status == "pending" + assert task.assignee_agent_id == "agent_c" + assert len(_events(db, task.id, "bid_skipped")) == 2 + + +def test_award_override_pending_task_and_guards(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + started = _stub_start_wakeup(monkeypatch) + admin = _admin_user() + # 人直派的 pending 任务(执行开始前)也可改派 + created = teams_api.create_team_task_endpoint( + team.id, + TeamTaskCreateRequest( + tenant_id="tenant_demo", title="直派任务", assignee_agent_id="agent_a" + ), + db, admin, + ) + result = teams_api.override_task_award( + team.id, created.id, + AwardOverrideRequest(tenant_id="tenant_demo", agent_id="agent_b"), + db, admin, + ) + assert result.status == "pending" + assert result.assignee_agent_id == "agent_b" + overridden = _events(db, created.id, "award_overridden") + assert overridden[0].payload_json["previous_assignee_agent_id"] == "agent_a" + assigned_wakes = _pending_wakes(db, "task_assigned") + assert [wake.target_agent_id for wake in assigned_wakes] == ["agent_a", "agent_b"] + assert started[-1] == assigned_wakes[1].id + + # 非成员不能中标 + with pytest.raises(HTTPException) as exc_info: + teams_api.override_task_award( + team.id, created.id, + AwardOverrideRequest(tenant_id="tenant_demo", agent_id="agent_outside"), + db, admin, + ) + assert exc_info.value.status_code == 404 + + # 非 owner/admin 不能改判 + with pytest.raises(HTTPException) as exc_info: + teams_api.override_task_award( + team.id, created.id, + AwardOverrideRequest(tenant_id="tenant_demo", agent_id="agent_a"), + db, _admin_user_other(), + ) + assert exc_info.value.status_code == 403 + + # 执行中/验收中的任务不可推翻判罚 + running = TeamTask( + team_id=team.id, tenant_id=team.tenant_id, title="执行中", + status="in_progress", assignee_agent_id="agent_a", + ) + db.add(running) + db.commit() + with pytest.raises(HTTPException) as exc_info: + teams_api.override_task_award( + team.id, running.id, + AwardOverrideRequest(tenant_id="tenant_demo", agent_id="agent_b"), + db, admin, + ) + assert exc_info.value.status_code == 409 + + +# ---------- 人建任务端点 ---------- + + +def test_create_task_endpoint_assign_and_pool(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + started = _stub_start_wakeup(monkeypatch) + admin = _admin_user() + + # 指定 assignee -> pending + task_assigned 唤醒(与 TL 直派同路) + direct = teams_api.create_team_task_endpoint( + team.id, + TeamTaskCreateRequest( + tenant_id="tenant_demo", title="直派任务", + description="直接执行", priority="high", assignee_agent_id="agent_a", + ), + db, admin, + ) + assert direct.status == "pending" + assert direct.assignee_agent_id == "agent_a" + assert direct.priority == "high" + assert direct.created_by_tl is False + created_events = _events(db, direct.id, "task_created") + assert created_events[0].actor_type == "user" + wakes = _pending_wakes(db, "task_assigned") + assert [wake.target_agent_id for wake in wakes] == ["agent_a"] + assert started == [wakes[0].id] + + # 省略 assignee -> 投池竞标 + pooled = teams_api.create_team_task_endpoint( + team.id, + TeamTaskCreateRequest(tenant_id="tenant_demo", title="调研"), + db, admin, + ) + assert pooled.status == "bidding" + assert pooled.assignee_agent_id is None + bid_wakes = _pending_wakes(db, "bid_request") + assert [wake.target_agent_id for wake in bid_wakes] == ["agent_a", "agent_b"] + + # 权限与非成员校验 + with pytest.raises(HTTPException) as exc_info: + teams_api.create_team_task_endpoint( + team.id, + TeamTaskCreateRequest(tenant_id="tenant_demo", title="x"), + db, _admin_user_other(), + ) + assert exc_info.value.status_code == 403 + with pytest.raises(HTTPException) as exc_info: + teams_api.create_team_task_endpoint( + team.id, + TeamTaskCreateRequest( + tenant_id="tenant_demo", title="x", assignee_agent_id="agent_outside" + ), + db, admin, + ) + assert exc_info.value.status_code == 404 + with pytest.raises(HTTPException) as exc_info: + teams_api.create_team_task_endpoint( + team.id, + TeamTaskCreateRequest(tenant_id="tenant_demo", title=" "), + db, admin, + ) + assert exc_info.value.status_code == 400 + + +def test_list_tasks_filter_bidding() -> None: + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team) + task.status = "bidding" + db.add(task) + db.commit() + _make_pool_task(db, team, title="另一个任务") # pending + + admin = _admin_user() + bidding = teams_api.list_team_tasks(team.id, "tenant_demo", "bidding", db, admin) + assert len(bidding) == 1 + assert bidding[0].status == "bidding" + assert bidding[0].bids == [] # 列表视图不带竞标记录 diff --git a/backend/tests/test_teams_blackboard.py b/backend/tests/test_teams_blackboard.py new file mode 100644 index 00000000..36f52c7a --- /dev/null +++ b/backend/tests/test_teams_blackboard.py @@ -0,0 +1,530 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from sqlmodel import select +from test_teams_api import ( + _admin_user, + _admin_user_other, + _make_task, + _seed_team, + _stub_start_wakeup, + _test_session, +) + +from app.api import teams as teams_api +from app.db.models import TeamBlackboardEntry, TeamTaskEvent, TeamWakeEvent +from app.teams import wakeup +from app.teams.schema import ( + TeamBlackboardEntryArchiveRequest, + TeamBlackboardEntryCreateRequest, + TeamBlackboardEntryUpdateRequest, +) +from app.teams.service import ( + blackboard_context_lines, + parse_blackboard_suggestions, + parse_tl_review, + write_blackboard_entries, +) +from app.teams.wakeup import ( + build_member_task_message, + build_tl_chat_message, + build_tl_review_message, +) + + +def _entries(db, team, status="active"): + return list( + db.exec( + select(TeamBlackboardEntry).where( + TeamBlackboardEntry.team_id == team.id, + TeamBlackboardEntry.status == status, + ) + ).all() + ) + + +# ---------- 写入流水线 ---------- + + +def test_pipeline_normalize_and_batch_dedup() -> None: + with _test_session() as db: + team = _seed_team(db) + written, skipped = write_blackboard_entries( + db, + team=team, + entries=[ + {"content": " 定价页 转化率 3.2% ", "tags": [" Pricing ", "PRICING", "数据"]}, + {"content": "定价页 转化率 3.2%"}, # 规范化后与首条相同 -> 批次内去重 + {"content": " "}, # 空内容丢弃 + "非字典条目", + ], + source_type="human", + ) + db.commit() + assert len(written) == 1 + assert written[0].content == "定价页 转化率 3.2%" + assert written[0].tags_json == ["pricing", "数据"] + assert len(skipped) == 3 + + +def test_pipeline_substring_skip_and_superset_merge() -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + write_blackboard_entries( + db, + team=team, + entries=[{"content": "竞品 A 定价 99 元", "tags": ["pricing"]}], + source_type="human", + ) + db.commit() + + # 新内容是既有条目的子串 -> 不新增 + written, skipped = write_blackboard_entries( + db, team=team, entries=[{"content": "竞品 A 定价"}], source_type="human" + ) + assert written == [] + assert len(skipped) == 1 + assert len(_entries(db, team)) == 1 + + # 新内容是既有条目的超集 -> 合并更新既有条目(黑板是活文档) + written, skipped = write_blackboard_entries( + db, + team=team, + entries=[{"content": "竞品 A 定价 99 元,含 20 席", "tags": ["竞品"]}], + source_type="member", + source_agent_id="agent_worker", + source_task_id=task.id, + ) + db.commit() + assert skipped == [] + assert len(written) == 1 + rows = _entries(db, team) + assert len(rows) == 1 + entry = rows[0] + assert entry.id == written[0].id + assert entry.content == "竞品 A 定价 99 元,含 20 席" + assert entry.tags_json == ["pricing", "竞品"] + assert entry.source_type == "human" # 合并更新不改来源 + + +def test_pipeline_citation_with_task_title() -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + written, _ = write_blackboard_entries( + db, + team=team, + entries=[{"content": "关键结论"}], + source_type="member", + source_agent_id="agent_worker", + source_task_id=task.id, + ) + db.commit() + assert written[0].citation_json == {"task_id": task.id, "task_title": "调研竞品"} + assert written[0].source_agent_id == "agent_worker" + assert written[0].source_task_id == task.id + + # 无任务来源时 citation 为空 + written2, _ = write_blackboard_entries( + db, team=team, entries=[{"content": "另一条"}], source_type="human" + ) + assert written2[0].citation_json == {} + + +def test_pipeline_invalid_source_type() -> None: + with _test_session() as db: + team = _seed_team(db) + with pytest.raises(ValueError): + write_blackboard_entries( + db, team=team, entries=[{"content": "x"}], source_type="robot" + ) + + +# ---------- 解析 ---------- + + +def test_parse_blackboard_suggestions() -> None: + reply = ( + "报告正文\n```json\n" + '{"blackboard_suggestions": [{"content": "结论一", "tags": ["A"]}, ' + '{"content": ""}, {"content": "结论二"}]}\n```' + ) + assert parse_blackboard_suggestions(reply) == [ + {"content": "结论一", "tags": ["A"]}, + {"content": "结论二"}, + ] + assert parse_blackboard_suggestions("没有块") == [] + + +def test_parse_tl_review_with_blackboard_writes() -> None: + reply = ( + "```json\n{\"team_review\": {\"verdict\": \"approve\", \"comment\": \"好\", " + '"blackboard_writes": [{"content": "结论", "tags": ["a"]}]}}\n```' + ) + result = parse_tl_review(reply) + assert result is not None + assert result["verdict"] == "approve" + assert result["blackboard_writes"] == [{"content": "结论", "tags": ["a"]}] + # 不带 blackboard_writes 时保持增量 1 的返回形状 + plain = parse_tl_review('```json\n{"team_review": {"verdict": "rework"}}\n```') + assert plain == {"verdict": "rework", "comment": ""} + + +# ---------- 成员建议暂存 + TL 裁决写入 ---------- + + +def test_member_report_stores_suggestions_from_reply(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + wake = wakeup.enqueue_wake_event( + db, team=team, target_agent_id="agent_worker", + trigger_type="task_assigned", payload={"task_id": task.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + reply = ( + "完成报告:已交付\n```json\n" + '{"blackboard_suggestions": [{"content": "竞品 A 定价 99 元", "tags": ["pricing"]}]}\n```' + ) + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *args, **kw: reply) + monkeypatch.setattr(wakeup, "_team_harness_outcome", lambda *args, **kw: "completed") + + assert wakeup.claim_wake_event(db, wake.id) is True + wakeup.execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + + db.refresh(task) + assert task.status == "review" + assert task.report_json["blackboard_suggestions"] == [ + {"content": "竞品 A 定价 99 元", "tags": ["pricing"]} + ] + # 建议只是暂存,不直接写黑板 + assert _entries(db, team) == [] + + +def test_member_report_suggestions_from_fragments(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + wake = wakeup.enqueue_wake_event( + db, team=team, target_agent_id="agent_worker", + trigger_type="task_assigned", payload={"task_id": task.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + # 最终回复被改写丢块,JSON 块只存在于 frame 级 reply_fragment + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *args, **kw: "完成报告(已改写)") + fragment = ( + '```json\n{"blackboard_suggestions": [{"content": "来自 fragment 的结论"}]}\n```' + ) + monkeypatch.setattr( + wakeup, "collect_turn_reply_fragments", lambda *args, **kw: [fragment] + ) + monkeypatch.setattr(wakeup, "_team_harness_outcome", lambda *args, **kw: "completed") + + assert wakeup.claim_wake_event(db, wake.id) is True + wakeup.execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + + db.refresh(task) + assert task.report_json["blackboard_suggestions"] == [ + {"content": "来自 fragment 的结论"} + ] + + +def test_tl_review_adjudicates_blackboard_writes(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team, status="review") + task.report_json = { + "full_reply": "报告全文", + "blackboard_suggestions": [ + {"content": "竞品 A 定价 99 元", "tags": ["pricing"]}, + {"content": "不值得记的草稿"}, + ], + } + wake = wakeup.enqueue_wake_event( + db, team=team, target_agent_id="agent_tl", + trigger_type="task_report", payload={"task_id": task.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + # TL 只认可第一条(并改了措辞),第二条未写入即视为拒绝 + reply = ( + "```json\n{\"team_review\": {\"verdict\": \"approve\", \"comment\": \"通过\", " + '"blackboard_writes": [{"content": "竞品 A 定价 99 元/月", "tags": ["pricing"]}]}}\n```' + ) + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *args, **kw: reply) + + assert wakeup.claim_wake_event(db, wake.id) is True + wakeup.execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + + db.refresh(task) + assert task.status == "done" + rows = _entries(db, team) + assert len(rows) == 1 + entry = rows[0] + assert entry.content == "竞品 A 定价 99 元/月" + assert entry.tags_json == ["pricing"] + assert entry.source_type == "member" + assert entry.source_agent_id == "agent_worker" + assert entry.source_task_id == task.id + assert entry.citation_json["task_title"] == "调研竞品" + + events = list( + db.exec( + select(TeamTaskEvent).where(TeamTaskEvent.event_type == "blackboard_written") + ).all() + ) + assert len(events) == 1 + assert events[0].payload_json["written"] == 1 + assert events[0].payload_json["entry_ids"] == [entry.id] + + +def test_tl_review_without_writes_keeps_blackboard_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team, status="review") + task.report_json = { + "full_reply": "报告全文", + "blackboard_suggestions": [{"content": "成员的建议"}], + } + wake = wakeup.enqueue_wake_event( + db, team=team, target_agent_id="agent_tl", + trigger_type="task_report", payload={"task_id": task.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + reply = '```json\n{"team_review": {"verdict": "approve", "comment": "通过"}}\n```' + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *args, **kw: reply) + + assert wakeup.claim_wake_event(db, wake.id) is True + wakeup.execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + + db.refresh(task) + assert task.status == "done" + assert _entries(db, team) == [] + assert ( + db.exec( + select(TeamTaskEvent).where(TeamTaskEvent.event_type == "blackboard_written") + ).all() + == [] + ) + + +# ---------- 启动注入 top-K ---------- + + +def test_blackboard_injection_into_messages() -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + write_blackboard_entries( + db, + team=team, + entries=[{"content": "竞品 A 定价 99 元", "tags": ["pricing", "竞品"]}], + source_type="human", + ) + db.commit() + + member_msg = build_member_task_message(db, team, task, rework=False) + assert "团队黑板" in member_msg + assert "- [pricing,竞品] 竞品 A 定价 99 元" in member_msg + assert "blackboard_suggestions" in member_msg + + tl_chat_msg = build_tl_chat_message(db, team, "帮我看看竞品定价") + assert "团队黑板" in tl_chat_msg + assert "竞品 A 定价 99 元" in tl_chat_msg + + review_msg = build_tl_review_message(db, team, task) + assert "团队黑板" in review_msg + # 报告无建议时不追加裁决说明 + assert "blackboard_writes" not in review_msg + + task.report_json = { + "full_reply": "报告", + "blackboard_suggestions": [{"content": "建议一", "tags": ["x"]}], + } + review_msg = build_tl_review_message(db, team, task) + assert "建议一" in review_msg + assert "blackboard_writes" in review_msg + + +def test_blackboard_injection_absent_when_empty() -> None: + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + assert "团队黑板(相关工作记忆)" not in build_member_task_message( + db, team, task, rework=False + ) + assert "团队黑板(相关工作记忆)" not in build_tl_chat_message(db, team, "随便聊聊") + assert "团队黑板(相关工作记忆)" not in build_tl_review_message(db, team, task) + + +def test_blackboard_topk_pinned_first_and_archived_excluded() -> None: + with _test_session() as db: + team = _seed_team(db) + for index in range(12): + entry = TeamBlackboardEntry( + team_id=team.id, + tenant_id=team.tenant_id, + content=f"条目 {index}", + tags_json=[], + source_type="human", + pinned=(index == 0), # 最旧的一条置顶 + ) + db.add(entry) + archived = TeamBlackboardEntry( + team_id=team.id, + tenant_id=team.tenant_id, + content="已归档条目", + source_type="human", + status="archived", + ) + db.add(archived) + db.commit() + + lines = blackboard_context_lines(db, team, "任意任务文本") + assert len(lines) == 10 + # pinned 的最旧条目仍在 top-K 内 + assert any("条目 0" in line for line in lines) + assert all("已归档条目" not in line for line in lines) + + +def test_blackboard_tag_relevance_scoring() -> None: + with _test_session() as db: + team = _seed_team(db) + write_blackboard_entries( + db, + team=team, + entries=[ + {"content": "无关条目", "tags": ["其他"]}, + {"content": "定价相关条目", "tags": ["定价"]}, + ], + source_type="human", + ) + db.commit() + lines = blackboard_context_lines(db, team, "请调研定价策略") + assert lines[0].endswith("定价相关条目") + + +# ---------- API ---------- + + +def test_blackboard_api_crud_flow() -> None: + with _test_session() as db: + team = _seed_team(db) + admin = _admin_user() + + created = teams_api.create_blackboard_entry( + team.id, + TeamBlackboardEntryCreateRequest( + tenant_id="tenant_demo", content="竞品 A 定价 99 元", tags=["Pricing"] + ), + db, + admin, + ) + assert len(created.entries) == 1 + assert created.skipped == [] + entry = created.entries[0] + assert entry.source_type == "human" + assert entry.tags == ["pricing"] + assert entry.status == "active" + assert entry.pinned is False + + # 重复直写 -> 流水线去重 + dup = teams_api.create_blackboard_entry( + team.id, + TeamBlackboardEntryCreateRequest(tenant_id="tenant_demo", content="竞品 A 定价 99 元"), + db, + admin, + ) + assert dup.entries == [] + assert len(dup.skipped) == 1 + + listed = teams_api.list_blackboard_entries(team.id, "tenant_demo", "active", db, admin) + assert len(listed) == 1 + + updated = teams_api.update_blackboard_entry( + team.id, + entry.id, + TeamBlackboardEntryUpdateRequest( + tenant_id="tenant_demo", content="竞品 A 定价 99 元/月", pinned=True + ), + db, + admin, + ) + assert updated.content == "竞品 A 定价 99 元/月" + assert updated.pinned is True + + archived = teams_api.archive_blackboard_entry( + team.id, + entry.id, + TeamBlackboardEntryArchiveRequest(tenant_id="tenant_demo"), + db, + admin, + ) + assert archived.status == "archived" + assert teams_api.list_blackboard_entries(team.id, "tenant_demo", "active", db, admin) == [] + archived_list = teams_api.list_blackboard_entries( + team.id, "tenant_demo", "archived", db, admin + ) + assert len(archived_list) == 1 + + with pytest.raises(HTTPException) as exc_info: + teams_api.list_blackboard_entries(team.id, "tenant_demo", "nonsense", db, admin) + assert exc_info.value.status_code == 400 + + +def test_blackboard_write_requires_manager_read_open_to_tenant() -> None: + with _test_session() as db: + team = _seed_team(db) # owner 是 user_admin + outsider = _admin_user_other() # 同租户,非 owner 非 admin + + with pytest.raises(HTTPException) as exc_info: + teams_api.create_blackboard_entry( + team.id, + TeamBlackboardEntryCreateRequest(tenant_id="tenant_demo", content="x"), + db, + outsider, + ) + assert exc_info.value.status_code == 403 + + # 本租户普通登录用户可读 + assert teams_api.list_blackboard_entries(team.id, "tenant_demo", "active", db, outsider) == [] + + # 跨租户不可读 + other_tenant_user = _admin_user() + other_tenant_user.tenant_id = "tenant_other" + with pytest.raises(HTTPException) as exc_info: + teams_api.list_blackboard_entries( + team.id, "tenant_demo", "active", db, other_tenant_user + ) + assert exc_info.value.status_code == 403 + + # 不存在的条目 404 + with pytest.raises(HTTPException) as exc_info: + teams_api.update_blackboard_entry( + team.id, + "bbentry_missing", + TeamBlackboardEntryUpdateRequest(tenant_id="tenant_demo", pinned=True), + db, + _admin_user(), + ) + assert exc_info.value.status_code == 404 + + +def test_delete_team_cascades_blackboard() -> None: + with _test_session() as db: + team = _seed_team(db) + write_blackboard_entries( + db, team=team, entries=[{"content": "条目"}], source_type="human" + ) + db.commit() + assert teams_api.delete_team_endpoint(team.id, "tenant_demo", db, _admin_user()) == { + "ok": True + } + assert db.exec(select(TeamBlackboardEntry)).all() == [] diff --git a/backend/tests/test_teams_conversations.py b/backend/tests/test_teams_conversations.py new file mode 100644 index 00000000..f101306d --- /dev/null +++ b/backend/tests/test_teams_conversations.py @@ -0,0 +1,513 @@ +from __future__ import annotations + +from datetime import datetime, timedelta + +import pytest +from fastapi import HTTPException +from sqlmodel import Session, select +from test_teams_api import ( + _admin_user, + _make_task, + _member_user, + _seed_team, + _stub_start_wakeup, + _test_session, +) +from test_teams_bidding import ( + _award_reply, + _bid_reply, + _make_pool_task, + _pending_wakes, + _run_wake, + _score_reply, + _seed_pool_team, +) + +from app.api import chat as chat_api +from app.api import teams as teams_api +from app.core import AgentLoop +from app.db.models import ChatSession, Message, TeamTask, TeamWakeEvent +from app.session.session_schema import ChatTurnRequest, ChatTurnResponse, SessionPublic +from app.teams import wakeup +from app.teams.schema import TeamTLChatRequest +from app.teams.service import add_member, create_team +from app.teams.wakeup import claim_wake_event, enqueue_wake_event + + +def _make_session( + db: Session, + *, + session_id: str, + team_id: str | None, + agent_id: str, + title: str, + created_at: datetime, +) -> ChatSession: + session = ChatSession( + id=session_id, + tenant_id="tenant_demo", + user_id="user_admin", + agent_id=agent_id, + title=title, + status="active", + team_id=team_id, + created_at=created_at, + ) + db.add(session) + return session + + +def _make_message( + db: Session, + *, + session_id: str, + message_id: str, + role: str, + content: str, + created_at: datetime, +) -> Message: + row = Message( + id=message_id, + tenant_id="tenant_demo", + session_id=session_id, + role=role, + content=content, + created_at=created_at, + ) + db.add(row) + return row + + +# ---------- 四类团队会话 team_id 落库 ---------- + + +def test_member_task_and_tl_review_sessions_carry_team_id(monkeypatch: pytest.MonkeyPatch) -> None: + """成员任务执行与 TL 验收创建的会话都绑定 team_id。""" + with _test_session() as db: + team = _seed_team(db) + task = _make_task(db, team) + _stub_start_wakeup(monkeypatch) + + def fake_turn(*args, **kw): + if kw["agent"].id == "agent_tl": + return '验收通过\n```json\n{"team_review": {"verdict": "approve", "comment": "通过"}}\n```' + return "执行报告:已完成" + + monkeypatch.setattr(wakeup, "run_agent_turn", fake_turn) + monkeypatch.setattr(wakeup, "_team_harness_outcome", lambda *a, **kw: "completed") + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_worker", + trigger_type="task_assigned", payload={"task_id": task.id}, + ) + db.commit() + assert claim_wake_event(db, wake.id) is True + wakeup.execute_wake_event(db, db.get(TeamWakeEvent, wake.id)) + + review_wake = db.exec( + select(TeamWakeEvent).where( + TeamWakeEvent.trigger_type == "task_report", + TeamWakeEvent.status == "pending", + ) + ).first() + assert review_wake is not None + assert claim_wake_event(db, review_wake.id) is True + wakeup.execute_wake_event(db, db.get(TeamWakeEvent, review_wake.id)) + + sessions = db.exec(select(ChatSession).where(ChatSession.team_id == team.id)).all() + titles = {item.title for item in sessions} + assert f"团队任务:{task.title}" in titles + assert f"团队任务验收:{task.title}" in titles + db.refresh(task) + assert task.status == "done" + + +def test_bid_request_and_award_sessions_carry_team_id(monkeypatch: pytest.MonkeyPatch) -> None: + """竞标陈述会话与 TL 裁决会话都绑定 team_id(辩论关闭,陈述后直接裁决)。""" + with _test_session() as db: + team = _seed_pool_team(db, config={"bid_rebuttal_rounds": 0}) + task = _make_pool_task(db, team, title="调研") + _stub_start_wakeup(monkeypatch) + + def fake_turn(*args, **kw): + agent_id = kw["agent"].id + if agent_id == "agent_tl": + return _award_reply("agent_a") + return _bid_reply(f"{agent_id} 的方案") + + monkeypatch.setattr(wakeup, "run_agent_turn", fake_turn) + wakeup.start_bidding(db, team, task) + wakes = _pending_wakes(db, "bid_request") + assert len(wakes) == 2 + for wake in wakes: + _run_wake(db, wake.id) + bid_sessions = db.exec( + select(ChatSession).where(ChatSession.title.like("团队竞标:%")) + ).all() + assert len(bid_sessions) == 2 + assert all(item.team_id == team.id for item in bid_sessions) + + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + assert judge[0].payload_json["mode"] == "award" + _run_wake(db, judge[0].id) + award_sessions = db.exec( + select(ChatSession).where(ChatSession.title.like("团队竞标裁决:%")) + ).all() + assert len(award_sessions) == 1 + assert award_sessions[0].team_id == team.id + + +def test_bid_score_session_carries_team_id(monkeypatch: pytest.MonkeyPatch) -> None: + """默认 3 轮血条赛制下,TL 每轮打分会话也绑定 team_id。""" + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="调研") + _stub_start_wakeup(monkeypatch) + + def fake_turn(*args, **kw): + agent_id = kw["agent"].id + if agent_id == "agent_tl": + return _score_reply(("agent_a", 9.0), ("agent_b", 8.0)) + return _bid_reply(f"{agent_id} 的方案") + + monkeypatch.setattr(wakeup, "run_agent_turn", fake_turn) + wakeup.start_bidding(db, team, task) + for wake in _pending_wakes(db, "bid_request"): + _run_wake(db, wake.id) + judge = _pending_wakes(db, "bid_judge") + assert len(judge) == 1 + assert judge[0].payload_json["mode"] == "score" + _run_wake(db, judge[0].id) + score_sessions = db.exec( + select(ChatSession).where(ChatSession.title.like("团队竞标打分:%")) + ).all() + assert len(score_sessions) == 1 + assert score_sessions[0].team_id == team.id + + +# ---------- 团队会话列表端点 ---------- + + +def test_conversations_endpoint_kinds_preview_order_isolation() -> None: + """kind 分类、preview 截取 80 字、updated_at 倒序;两个团队交叉验证不串。""" + with _test_session() as db: + team = _seed_team(db) + base = datetime(2026, 1, 1, 12, 0, 0) + s_tl = _make_session( + db, session_id="sess_tl", team_id=team.id, agent_id="agent_tl", + title=f"团队 {team.name} · TL 对话", created_at=base, + ) + s_task = _make_session( + db, session_id="sess_task", team_id=team.id, agent_id="agent_worker", + title="团队任务:写方案", created_at=base, + ) + s_review = _make_session( + db, session_id="sess_review", team_id=team.id, agent_id="agent_tl", + title="团队任务验收:写方案", created_at=base + timedelta(minutes=1), + ) + s_bid = _make_session( + db, session_id="sess_bid", team_id=team.id, agent_id="agent_worker", + title="团队竞标:写方案", created_at=base, + ) + # member_task 会话由 task.session_id 反向关联任务 + task = TeamTask( + team_id=team.id, + tenant_id="tenant_demo", + title="写方案", + status="in_progress", + created_by_user_id="user_admin", + created_by_tl=True, + assignee_agent_id="agent_worker", + session_id=s_task.id, + ) + db.add(task) + # 第二团队:交叉验证物理隔离 + team2 = create_team( + db, tenant_id="tenant_demo", name="另一个团队", + description=None, owner_user_id="user_admin", + ) + add_member(db, team2, agent_id="agent_worker2", role="leader") + s_other = _make_session( + db, session_id="sess_other", team_id=team2.id, agent_id="agent_worker2", + title="团队任务:别的任务", created_at=base + timedelta(minutes=10), + ) + _make_message( + db, session_id=s_other.id, message_id="msg_other", + role="assistant", content="外团队消息", created_at=base + timedelta(minutes=10), + ) + _make_message( + db, session_id=s_tl.id, message_id="msg_tl", role="user", + content="你好 TL", created_at=base + timedelta(minutes=4), + ) + _make_message( + db, session_id=s_task.id, message_id="msg_task", role="assistant", + content="执" * 100, created_at=base + timedelta(minutes=2), + ) + _make_message( + db, session_id=s_bid.id, message_id="msg_bid", role="assistant", + content="我的竞标方案", created_at=base + timedelta(minutes=3), + ) + db.commit() + + # 本租户普通成员可读(对齐 GET tasks) + response = teams_api.list_team_conversations(team.id, "tenant_demo", db, _member_user()) + assert response.team_id == team.id + assert response.team_name == team.name + assert response.tl is not None + assert response.tl.agent_id == "agent_tl" + assert response.tl.agent_name == "TL" + assert response.tl.session_id == s_tl.id + + by_id = {item.session_id: item for item in response.conversations} + # 只含本团队会话,不串 team2 + assert set(by_id) == {s_tl.id, s_task.id, s_review.id, s_bid.id} + assert by_id[s_tl.id].kind == "tl_chat" + assert by_id[s_task.id].kind == "member_task" + assert by_id[s_review.id].kind == "tl_review" + assert by_id[s_bid.id].kind == "member_bid" + assert by_id[s_tl.id].agent_name == "TL" + assert by_id[s_task.id].agent_name == "Worker" + assert by_id[s_task.id].task_id == task.id + assert by_id[s_bid.id].task_id is None + # preview 为末条消息截取 80 字;无消息则空串 + assert by_id[s_task.id].preview == "执" * 80 + assert by_id[s_review.id].preview == "" + # 按 updated_at(末条消息时间或会话创建时间)倒序 + assert [item.session_id for item in response.conversations] == [ + s_tl.id, + s_bid.id, + s_task.id, + s_review.id, + ] + + # team2 视角:只有自己的会话;有 TL 但无 TL 会话时 session_id 为 None + other = teams_api.list_team_conversations(team2.id, "tenant_demo", db, _member_user()) + assert [item.session_id for item in other.conversations] == [s_other.id] + assert other.tl is not None + assert other.tl.agent_id == "agent_worker2" + assert other.tl.session_id is None + + +# ---------- 团队会话消息端点 ---------- + + +def test_conversation_messages_endpoint_order_and_isolation() -> None: + """消息按 created_at 正序;跨团队/无 team_id 的 sessionId 一律 404。""" + with _test_session() as db: + team = _seed_team(db) + base = datetime(2026, 1, 1, 12, 0, 0) + session = _make_session( + db, session_id="sess_msg", team_id=team.id, agent_id="agent_worker", + title="团队任务:写方案", created_at=base, + ) + # 乱序插入,验证端点按 created_at 正序返回 + _make_message( + db, session_id=session.id, message_id="m2", role="assistant", + content="第二条", created_at=base + timedelta(minutes=2), + ) + _make_message( + db, session_id=session.id, message_id="m1", role="user", + content="第一条", created_at=base + timedelta(minutes=1), + ) + team2 = create_team( + db, tenant_id="tenant_demo", name="隔离团队", + description=None, owner_user_id="user_admin", + ) + other = _make_session( + db, session_id="sess_x", team_id=team2.id, agent_id="agent_worker", + title="团队任务:外部", created_at=base, + ) + plain = _make_session( + db, session_id="sess_plain", team_id=None, agent_id="agent_worker", + title="普通会话", created_at=base, + ) + db.commit() + + rows = teams_api.list_team_conversation_messages( + team.id, session.id, "tenant_demo", db, _member_user() + ) + assert [row.id for row in rows] == ["m1", "m2"] + assert rows[0].role == "user" + assert rows[0].content == "第一条" + assert rows[1].role == "assistant" + + with pytest.raises(HTTPException) as exc_info: + teams_api.list_team_conversation_messages( + team.id, other.id, "tenant_demo", db, _admin_user() + ) + assert exc_info.value.status_code == 404 + + with pytest.raises(HTTPException) as exc_info: + teams_api.list_team_conversation_messages( + team.id, plain.id, "tenant_demo", db, _admin_user() + ) + assert exc_info.value.status_code == 404 + + with pytest.raises(HTTPException) as exc_info: + teams_api.list_team_conversation_messages( + team.id, "sess_missing", "tenant_demo", db, _admin_user() + ) + assert exc_info.value.status_code == 404 + + +# ---------- 发送复用:团队会话对本租户成员放行 ---------- + + +def test_chat_turn_allows_tenant_member_on_team_session(monkeypatch: pytest.MonkeyPatch) -> None: + """团队会话(team_id 非空)允许本租户非创建者发言;普通会话仍仅创建者可见。""" + with _test_session() as db: + team = _seed_team(db) + session = _make_session( + db, session_id="sess_shared_tl", team_id=team.id, agent_id="agent_tl", + title=f"团队 {team.name} · TL 对话", created_at=datetime(2026, 1, 1), + ) + db.commit() + _stub_start_wakeup(monkeypatch) + monkeypatch.setattr(chat_api, "_schedule_session_title_summary", lambda *a, **kw: None) + + def fake_handle_turn(self, request): + return ChatTurnResponse( + reply="收到,先讨论。", + session_id=request.session_id, + session_state=SessionPublic( + session_id=request.session_id, tenant_id=request.tenant_id + ), + ) + + monkeypatch.setattr(AgentLoop, "handle_turn", fake_handle_turn) + # 会话 user_id 是 user_admin,同租户成员 user_member 发言不再被所有权校验挡住 + response = chat_api.chat_turn( + ChatTurnRequest( + tenant_id="tenant_demo", + session_id=session.id, + client_turn_id="ct_member_1", + message="聊聊规划", + ), + _member_user(), + db, + ) + assert response.reply.startswith("收到") + + private = _make_session( + db, session_id="sess_private", team_id=None, agent_id="agent_tl", + title="私聊", created_at=datetime(2026, 1, 1), + ) + db.commit() + with pytest.raises(HTTPException) as exc_info: + chat_api.chat_turn( + ChatTurnRequest( + tenant_id="tenant_demo", + session_id=private.id, + client_turn_id="ct_member_2", + message="你好", + ), + _member_user(), + db, + ) + assert exc_info.value.status_code == 404 + + +# ---------- 团队会话写入权限与共享 TL 隔离 ---------- + + +def test_chat_turn_and_stream_reject_non_tl_team_sessions() -> None: + """任务执行/竞标/验收会话仅可查看:人工 /turn、/stream 一律 403,TL 对话不受影响。""" + with _test_session() as db: + team = _seed_team(db) + base = datetime(2026, 1, 1, 12, 0, 0) + sessions = [ + _make_session( + db, session_id="sess_ro_task", team_id=team.id, agent_id="agent_worker", + title="团队任务:写方案", created_at=base, + ), + _make_session( + db, session_id="sess_ro_review", team_id=team.id, agent_id="agent_tl", + title="团队任务验收:写方案", created_at=base, + ), + _make_session( + db, session_id="sess_ro_bid", team_id=team.id, agent_id="agent_worker", + title="团队竞标:写方案", created_at=base, + ), + ] + db.commit() + + for session in sessions: + with pytest.raises(HTTPException) as exc_info: + chat_api.chat_turn( + ChatTurnRequest( + tenant_id="tenant_demo", + session_id=session.id, + client_turn_id=f"ct_ro_{session.id}", + message="插一句话", + ), + _member_user(), + db, + ) + assert exc_info.value.status_code == 403 + with pytest.raises(HTTPException) as exc_info: + chat_api.chat_stream( + ChatTurnRequest( + tenant_id="tenant_demo", + session_id=session.id, + client_turn_id=f"cs_ro_{session.id}", + message="插一句话", + ), + _member_user(), + db, + ) + assert exc_info.value.status_code == 403 + + +def test_tl_chat_rejects_session_from_other_team_with_shared_tl() -> None: + """共享 TL 场景:不能把 A 团队的 TL 会话传给 B 团队的 /tl/chat。""" + with _test_session() as db: + team_a = _seed_team(db) + team_b = create_team( + db, tenant_id="tenant_demo", name="共享TL团队B", + description=None, owner_user_id="user_admin", + ) + add_member(db, team_b, agent_id="agent_tl", role="leader") + session_a = _make_session( + db, session_id="sess_tl_a", team_id=team_a.id, agent_id="agent_tl", + title=f"团队 {team_a.name} · TL 对话", created_at=datetime(2026, 1, 1), + ) + db.commit() + + with pytest.raises(HTTPException) as exc_info: + teams_api.tl_chat_endpoint( + team_b.id, + TeamTLChatRequest( + tenant_id="tenant_demo", + message="给 B 团队派个任务", + session_id=session_a.id, + ), + db, + _admin_user(), + ) + assert exc_info.value.status_code == 404 + + +def test_team_threads_scope_tl_sessions_by_team_with_shared_tl() -> None: + """共享 TL 场景:统一线程列表按 team_id 归属,同一会话不会以两个团队名重复出现。""" + with _test_session() as db: + team_a = _seed_team(db) + team_b = create_team( + db, tenant_id="tenant_demo", name="共享TL团队B", + description=None, owner_user_id="user_admin", + ) + add_member(db, team_b, agent_id="agent_tl", role="leader") + base = datetime(2026, 1, 1, 12, 0, 0) + session_a = _make_session( + db, session_id="sess_thread_a", team_id=team_a.id, agent_id="agent_tl", + title=f"团队 {team_a.name} · TL 对话", created_at=base, + ) + session_b = _make_session( + db, session_id="sess_thread_b", team_id=team_b.id, agent_id="agent_tl", + title=f"团队 {team_b.name} · TL 对话", created_at=base, + ) + db.commit() + + threads = teams_api.list_team_threads("tenant_demo", db, _member_user()) + tl_threads = [item for item in threads if item.kind == "tl_chat"] + by_session = {item.session_id: item.team_id for item in tl_threads} + assert by_session == {session_a.id: team_a.id, session_b.id: team_b.id} diff --git a/backend/tests/test_teams_ops.py b/backend/tests/test_teams_ops.py new file mode 100644 index 00000000..80354cc3 --- /dev/null +++ b/backend/tests/test_teams_ops.py @@ -0,0 +1,516 @@ +from __future__ import annotations + +import base64 +from datetime import timedelta + +import pytest +from fastapi import HTTPException +from sqlmodel import Session, select +from test_teams_api import ( + _admin_user, + _admin_user_other, + _stub_start_wakeup, + _test_session, +) +from test_teams_bidding import ( + _bid_reply, + _events, + _make_pool_task, + _pending_wakes, + _run_wake, + _seed_pool_team, +) + +from app.api import teams as teams_api +from app.db.models import ( + ChatSession, + KnowledgeIngestJob, + Team, + TeamBlackboardEntry, + TeamTask, + TeamTaskBid, + TeamWakeEvent, + User, + new_id, + utc_now, +) +from app.teams import wakeup +from app.teams.schema import AwardOverrideRequest, TeamBlackboardPromoteRequest +from app.teams.service import member_concurrency, record_task_event +from app.teams.sweeper import ( + DEFAULT_TASK_TIMEOUT_MINUTES, + sweep_timed_out_tasks, + task_timeout_minutes, +) +from app.teams.wakeup import enqueue_wake_event + + +def _foreign_user() -> User: + return User( + id="user_foreign", + tenant_id="tenant_else", + username="foreign", + role="admin", + password_hash="test", + ) + + +def _make_assigned_task( + db: Session, team: Team, *, title: str, assignee: str, status: str = "pending" +) -> TeamTask: + task = TeamTask( + team_id=team.id, + tenant_id=team.tenant_id, + title=title, + status=status, + created_by_user_id="user_admin", + created_by_tl=True, + assignee_agent_id=assignee, + ) + db.add(task) + db.commit() + db.refresh(task) + return task + + +# ---------- 成员串行排队 ---------- + + +def test_member_needs_input_escalates_with_question(monkeypatch: pytest.MonkeyPatch) -> None: + """成员 awaiting_user(如索要合同文本):保留提问并升级给人,不进入 TL 验收。""" + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_assigned_task(db, team, title="合同审查", assignee="agent_a") + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_a", + trigger_type="task_assigned", payload={"task_id": task.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + monkeypatch.setattr( + wakeup, "run_agent_turn", lambda *a, **kw: "请提供合同文本,我才能逐条审查" + ) + monkeypatch.setattr(wakeup, "_team_harness_outcome", lambda *a, **kw: "needs_input") + + finished = _run_wake(db, wake.id) + + assert finished.status == "done" + db.refresh(task) + assert task.status == "escalated" + assert task.report_json["needs_input"] is True + assert "合同文本" in task.report_json["full_reply"] + events = _events(db, task.id, "task_needs_input") + assert len(events) == 1 + assert "合同文本" in events[0].payload_json["question"] + # 不触发 TL 验收唤醒 + assert _pending_wakes(db, "task_report") == [] + + +def test_member_concurrency_config_fallback() -> None: + base = {"tenant_id": "t", "name": "n", "owner_user_id": "u"} + assert member_concurrency(Team(**base)) == 1 + assert member_concurrency(Team(**base, config_json={"member_concurrency": 3})) == 3 + # 非数字/非正数配置回退默认 1 + assert member_concurrency(Team(**base, config_json={"member_concurrency": "abc"})) == 1 + assert member_concurrency(Team(**base, config_json={"member_concurrency": 0})) == 1 + # 非 dict 配置(绕过校验直写)同样回退默认 + weird = Team(**base) + weird.config_json = "x" + assert member_concurrency(weird) == 1 + + +def test_execution_wake_queues_when_member_busy(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + _make_assigned_task(db, team, title="执行中", assignee="agent_a", status="in_progress") + queued_task = _make_assigned_task(db, team, title="排队任务", assignee="agent_a") + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_a", + trigger_type="task_assigned", payload={"task_id": queued_task.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + monkeypatch.setattr( + wakeup, "run_agent_turn", + lambda *a, **kw: pytest.fail("排队中的唤醒不应执行"), + ) + + finished = _run_wake(db, wake.id) + + # 排队:事件保持 pending,记 wake_queued 审计,任务不动 + assert finished.status == "pending" + queued = _events(db, queued_task.id, "wake_queued") + assert len(queued) == 1 + assert queued[0].actor_type == "system" + assert queued[0].payload_json["wake_event_id"] == wake.id + db.refresh(queued_task) + assert queued_task.status == "pending" + + +def test_member_queue_drains_after_task_reported(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + first = _make_assigned_task(db, team, title="先做", assignee="agent_a") + second = _make_assigned_task(db, team, title="后做", assignee="agent_a") + started = _stub_start_wakeup(monkeypatch) + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *a, **kw: "完成报告") + monkeypatch.setattr(wakeup, "_team_harness_outcome", lambda *a, **kw: "completed") + wake1 = enqueue_wake_event( + db, team=team, target_agent_id="agent_a", + trigger_type="task_assigned", payload={"task_id": first.id}, + ) + wake2 = enqueue_wake_event( + db, team=team, target_agent_id="agent_a", + trigger_type="task_assigned", payload={"task_id": second.id}, + ) + db.commit() + + _run_wake(db, wake1.id) # 第一个任务执行完成 -> review + + db.refresh(first) + assert first.status == "review" + # 终态后自动出队:最老的 pending 执行类唤醒被拉起(TL 验收唤醒在前) + assert started[-1] == wake2.id + assert _events(db, second.id, "wake_queued") == [] + + # 出队的唤醒此时可正常执行(成员已空闲) + _run_wake(db, wake2.id) + db.refresh(second) + assert second.status == "review" + assert db.get(TeamWakeEvent, wake2.id).status == "done" + + +def test_member_concurrency_config_allows_parallel(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db, config={"member_concurrency": 2}) + _make_assigned_task(db, team, title="执行中", assignee="agent_a", status="in_progress") + second = _make_assigned_task(db, team, title="并发任务", assignee="agent_a") + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_a", + trigger_type="task_assigned", payload={"task_id": second.id}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *a, **kw: "完成报告") + monkeypatch.setattr(wakeup, "_team_harness_outcome", lambda *a, **kw: "completed") + + finished = _run_wake(db, wake.id) + + # 上限 2:已有 1 个 in_progress 仍可直接执行,不排队 + assert finished.status == "done" + db.refresh(second) + assert second.status == "review" + assert _events(db, second.id, "wake_queued") == [] + + +def test_bid_wake_bypasses_member_queue(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + _make_assigned_task(db, team, title="执行中", assignee="agent_a", status="in_progress") + bidding_task = _make_pool_task(db, team, title="调研") + bidding_task.status = "bidding" + db.add(bidding_task) + db.commit() + wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_a", + trigger_type="bid_request", payload={"task_id": bidding_task.id, "round": 1}, + ) + db.commit() + _stub_start_wakeup(monkeypatch) + monkeypatch.setattr(wakeup, "run_agent_turn", lambda *a, **kw: _bid_reply("甲的方案")) + + finished = _run_wake(db, wake.id) + + # 竞标属轻量 turn:不受执行排队影响,照跑照记 + assert finished.status == "done" + bids = db.exec(select(TeamTaskBid).where(TeamTaskBid.task_id == bidding_task.id)).all() + assert len(bids) == 1 + assert _events(db, bidding_task.id, "bid_submitted") + assert _events(db, bidding_task.id, "wake_queued") == [] + + +# ---------- 超时清扫 ---------- + + +def _make_timed_task( + db: Session, team: Team, *, status: str, minutes_ago: float, assignee: str | None = None +) -> TeamTask: + task = TeamTask( + team_id=team.id, + tenant_id=team.tenant_id, + title=f"{status}任务", + status=status, + assignee_agent_id=assignee, + ) + task.updated_at = utc_now() - timedelta(minutes=minutes_ago) + db.add(task) + db.commit() + db.refresh(task) + return task + + +def test_sweep_escalates_timed_out_tasks(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + started = _stub_start_wakeup(monkeypatch) + stale = _make_timed_task(db, team, status="in_progress", minutes_ago=60, assignee="agent_a") + stale_wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_a", + trigger_type="task_assigned", payload={"task_id": stale.id}, + ) + # 同成员的另一个排队唤醒:超时释放额度后应被出队拉起 + queued_task = _make_assigned_task(db, team, title="排队任务", assignee="agent_a") + queued_wake = enqueue_wake_event( + db, team=team, target_agent_id="agent_a", + trigger_type="task_assigned", payload={"task_id": queued_task.id}, + ) + fresh = _make_timed_task(db, team, status="review", minutes_ago=10, assignee="agent_b") + done = _make_timed_task(db, team, status="done", minutes_ago=120, assignee="agent_b") + db.commit() + + swept = sweep_timed_out_tasks(db) + + assert [task.id for task in swept] == [stale.id] + db.refresh(stale) + assert stale.status == "escalated" + escalated = _events(db, stale.id, "task_escalated") + assert escalated[0].actor_type == "system" + assert escalated[0].payload_json["reason"] == "timeout" + assert escalated[0].payload_json["from_status"] == "in_progress" + # 关联 pending 唤醒标记 failed(error=timeout) + failed_wake = db.get(TeamWakeEvent, stale_wake.id) + assert failed_wake.status == "failed" + assert failed_wake.error == "timeout" + # in_progress 超时释放执行额度,出队该成员的排队唤醒 + assert started == [queued_wake.id] + # 未超时的 review 与终态 done 不误伤 + db.refresh(fresh) + db.refresh(done) + assert fresh.status == "review" + assert done.status == "done" + assert db.get(TeamWakeEvent, queued_wake.id).status == "pending" + + +def test_sweep_respects_team_timeout_config() -> None: + with _test_session() as db: + team = _seed_pool_team(db, config={"task_timeout_minutes": 5}) + stale = _make_timed_task(db, team, status="bidding", minutes_ago=10) + + swept = sweep_timed_out_tasks(db) + + assert [task.id for task in swept] == [stale.id] + db.refresh(stale) + assert stale.status == "escalated" + + +def test_task_timeout_minutes_config_fallback() -> None: + base = {"tenant_id": "t", "name": "n", "owner_user_id": "u"} + assert task_timeout_minutes(Team(**base)) == DEFAULT_TASK_TIMEOUT_MINUTES + assert task_timeout_minutes(Team(**base, config_json={"task_timeout_minutes": 45})) == 45.0 + # 非数字/非正数配置回退默认 + assert task_timeout_minutes(Team(**base, config_json={"task_timeout_minutes": "abc"})) == 30.0 + assert task_timeout_minutes(Team(**base, config_json={"task_timeout_minutes": -5})) == 30.0 + # 非 dict 配置(绕过校验直写)同样回退默认 + weird = Team(**base) + weird.config_json = "x" + assert task_timeout_minutes(weird) == 30.0 + + +# ---------- 团队级审计与统一线程 ---------- + + +def test_team_events_endpoint_aggregates_desc_with_title() -> None: + with _test_session() as db: + team = _seed_pool_team(db) + admin = _admin_user() + task1 = _make_pool_task(db, team, title="任务一") + task2 = _make_pool_task(db, team, title="任务二") + for task in (task1, task2): + record_task_event( + db, team_id=team.id, task_id=task.id, + actor_type="user", actor_id="user_admin", event_type="task_created", + ) + db.commit() + + events = teams_api.list_team_events(team.id, "tenant_demo", 50, db, admin) + + assert len(events) == 2 + assert events[0].created_at >= events[1].created_at + titles = {item.task_id: item.task_title for item in events} + assert titles == {task1.id: "任务一", task2.id: "任务二"} + + # limit 生效 + limited = teams_api.list_team_events(team.id, "tenant_demo", 1, db, admin) + assert len(limited) == 1 + # 其他租户的用户 -> 403 + with pytest.raises(HTTPException) as exc_info: + teams_api.list_team_events(team.id, "tenant_demo", 50, db, _foreign_user()) + assert exc_info.value.status_code == 403 + + +def test_team_threads_aggregates_tl_chat_and_tasks() -> None: + with _test_session() as db: + team = _seed_pool_team(db) + admin = _admin_user() + tl_session = ChatSession( + id=new_id("session"), tenant_id="tenant_demo", user_id="user_admin", + agent_id="agent_tl", title=f"团队 {team.name} · TL 对话", status="active", + team_id=team.id, + ) + # 非 TL 对话标题的同 agent 会话不入选 + other_session = ChatSession( + id=new_id("session"), tenant_id="tenant_demo", user_id="user_admin", + agent_id="agent_tl", title="日常闲聊", status="active", + team_id=team.id, + ) + task_session = ChatSession( + id=new_id("session"), tenant_id="tenant_demo", user_id="user_admin", + agent_id="agent_a", title="团队任务:调研", status="active", + ) + db.add(tl_session) + db.add(other_session) + db.add(task_session) + task = _make_assigned_task(db, team, title="调研", assignee="agent_a", status="review") + task.session_id = task_session.id + task.updated_at = utc_now() + timedelta(minutes=1) # 任务线程排在最前 + db.add(task) + db.commit() + + threads = teams_api.list_team_threads("tenant_demo", db, admin) + + assert [(item.kind) for item in threads] == ["task", "tl_chat"] + task_thread = threads[0] + assert task_thread.team_id == team.id + assert task_thread.team_name == team.name + assert task_thread.session_id == task_session.id + assert task_thread.task_id == task.id + assert task_thread.title == "调研" + assert task_thread.task_status == "review" + tl_thread = threads[1] + assert tl_thread.session_id == tl_session.id + assert tl_thread.task_id is None + assert tl_thread.task_status is None + assert "TL 对话" in tl_thread.title + # 按 updated_at 倒序 + assert threads[0].updated_at >= threads[1].updated_at + + # 其他租户的用户 -> 403 + with pytest.raises(HTTPException) as exc_info: + teams_api.list_team_threads("tenant_demo", db, _foreign_user()) + assert exc_info.value.status_code == 403 + + +# ---------- 黑板沉淀到知识库 ---------- + + +def test_blackboard_promote_creates_ingest_job_idempotent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + admin = _admin_user() + task = _make_pool_task(db, team, title="来源任务") + entry = TeamBlackboardEntry( + team_id=team.id, tenant_id=team.tenant_id, + content="竞品 A 定价 99 元", tags_json=["pricing"], + source_type="member", source_agent_id="agent_a", source_task_id=task.id, + citation_json={"task_id": task.id, "task_title": task.title}, + ) + db.add(entry) + db.commit() + db.refresh(entry) + enqueued: list[tuple[tuple, dict]] = [] + monkeypatch.setattr( + teams_api, "enqueue_async_job", + lambda *args, **kw: enqueued.append((args, kw)), + ) + + resp = teams_api.promote_blackboard_entry( + team.id, entry.id, + TeamBlackboardPromoteRequest(tenant_id="tenant_demo"), + db, admin, + ) + + assert resp.already_promoted is False + assert resp.knowledge_base_id + job = db.get(KnowledgeIngestJob, resp.ingest_job_id) + assert job is not None + assert job.status == "queued" + assert job.tenant_id == "tenant_demo" + assert job.knowledge_base_id == resp.knowledge_base_id + # 原始资料 markdown:含条目内容、tags、来源团队/任务标注 + markdown = base64.b64decode(job.metadata_json["content_base64"]).decode("utf-8") + assert "竞品 A 定价 99 元" in markdown + assert "pricing" in markdown + assert team.name in markdown + assert task.title in markdown + # citation 回写 + db.refresh(entry) + assert entry.citation_json["knowledge_base_id"] == resp.knowledge_base_id + assert entry.citation_json["ingest_job_id"] == job.id + assert entry.citation_json["task_id"] == task.id # 既有引用保留 + # 异步执行复用知识库 ingest 队列 + assert len(enqueued) == 1 + assert enqueued[0][0][0] == "knowledge_ingest" + assert enqueued[0][0][2] == job.id + + # 重复 promote:返回既有引用,不重复建 job + resp2 = teams_api.promote_blackboard_entry( + team.id, entry.id, + TeamBlackboardPromoteRequest(tenant_id="tenant_demo"), + db, admin, + ) + assert resp2.already_promoted is True + assert resp2.ingest_job_id == job.id + assert resp2.knowledge_base_id == resp.knowledge_base_id + assert len(enqueued) == 1 + assert len(db.exec(select(KnowledgeIngestJob)).all()) == 1 + + # 非 owner/admin 不可沉淀 + with pytest.raises(HTTPException) as exc_info: + teams_api.promote_blackboard_entry( + team.id, entry.id, + TeamBlackboardPromoteRequest(tenant_id="tenant_demo"), + db, _admin_user_other(), + ) + assert exc_info.value.status_code == 403 + + +# ---------- 迟到竞标语义 ---------- + + +def test_late_bid_wake_after_override_records_bid_skipped( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with _test_session() as db: + team = _seed_pool_team(db) + task = _make_pool_task(db, team, title="调研") + _stub_start_wakeup(monkeypatch) + calls: list[str] = [] + + def fake_turn(*args, **kw): + calls.append(kw["agent"].id) + return _bid_reply("方案") + + monkeypatch.setattr(wakeup, "run_agent_turn", fake_turn) + wakeup.start_bidding(db, team, task) + + # 人改判:任务离开 bidding + teams_api.override_task_award( + team.id, task.id, + AwardOverrideRequest(tenant_id="tenant_demo", agent_id="agent_c"), + db, _admin_user(), + ) + calls.clear() + + late_wake = _pending_wakes(db, "bid_request")[0] + finished = _run_wake(db, late_wake.id) + + # 迟到竞标:记 bid_skipped 直接返回,不执行 turn、不落 bid、不记 bid_submitted + assert finished.status == "done" + assert calls == [] + skipped = _events(db, task.id, "bid_skipped") + assert len(skipped) == 1 + assert skipped[0].payload_json["task_status"] == "pending" + assert db.exec(select(TeamTaskBid).where(TeamTaskBid.task_id == task.id)).all() == [] + assert _events(db, task.id, "bid_submitted") == [] diff --git a/backend/tests/test_teams_workspace.py b/backend/tests/test_teams_workspace.py new file mode 100644 index 00000000..5457d6e8 --- /dev/null +++ b/backend/tests/test_teams_workspace.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from sqlalchemy import create_engine, inspect, text +from sqlmodel import Session, select +from test_teams_api import ( + _admin_user, + _member_user, + _seed_team, + _stub_start_wakeup, + _test_session, +) + +from app.api import chat as chat_api +from app.api import teams as teams_api +from app.core import AgentLoop +from app.db import database +from app.db.models import ChatSession, TeamTask, TeamWakeEvent, User +from app.session.session_schema import ChatTurnRequest, ChatTurnResponse, SessionPublic +from app.teams.schema import TeamTLChatRequest, TeamTLSessionRequest +from app.teams.service import create_team + +# ---------- sessions.team_id 迁移 ---------- + + +def test_sessions_team_id_migration(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """老库无 team_id 列:migrate 后存在,且重复执行幂等。""" + db_path = tmp_path / "migrate.db" + engine = create_engine(f"sqlite:///{db_path}") + with engine.begin() as conn: + conn.execute( + text( + """ + CREATE TABLE sessions ( + id VARCHAR PRIMARY KEY, + tenant_id VARCHAR, + user_id VARCHAR, + agent_id VARCHAR, + title VARCHAR, + status VARCHAR + ) + """ + ) + ) + monkeypatch.setattr(database, "database_url", f"sqlite:///{db_path}") + monkeypatch.setattr(database, "engine", engine) + + database._migrate_sqlite_skill_schema() + columns = {column["name"] for column in inspect(engine).get_columns("sessions")} + assert "team_id" in columns + + database._migrate_sqlite_skill_schema() # 重复执行不炸 + columns = {column["name"] for column in inspect(engine).get_columns("sessions")} + assert "team_id" in columns + + +# ---------- TL 会话 get-or-create ---------- + + +def test_tl_session_get_or_create_idempotent() -> None: + with _test_session() as db: + team = _seed_team(db) + admin = _admin_user() + + first = teams_api.tl_session_endpoint( + team.id, TeamTLSessionRequest(tenant_id="tenant_demo"), db, admin + ) + session = db.get(ChatSession, first.session_id) + assert session is not None + assert session.team_id == team.id + assert session.agent_id == "agent_tl" + assert session.user_id == "user_admin" + + second = teams_api.tl_session_endpoint( + team.id, TeamTLSessionRequest(tenant_id="tenant_demo"), db, admin + ) + assert second.session_id == first.session_id + sessions = db.exec( + select(ChatSession).where(ChatSession.team_id == team.id) + ).all() + assert len(sessions) == 1 + + +def test_tl_session_requires_leader() -> None: + with _test_session() as db: + _seed_team(db) # 种子租户/员工 + bare = create_team( + db, + tenant_id="tenant_demo", + name="无TL团队", + description=None, + owner_user_id="user_admin", + ) + with pytest.raises(HTTPException) as exc_info: + teams_api.tl_session_endpoint( + bare.id, TeamTLSessionRequest(tenant_id="tenant_demo"), db, _admin_user() + ) + assert exc_info.value.status_code == 400 + + +def test_tl_session_open_to_tenant_member_and_rejects_foreign_tenant() -> None: + """tl/session 对本租户所有登录用户开放(对齐 TL chat 权限),外租户拒绝。""" + with _test_session() as db: + team = _seed_team(db) + response = teams_api.tl_session_endpoint( + team.id, TeamTLSessionRequest(tenant_id="tenant_demo"), db, _member_user() + ) + session = db.get(ChatSession, response.session_id) + assert session is not None + assert session.team_id == team.id + assert session.user_id == "user_member" + + foreign = User( + id="user_foreign", + tenant_id="tenant_other", + username="foreign", + role="admin", + password_hash="test", + ) + with pytest.raises(HTTPException) as exc_info: + teams_api.tl_session_endpoint( + team.id, TeamTLSessionRequest(tenant_id="tenant_demo"), db, foreign + ) + assert exc_info.value.status_code == 403 + + +# ---------- 会话 read 带团队归属 ---------- + + +def test_session_read_carries_team_id_and_name() -> None: + """团队 TL 会话 read 带 team_id/team_name;普通会话两者均为 None。""" + with _test_session() as db: + team = _seed_team(db) + admin = _admin_user() + plain = ChatSession( + id="session_plain_read", + tenant_id="tenant_demo", + user_id=admin.id, + agent_id="agent_tl", + title="普通会话", + status="active", + ) + db.add(plain) + db.commit() + tl = teams_api.tl_session_endpoint( + team.id, TeamTLSessionRequest(tenant_id="tenant_demo"), db, admin + ) + + reads = {item.id: item for item in chat_api.list_chat_sessions("tenant_demo", admin, db)} + assert reads[tl.session_id].team_id == team.id + assert reads[tl.session_id].team_name == team.name + assert reads[plain.id].team_id is None + assert reads[plain.id].team_name is None + + +def test_tl_chat_session_carries_team_id(monkeypatch: pytest.MonkeyPatch) -> None: + with _test_session() as db: + team = _seed_team(db) + _stub_start_wakeup(monkeypatch) + + def fake_handle_turn(self, request): + return ChatTurnResponse( + reply="收到,先讨论。", + session_id=request.session_id, + session_state=SessionPublic( + session_id=request.session_id, tenant_id=request.tenant_id + ), + ) + + monkeypatch.setattr(AgentLoop, "handle_turn", fake_handle_turn) + response = teams_api.tl_chat_endpoint( + team.id, + TeamTLChatRequest(tenant_id="tenant_demo", message="聊聊规划"), + db, + _admin_user(), + ) + session = db.get(ChatSession, response.session_id) + assert session is not None + assert session.team_id == team.id + + +# ---------- 主聊天端团队 TL 会话 ---------- + + +def _make_tl_session(db: Session, team, *, team_id: str | None) -> ChatSession: + session = ChatSession( + id="session_tl", + tenant_id="tenant_demo", + user_id="user_admin", + agent_id="agent_tl", + title="团队 TL 对话", + status="active", + team_id=team_id, + ) + db.add(session) + db.commit() + return session + + +def test_chat_turn_team_tl_session_injects_context_and_creates_tasks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with _test_session() as db: + team = _seed_team(db) + session = _make_tl_session(db, team, team_id=team.id) + started = _stub_start_wakeup(monkeypatch) + monkeypatch.setattr( + chat_api, "_schedule_session_title_summary", lambda *a, **kw: None + ) + seen: dict[str, str] = {} + + def fake_handle_turn(self, request): + seen["message"] = request.message + seen["interaction_mode"] = request.interaction_mode + reply = ( + "收到,派给 Worker。\n" + '```json\n{"team_tasks": [{"title": "竞品调研", ' + '"assignee_agent_id": "agent_worker"}]}\n```' + ) + return ChatTurnResponse( + reply=reply, + session_id=request.session_id, + session_state=SessionPublic( + session_id=request.session_id, tenant_id=request.tenant_id + ), + ) + + monkeypatch.setattr(AgentLoop, "handle_turn", fake_handle_turn) + + response = chat_api.chat_turn( + ChatTurnRequest( + tenant_id="tenant_demo", + session_id=session.id, + client_turn_id="ct_1", + message="帮我调研竞品", + ), + _admin_user(), + db, + ) + + # 消息被团队上下文包装后再走引擎 + assert "团队花名册" in seen["message"] + assert "agent_worker" in seen["message"] + assert "人的需求:帮我调研竞品" in seen["message"] + assert seen["interaction_mode"] == "team_tl" + assert response.reply.startswith("收到") + + # 回复后处理:派任务块解析并创建任务 + 唤醒(与 tl_chat 端点同语义) + tasks = db.exec(select(TeamTask).where(TeamTask.team_id == team.id)).all() + assert len(tasks) == 1 + assert tasks[0].title == "竞品调研" + assert tasks[0].assignee_agent_id == "agent_worker" + assert tasks[0].created_by_tl is True + wakes = db.exec(select(TeamWakeEvent)).all() + assert len(wakes) == 1 + assert wakes[0].trigger_type == "task_assigned" + assert started == [wakes[0].id] + + +def test_chat_turn_plain_session_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + """非团队会话:不注入团队上下文、不做派任务后处理。""" + with _test_session() as db: + team = _seed_team(db) + session = ChatSession( + id="session_plain", + tenant_id="tenant_demo", + user_id="user_admin", + agent_id="agent_tl", # 同一 agent,但会话未挂 team_id + title="普通会话", + status="active", + ) + db.add(session) + db.commit() + monkeypatch.setattr( + chat_api, "_schedule_session_title_summary", lambda *a, **kw: None + ) + seen: dict[str, str] = {} + + def fake_handle_turn(self, request): + seen["message"] = request.message + seen["interaction_mode"] = request.interaction_mode + return ChatTurnResponse( + reply="好的", + session_id=request.session_id, + session_state=SessionPublic( + session_id=request.session_id, tenant_id=request.tenant_id + ), + ) + + monkeypatch.setattr(AgentLoop, "handle_turn", fake_handle_turn) + + chat_api.chat_turn( + ChatTurnRequest( + tenant_id="tenant_demo", + session_id=session.id, + client_turn_id="ct_2", + message="帮我调研竞品", + ), + _admin_user(), + db, + ) + + assert seen["message"] == "帮我调研竞品" + assert seen["interaction_mode"] == "normal" + assert db.exec(select(TeamTask).where(TeamTask.team_id == team.id)).all() == [] diff --git a/design-multi-agent-team-decisions.md b/design-multi-agent-team-decisions.md new file mode 100644 index 00000000..1d217fe8 --- /dev/null +++ b/design-multi-agent-team-decisions.md @@ -0,0 +1,58 @@ +# 多 Agent 团队功能 · 决策记录 v1.1 + +> 日期:2026-08-10(v1);2026-08-11(v1.1 修订,体验测试反馈) +> 状态:已锁定(经 ni-unknown-first 关键决策访谈确认) +> 背景:当前平台只有独立的数字员工(`agent_profiles`),不存在团队、任务下发、认领、共享黑板机制。本记录锁定该功能的 5 组关键决策,作为数据模型与架构边界的依据。 +> v1.1 修订:决策 2 赛制改为 3 轮血条制;决策 3 补充工作区聊天集成。增量 1-4 已全部实现并真机验收通过。 + +## 贯穿原则:HITL(Human in the Loop) + +人在每个关键环节保留介入点:下发可审、竞标可看可改判、过程以多线程方式全程可见、结果可收、黑板可治理。 + +## 决策 1:TL 身份 + +- **选择**:TL 由数字员工扮演(团队成员的"角色标记")+ 人类控制台兜底 +- **理由**:纯人工 TL 丧失多 agent 差异点;纯 agent TL 的运行时风险(唤醒循环、错误派发)会拖垮 v1。混合制让 v1 即可演示完整闭环,人始终可介入 +- **被否**:纯人工 TL(降级为兜底通道);纯 agent TL(v2 演进目标,架构天然兼容) + +## 决策 2:任务认领机制(v1.1 修订:3 轮血条赛制) + +- **选择**:指派 + 认领混合 + **竞标制**——≥2 名候选且 TL 未锁人时开启竞标。赛制(v1.1):默认 3 轮(团队 config `bid_rebuttal_rounds` 可调,0=直接裁决),round 1 陈述、后续轮反驳;**每轮结束 TL 轻量打分(0-10)**,候选 HP = 100 − Σ(10 − 该轮得分) × 3,下限 0;HP 归零淘汰(`bid_eliminated` 审计),存活 ≤1 人提前终局;末轮后 TL 在存活者中裁决(bid_award 块),胜者为王。打分失败兜底全员 5 分不阻塞流程。超时/失败候选 → TL 用已有材料照样判 +- **理由**:认领动作有理由、可审计,契合 HITL;候选上限 3 人控制成本;血条由真实打分驱动,不做假数据 +- **被否**:纯指派(保留为 TL 锁人选项);纯认领抢单(无人认领/错误认领风险);无界自由辩论(v2);单次裁决无血条(v1 原案,体验不足被 v1.1 替代) + +## 决策 3:执行载体与反馈 + 多线程(v1.1 修订:工作区聊天集成) + +- **选择**:任务 = 绑定 `(team, task)` 的独立 harness 会话 + 团队上下文注入(任务描述/竞标记录/黑板 top-K/花名册);完成产出三件套:结构化报告 + 黑板写入建议 + TL 唤醒事件;TL 验收运行判定通过/退回/升级。**任务全部后台异步执行**,统一线程列表跨团队自由切换;同一成员默认串行(并发上限团队级可配),跨团队不受限 +- **v1.1 补充**:`ChatSession` 增加 `team_id` 列(SQLite ALTER 迁移);团队 TL 会话落该标记,主聊天端点(`/api/chat/turn`、`/api/chat/stream`)命中团队 TL 会话时自动注入团队上下文并做派任务后处理(`process_tl_reply` 三处复用)。TL 对话因此是工作区 `/workspace/chat/:sessionId` 里的正规多轮流式会话,任务执行会话也可从看板直接跳转查看 +- **被否**:全团队共享长会话(上下文污染、无法并行);把人锚定在单任务对话的同步模型;团队页内嵌一次性对话面板(v1 原案,不符合 harness 会话特性,被工作区集成替代) + +## 决策 4:黑板语义 + +- **选择**:TL 裁决写入(并入验收运行,零额外成本)+ 双通道读取(启动注入 top-K + `blackboard` 查询工具)+ 按团队隔离;写入走**轻量入库流水线**(对齐 `backend/app/knowledge/service.py` 的 `INGEST_STAGES`):解析 → 规范化(去重/合并/更新,黑板是活文档)→ 结构化写入 → 生成引用(回链任务报告)→ 刷新黑板索引 → 高价值条目可升级走完整 `INGEST_STAGES` 沉淀到知识库 +- **被否**:原始内容直写;自由集市直写(信噪比失控);逐条独立审批(成本翻倍) + +## 决策 5:权限与验收 + +- **选择**:建团队与建员工同权,创建者为 Owner;角色 = Owner / 协作人 / 成员(含 TL 标记,可随时换任);黑板按团队隔离,跨团队流动只经由人;v1 不接飞书/微信渠道 +- **v1 验收**:建团队→人对 TL 下需求→TL 拆解投放→竞标留痕→中标执行→报告+黑板建议→TL 验收/人可改判→超时升级,全链路无任务丢失、步步有审计 +- **被否**:管理员审批建团队;v1 接渠道 + +## 表结构草案 + +- `teams`:id, tenant_id, name, description, owner_user_id, config_json(成员并发上限/竞标轮数/超时阈值), status, created_at, updated_at +- `team_members`:id, team_id, agent_id, role(`leader`/`member`), joined_at;唯一约束 `(team_id, agent_id)`;TL 只是 role,非特权实体 +- `team_tasks`:id, team_id, parent_task_id(拆解层级), title, description, priority, status(`pending/bidding/in_progress/review/rework/done/cancelled/escalated`), created_by, suggested_agent_id, assignee_agent_id, session_id(harness 会话绑定), timeout_at, report_json, review_json, version(乐观锁) +- `team_task_bids`:id, task_id, agent_id, round, kind(`statement/rebuttal`), content, score, score_rationale +- `team_task_events`:审计流水(task_id, actor_type, actor_id, event_type, payload_json) +- `team_blackboard_entries`:id, team_id, content, tags_json, source_agent_id, source_task_id, citation_json, status(`pending_review/active/archived`), pinned +- `team_wake_events`:唤醒队列(team_id, target_agent_id, trigger 类型, payload_json, status)——TL/成员的唤起机制 + +## 运行时边界 + +- **执行**:复用 Harness v2 的 session/run 设施;`team_tasks.session_id ↔ harness session` 绑定复用 `channel_binding_agents` 的既有模式 +- **唤醒**:`team_wake_events` 由任务状态变迁产生,消费侧是 `scheduled_tasks` 触发器的泛化(事件驱动,非常驻进程) +- **并发安全**:认领/中标落库用条件更新的原子 claim,渠道层 `service_intake.py` 已有成熟同款 +- **工具**:新增 `blackboard` 查询工具挂进成员执行会话 +- **前端**:新增 TeamsPage(团队管理)+ 任务看板(竞标记录、报告、改判入口)+ 统一线程列表;不动现有 AgentsPage 的单员工路径 +- **明确不做(v1)**:渠道接入、纯 agent TL 常驻循环、无界辩论、agent 自动跨团队信息流转 diff --git a/frontend-enterprise/src/App.test.tsx b/frontend-enterprise/src/App.test.tsx new file mode 100644 index 00000000..e3acb1c5 --- /dev/null +++ b/frontend-enterprise/src/App.test.tsx @@ -0,0 +1,178 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ENTERPRISE_AGENT_STORAGE_KEY } from '@/lib/agent-scope-storage'; +import { I18nProvider } from '@/i18n'; +import type { AgentProfileRead, TeamRead } from '@/types'; + +import App from './App'; + +const AUTH_STORAGE_KEY = 'ultrarag_auth'; + +const authUser = { + id: 'user-1', + tenant_id: 'tenant_demo', + username: 'demo', + role: 'admin', +}; + +const agent: AgentProfileRead = { + id: 'agent-1', + tenant_id: 'tenant_demo', + name: '小艾', + is_overall: false, + status: 'active', + metadata: {}, + resources: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +const team: TeamRead = { + id: 'team-1', + tenant_id: 'tenant_demo', + name: '增长团队', + description: '', + owner_user_id: 'user-1', + config: {}, + status: 'active', + members: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(body ?? {}), + } as Response; +} + +// 提供一个可用模型配置,避免聊天页弹出模型配置引导(jsdom 下其内部数据为空会报错)。 +const modelConfig = { + id: 'model-1', + tenant_id: 'tenant_demo', + name: '默认模型', + provider: 'openai', + api_protocol: 'openai_chat_completions', + api_key_masked: 'sk-***', + model: 'gpt-test', + temperature: 0.7, + max_output_tokens: 1024, + extra_body: {}, + protocol_options: {}, + legacy_unmapped_options: {}, + trust_status: 'verified', + verification_attempt_status: 'idle', + config_revision: 1, + security_revision: 1, + is_default: true, + enabled: true, +}; + +function stubAppFetch() { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method || 'GET').toUpperCase(); + if (method === 'POST' && url.includes('/tl/session')) { + return jsonResponse({ session_id: 'session-tl-1' }); + } + if (url.includes('/api/auth/me')) return jsonResponse(authUser); + if (url.includes('/api/enterprise/agents')) return jsonResponse([agent]); + if (url.includes('/api/enterprise/teams')) return jsonResponse([team]); + if (url.includes('/api/enterprise/model-configs')) return jsonResponse([modelConfig]); + if (url.includes('/api/chat/')) return jsonResponse([]); + if (url.includes('/api/enterprise/')) return jsonResponse([]); + return jsonResponse({}); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function stubBrowserApis() { + if (!window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; + } + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } +} + +beforeEach(() => { + stubBrowserApis(); + window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ token: 'token-1', user: authUser })); + window.localStorage.setItem('staffdeck_onboarding_guide_seen', '1'); + window.localStorage.setItem('staffdeck_quick_start_guide_seen', '1'); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.localStorage.clear(); + window.history.pushState({}, '', '/'); +}); + +describe('App team scope selection', () => { + it('creates the TL session and navigates to the chat when a team is selected', async () => { + const user = userEvent.setup(); + const fetchMock = stubAppFetch(); + window.localStorage.setItem(ENTERPRISE_AGENT_STORAGE_KEY, 'agent-1'); + window.history.pushState({}, '', '/enterprise/agents'); + render(); + + const switcher = await screen.findByLabelText('切换当前员工'); + await user.click(switcher); + const menu = await screen.findByRole('menu'); + const teamItem = within(menu) + .getAllByRole('menuitem') + .find((item) => item.textContent?.includes('增长团队')); + expect(teamItem).toBeTruthy(); + await user.click(teamItem!); + + await waitFor(() => { + expect(window.location.pathname).toBe('/workspace/chat/session-tl-1'); + }); + const postCall = fetchMock.mock.calls.find(([, init]) => ( + (init?.method || '').toUpperCase() === 'POST' + )); + expect(postCall).toBeTruthy(); + expect(String(postCall?.[0])).toContain('/api/enterprise/teams/team-1/tl/session'); + const body = JSON.parse(String(postCall?.[1]?.body)) as Record; + expect(body.tenant_id).toBeTruthy(); + expect(window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY)).toBe('team:team-1'); + }); + + it('keeps a preset team scope instead of resetting it to an employee on agents load', async () => { + stubAppFetch(); + window.localStorage.setItem(ENTERPRISE_AGENT_STORAGE_KEY, 'team:team-1'); + window.history.pushState({}, '', '/enterprise/agents'); + render(); + + const switcher = await screen.findByLabelText('切换当前员工'); + await waitFor(() => { + expect(switcher.textContent).toContain('当前团队'); + expect(switcher.textContent).toContain('增长团队'); + }); + expect(window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY)).toBe('team:team-1'); + }); +}); diff --git a/frontend-enterprise/src/App.tsx b/frontend-enterprise/src/App.tsx index 4309ca0a..bc4b75f6 100644 --- a/frontend-enterprise/src/App.tsx +++ b/frontend-enterprise/src/App.tsx @@ -56,6 +56,9 @@ import RuntimeSettingsPage from "./pages/RuntimeSettingsPage"; import OpenPlatformPage from "./pages/OpenPlatformPage"; import PersonaPage from "./pages/PersonaPage"; import SkillsPage from "./pages/SkillsPage"; +import TeamChatPage from "./pages/TeamChatPage"; +import TeamDetailPage from "./pages/TeamDetailPage"; +import TeamsPage from "./pages/TeamsPage"; import { ScheduledTaskEditPage, ScheduledTaskNewPage, @@ -87,7 +90,10 @@ import { notify } from "@/components/ui/app-toast"; import { emitAgentScopeChange, ENTERPRISE_AGENT_STORAGE_KEY, + isTeamScope, persistSharedAgentScope, + teamIdFromScope, + toTeamScope, } from "@/lib/agent-scope-storage"; import { cn } from "@/lib/utils"; import { @@ -96,7 +102,7 @@ import { DIALOG_FOOTER_CLASS, DIALOG_PRIMARY_BUTTON_CLASS, } from "@/lib/enterprise-ui"; -import type { AgentProfileRead, ModelConfigRead } from "./types"; +import type { AgentProfileRead, ModelConfigRead, TeamRead } from "./types"; import { useI18n } from "./i18n"; const ENTERPRISE_SIDEBAR_STORAGE_KEY = "ultrarag_enterprise_sidebar_expanded"; @@ -133,6 +139,7 @@ function Shell({ const { t } = useI18n(); const [agents, setAgents] = useState([]); const [agentsLoaded, setAgentsLoaded] = useState(false); + const [scopeTeams, setScopeTeams] = useState([]); const [selectedAgentId, setSelectedAgentId] = useState( () => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || "", ); @@ -160,7 +167,9 @@ function Shell({ ? "/enterprise/general-skills" : location.pathname.startsWith("/enterprise/tools") ? "/enterprise/tools" - : location.pathname.startsWith("/enterprise/scheduled-tasks") + : location.pathname.startsWith("/enterprise/teams") + ? "/enterprise/teams" + : location.pathname.startsWith("/enterprise/scheduled-tasks") ? "/enterprise/scheduled-tasks" : isDistillRoute ? "/enterprise/skills" @@ -183,6 +192,7 @@ function Shell({ useEffect(() => { loadAgents(); + loadTeams(); }, []); const loadModelConfigs = useCallback(() => { @@ -249,7 +259,7 @@ function Shell({ (event as CustomEvent<{ agentId?: string }>).detail?.agentId || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ""; - if (nextAgentId) { + if (nextAgentId && !isTeamScope(nextAgentId)) { persistSharedAgentScope(nextAgentId, auth.user.id); const knownSelectableAgent = agents.some( (item) => item.id === nextAgentId && canUseAgentScope(item), @@ -279,6 +289,13 @@ function Shell({ ); }, []); + function loadTeams() { + return api + .get(`/api/enterprise/teams?tenant_id=${TENANT_ID}`) + .then((rows) => setScopeTeams(rows)) + .catch(() => setScopeTeams([])); + } + function loadAgents(preferredAgentId = "") { return api .get(`/api/enterprise/agents?tenant_id=${TENANT_ID}`) @@ -286,6 +303,8 @@ function Shell({ setAgents(rows); const selectableRows = rows.filter((item) => canUseAgentScope(item)); setSelectedAgentId((current) => { + // A team scope is not part of the employee roster; keep it untouched. + if (isTeamScope(current)) return current; const requestedAgentId = preferredAgentId || current; if ( requestedAgentId && @@ -325,6 +344,23 @@ function Shell({ emitAgentScopeChange(agentId); } + async function selectTeamScope(teamId: string) { + const scope = toTeamScope(teamId); + try { + const result = await api.post<{ session_id: string }>( + `/api/enterprise/teams/${teamId}/tl/session`, + { tenant_id: TENANT_ID }, + ); + if (!result.session_id) throw new Error("发起对话失败"); + setSelectedAgentId(scope); + persistSharedAgentScope(scope, auth.user.id); + emitAgentScopeChange(scope); + navigate(`/workspace/chat/${result.session_id}`); + } catch (error) { + notify.error(error instanceof Error ? error.message : "发起对话失败"); + } + } + function handleSidebarOpenChange(open: boolean) { setSidebarExpanded(open); window.localStorage.setItem( @@ -375,7 +411,7 @@ function Shell({ function openCreateAgentModal() { setAgentForm({ ...EMPTY_AGENT_FORM, - copyFromAgentId: selectedAgentId || sourceAgents[0]?.id || "", + copyFromAgentId: (isTeamScope(selectedAgentId) ? "" : selectedAgentId) || sourceAgents[0]?.id || "", }); setAgentCreateOpen(true); } @@ -467,8 +503,15 @@ function Shell({ isAdmin={isAdmin} sidebarAgent={sidebarAgent} scopeAgents={scopeAgents} + scopeTeams={scopeTeams} selectedAgentId={selectedAgentId} onSelectAgent={(agentId) => { + const teamId = teamIdFromScope(agentId); + if (teamId) { + // 选择团队 = 开聊:tl/session 是 get-or-create,重复点击会回到同一会话。 + void selectTeamScope(teamId); + return; + } if (agentId !== selectedAgentId) changeAgentScope(agentId); navigate(EnterpriseRoute.Dashboard); }} @@ -569,6 +612,36 @@ function Shell({ /> } /> + + } + /> + + } + /> + + } + /> + + + + + diff --git a/frontend-enterprise/src/components/AppSidebar.test.tsx b/frontend-enterprise/src/components/AppSidebar.test.tsx new file mode 100644 index 00000000..1b9f1403 --- /dev/null +++ b/frontend-enterprise/src/components/AppSidebar.test.tsx @@ -0,0 +1,233 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '@/i18n'; +import { SidebarProvider } from '@/components/ui/sidebar'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import type { AgentProfileRead, TeamRead } from '@/types'; + +import AppSidebar from './AppSidebar'; + +const agent: AgentProfileRead = { + id: 'agent-1', + tenant_id: 'tenant_demo', + name: '小艾', + is_overall: false, + status: 'active', + metadata: {}, + resources: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +const team: TeamRead = { + id: 'team-1', + tenant_id: 'tenant_demo', + name: '增长团队', + description: '', + owner_user_id: 'user-1', + config: {}, + status: 'active', + members: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +function stubRadixPointerApis() { + if (!window.ResizeObserver) { + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + } + if (!window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; + } + if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; + } + if (!Element.prototype.releasePointerCapture) { + Element.prototype.releasePointerCapture = () => {}; + } + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } +} + +function renderSidebar(props: { + selectedAgentId: string; + onSelectAgent?: (value: string) => void; + scopeTeams?: TeamRead[]; +}) { + return render( + + + + {}} + isAdmin={false} + scopeAgents={[agent]} + scopeTeams={props.scopeTeams ?? [team]} + selectedAgentId={props.selectedAgentId} + onSelectAgent={props.onSelectAgent ?? (() => {})} + onOpenChat={() => {}} + /> + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +beforeEach(() => { + stubRadixPointerApis(); +}); + +describe('AppSidebar agent switcher team group', () => { + it('lists teams under a team group and emits the team scope value on select', async () => { + const user = userEvent.setup(); + const onSelectAgent = vi.fn(); + renderSidebar({ selectedAgentId: 'agent-1', onSelectAgent }); + + await user.click(screen.getByLabelText('切换当前员工')); + const menu = await screen.findByRole('menu'); + // 分组标签与团队项上的 Badge 都写作「团队」。 + expect(within(menu).getAllByText('团队').length).toBeGreaterThanOrEqual(2); + const teamItem = within(menu) + .getAllByRole('menuitem') + .find((item) => item.textContent?.includes('增长团队')); + expect(teamItem).toBeTruthy(); + + await user.click(teamItem!); + expect(onSelectAgent).toHaveBeenCalledWith('team:team-1'); + }); + + it('shows the team name and team caption when a team scope is selected', () => { + renderSidebar({ selectedAgentId: 'team:team-1' }); + + const trigger = screen.getByLabelText('切换当前员工'); + expect(within(trigger).getByText('当前团队')).toBeTruthy(); + expect(within(trigger).getByText('增长团队')).toBeTruthy(); + }); + + it('falls back to a bare team label when the selected team is unknown', () => { + renderSidebar({ selectedAgentId: 'team:missing', scopeTeams: [team] }); + + const trigger = screen.getByLabelText('切换当前员工'); + expect(within(trigger).getByText('当前团队')).toBeTruthy(); + expect(within(trigger).getByText('团队')).toBeTruthy(); + }); +}); + +describe('AppSidebar chat variant team leader badge', () => { + const leaderAgent: AgentProfileRead = { ...agent, id: 'agent-tl', name: '队长' }; + const teamWithLeader: TeamRead = { + ...team, + members: [ + { + id: 'member-1', + team_id: 'team-1', + agent_id: 'agent-tl', + role: 'leader', + agent_name: '队长', + created_at: '2026-08-01T00:00:00Z', + }, + { + id: 'member-2', + team_id: 'team-1', + agent_id: 'agent-1', + role: 'member', + agent_name: '小艾', + created_at: '2026-08-01T00:00:00Z', + }, + ], + }; + + function renderChatSidebar() { + const sessions = [ + { + id: 'session-tl', + tenant_id: 'tenant_demo', + agent_id: 'agent-tl', + team_id: 'team-1', + title: 'TL 对话', + status: 'active', + updated_at: '2026-08-01T00:00:00Z', + }, + { + id: 'session-member', + tenant_id: 'tenant_demo', + agent_id: 'agent-1', + team_id: 'team-1', + title: '团队任务:写公告', + status: 'active', + updated_at: '2026-08-01T00:00:00Z', + }, + ]; + return render( + + + + {}} + sessionFilterOptions={[{ value: 'all', label: '全部员工 · 2' }]} + isSessionUnread={() => false} + onOpenSession={() => {}} + onOpenGallery={() => {}} + onRenameSession={() => {}} + onDeleteSession={() => {}} + onOpenAdmin={() => {}} + /> + + + , + ); + } + + it('marks the team leader session row with a TL badge only', () => { + renderChatSidebar(); + + expect(screen.getByLabelText('团队 TL')).toBeTruthy(); + expect(screen.getAllByLabelText('团队 TL')).toHaveLength(1); + const tlRow = screen.getByText('TL 对话').closest('[role="button"]'); + expect(tlRow?.textContent).toContain('TL'); + const memberRow = screen.getByText('团队任务:写公告').closest('[role="button"]'); + expect(memberRow?.textContent).not.toContain('TL'); + }); + + it('shows the employee name in a styled tooltip on avatar hover', async () => { + const user = userEvent.setup(); + renderChatSidebar(); + + const tlRow = screen.getByText('TL 对话').closest('[role="button"]'); + const avatar = tlRow?.querySelector('span'); + expect(avatar).toBeTruthy(); + await user.hover(avatar as Element); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip.textContent).toContain('队长'); + }); +}); diff --git a/frontend-enterprise/src/components/AppSidebar.tsx b/frontend-enterprise/src/components/AppSidebar.tsx index 0b0ff4cf..00d79c3c 100644 --- a/frontend-enterprise/src/components/AppSidebar.tsx +++ b/frontend-enterprise/src/components/AppSidebar.tsx @@ -1,8 +1,10 @@ import { useMemo } from 'react'; import { + Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui'; import { @@ -18,14 +20,16 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { ComponentType, SVGProps } from 'react'; import { cn } from '@/lib/utils'; +import { teamIdFromScope, toTeamScope } from '@/lib/agent-scope-storage'; import EmployeeAvatar from './EmployeeAvatar'; import BrandLogo from './BrandLogo'; import StaffdeckIcon from './StaffdeckIcon'; import { employeeDisplayNameWithCreator, employeeProfile, staffdeckDisplayText } from '../employee'; import { EnterpriseRoute } from '../enums/routes'; -import type { AgentProfileRead, ChatSession } from '../types'; +import type { AgentProfileRead, ChatSession, TeamRead } from '../types'; import IconPlatform from '../assets/icons/nav-platform.svg?react'; import IconAgents from '../assets/icons/nav-agents.svg?react'; +import IconTeams from '../assets/icons/nav-teams.svg?react'; import IconFile from '../assets/icons/profile-file.svg?react'; import IconAlarm from '../assets/icons/profile-alarm.svg?react'; import IconHistory from '../assets/icons/profile-history.svg?react'; @@ -60,6 +64,7 @@ type NavItem = { const PRIMARY_NAV: NavItem[] = [ { route: EnterpriseRoute.Platform, label: '开放广场平台', Icon: IconPlatform }, { route: EnterpriseRoute.Agents, label: '我的数字员工', Icon: IconAgents }, + { route: EnterpriseRoute.Teams, label: '我的团队', Icon: IconTeams }, { route: EnterpriseRoute.Channels, label: '渠道接入', Icon: IconGlobe }, ]; @@ -94,6 +99,7 @@ export type AppSidebarManagementProps = { isAdmin: boolean; sidebarAgent?: AgentProfileRead; scopeAgents: AgentProfileRead[]; + scopeTeams?: TeamRead[]; selectedAgentId: string; onSelectAgent: (agentId: string) => void; onOpenChat: () => void; @@ -110,6 +116,8 @@ export type AppSidebarChatProps = { sessionsLoading?: boolean; /** Full agent roster, used to resolve per-session avatars/roles. */ agents: AgentProfileRead[]; + /** Team roster, used to mark the team leader (TL) in team session rows. */ + scopeTeams?: TeamRead[]; activeSessionId?: string; sessionFilter: string; onSessionFilterChange: (value: string) => void; @@ -207,18 +215,61 @@ function GroupLabel({ children }: { children: string }) { ); } +function TeamScopeItems({ + scopeTeams, + selectedAgentId, + onSelectAgent, +}: Pick & { scopeTeams: TeamRead[] }) { + if (scopeTeams.length === 0) return null; + return ( + <> + + 团队 + + {scopeTeams.map((team) => ( + onSelectAgent(toTeamScope(team.id))} + className="shrink-0 gap-2 rounded-[14px] cursor-pointer focus:bg-[#F6F6F6] focus:[&_strong]:text-foreground! data-[active=true]:bg-[#F6F6F6] data-[active=true]:[&_strong]:text-foreground!" + > + + + + + {team.name} + + + 团队 + + + ))} + + ); +} + function AgentSwitcher({ sidebarAgent, scopeAgents, + scopeTeams = [], selectedAgentId, onSelectAgent, -}: Pick) { +}: Pick) { const employeeAgents = scopeAgents.filter((agent) => !agent.is_overall); const currentAgent = sidebarAgent && !sidebarAgent.is_overall ? sidebarAgent : undefined; - const caption = currentAgent ? '当前员工' : '未选择'; - const nameLabel = currentAgent - ? employeeDisplayNameWithCreator(currentAgent) - : '-'; + const selectedTeamId = teamIdFromScope(selectedAgentId); + const currentTeam = selectedTeamId + ? scopeTeams.find((team) => team.id === selectedTeamId) + : undefined; + const caption = selectedTeamId ? '当前团队' : currentAgent ? '当前员工' : '未选择'; + const nameLabel = selectedTeamId + ? currentTeam?.name || '团队' + : currentAgent + ? employeeDisplayNameWithCreator(currentAgent) + : '-'; return ( @@ -240,7 +291,7 @@ function AgentSwitcher({
- + {selectedTeamId ? : }
@@ -276,6 +327,11 @@ function AgentSwitcher({ ))} +
); @@ -370,14 +426,16 @@ function CollapsedNavButton({ function CollapsedAgentSwitcher({ sidebarAgent, scopeAgents, + scopeTeams = [], selectedAgentId, onSelectAgent, nameLabel, -}: Pick & { +}: Pick & { nameLabel: string; }) { const employeeAgents = scopeAgents.filter((agent) => !agent.is_overall); const currentAgent = sidebarAgent && !sidebarAgent.is_overall ? sidebarAgent : undefined; + const selectedTeamId = teamIdFromScope(selectedAgentId); return ( @@ -390,7 +448,7 @@ function CollapsedAgentSwitcher({ ) : ( - + {selectedTeamId ? : } )} @@ -417,6 +475,11 @@ function CollapsedAgentSwitcher({ ))} + ); @@ -428,6 +491,7 @@ function CollapsedSidebar({ isAdmin, sidebarAgent, scopeAgents, + scopeTeams = [], selectedAgentId, onSelectAgent, onOpenChat, @@ -435,13 +499,19 @@ function CollapsedSidebar({ modelSetupAttention, }: Pick< AppSidebarManagementProps, - 'selected' | 'onNavigate' | 'isAdmin' | 'sidebarAgent' | 'scopeAgents' | 'selectedAgentId' | 'onSelectAgent' | 'onOpenChat' | 'modelSetupAttention' + 'selected' | 'onNavigate' | 'isAdmin' | 'sidebarAgent' | 'scopeAgents' | 'scopeTeams' | 'selectedAgentId' | 'onSelectAgent' | 'onOpenChat' | 'modelSetupAttention' > & { onToggle: () => void }) { - const nameLabel = sidebarAgent - ? sidebarAgent.is_overall - ? '未选择' - : employeeDisplayNameWithCreator(sidebarAgent) - : '未选择'; + const selectedTeamId = teamIdFromScope(selectedAgentId); + const selectedTeam = selectedTeamId + ? scopeTeams.find((team) => team.id === selectedTeamId) + : undefined; + const nameLabel = selectedTeamId + ? selectedTeam?.name || '团队' + : sidebarAgent + ? sidebarAgent.is_overall + ? '未选择' + : employeeDisplayNameWithCreator(sidebarAgent) + : '未选择'; const primaryItems = primaryNavItems(isAdmin); return ( @@ -487,6 +557,7 @@ function CollapsedSidebar({ @@ -829,6 +903,7 @@ function ChatSessionRow({ agent, active, unread, + isTeamLeader = false, onOpenSession, onRenameSession, onDeleteSession, @@ -837,6 +912,7 @@ function ChatSessionRow({ agent: AgentProfileRead | null; active: boolean; unread: boolean; + isTeamLeader?: boolean; onOpenSession: (id: string) => void; onRenameSession: (session: ChatSession) => void; onDeleteSession: (session: ChatSession) => void; @@ -862,16 +938,35 @@ function ChatSessionRow({ : 'border-[0.5px] border-transparent hover:bg-[#f4f5f7]', )} > - - {agent ? ( - - ) : ( - + + + + {agent ? ( + + ) : ( + + )} + + + {agent && ( + + {agent.name} + )} - + - - {title} + + + {title} + + {isTeamLeader && ( + + TL + + )} {subtitle} @@ -1088,7 +1183,7 @@ function CollapsedChatSidebar({ - {title} + {agent ? `${agent.name} · ${title}` : title} ); @@ -1121,6 +1216,7 @@ function ChatSidebarVariant({ sessions, sessionsLoading = false, agents, + scopeTeams = [], activeSessionId, sessionFilter, onSessionFilterChange, @@ -1139,6 +1235,12 @@ function ChatSidebarVariant({ const { toggleSidebar, state } = useSidebar(); const collapsed = state === 'collapsed'; const showSkeleton = sessionsLoading && sessions.length === 0; + // team_id → TL agent_id:用于在团队会话行上标明 TL 身份 + const teamLeaderByTeam = new Map(); + for (const team of scopeTeams) { + const leader = (team.members || []).find((member) => member.role === 'leader'); + if (leader) teamLeaderByTeam.set(team.id, leader.agent_id); + } if (collapsed) { return ( @@ -1238,6 +1340,9 @@ function ChatSidebarVariant({ agent={sessionAgentFor(session, agents)} active={session.id === activeSessionId} unread={isSessionUnread(session)} + isTeamLeader={Boolean( + session.team_id && teamLeaderByTeam.get(session.team_id) === session.agent_id, + )} onOpenSession={onOpenSession} onRenameSession={onRenameSession} onDeleteSession={onDeleteSession} diff --git a/frontend-enterprise/src/components/BiddingArena.test.tsx b/frontend-enterprise/src/components/BiddingArena.test.tsx new file mode 100644 index 00000000..bb81451f --- /dev/null +++ b/frontend-enterprise/src/components/BiddingArena.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { TeamTaskBidRead } from '@/types'; + +import BiddingArena, { computeBidHp } from './BiddingArena'; + +function makeBid(overrides: Partial): TeamTaskBidRead { + return { + id: 'bid-1', + task_id: 'task-1', + agent_id: 'agent-1', + agent_name: '小艾', + round: 1, + kind: 'statement', + content: '陈述内容', + score: null, + score_rationale: null, + created_at: '2026-08-02T00:00:00Z', + ...overrides, + }; +} + +afterEach(cleanup); + +describe('computeBidHp', () => { + it('starts at 100 and deducts (10 - score) * 3 per scored round', () => { + const bids = [ + makeBid({ id: 'bid-1', round: 1, score: 8 }), + makeBid({ id: 'bid-2', round: 2, score: 5 }), + ]; + expect(computeBidHp(bids)).toBe(100 - 6 - 15); + }); + + it('floors HP at 0', () => { + const bids = [1, 2, 3, 4].map((round) => makeBid({ id: `bid-${round}`, round, score: 0 })); + expect(computeBidHp(bids)).toBe(0); + }); + + it('keeps full HP for legacy bids without scores', () => { + expect(computeBidHp([makeBid({})])).toBe(100); + expect(computeBidHp([])).toBe(100); + }); +}); + +describe('BiddingArena', () => { + it('renders the winner crown, HP bar width and elimination state', () => { + const bids = [ + makeBid({ id: 'bid-1', agent_id: 'agent-1', agent_name: '小艾', round: 1, score: 9 }), + makeBid({ + id: 'bid-2', + agent_id: 'agent-2', + agent_name: '小北', + round: 1, + kind: 'rebuttal', + content: '反驳内容', + score: 0, + }), + makeBid({ id: 'bid-3', agent_id: 'agent-2', agent_name: '小北', round: 2, score: 0 }), + makeBid({ id: 'bid-4', agent_id: 'agent-2', agent_name: '小北', round: 3, score: 1 }), + makeBid({ id: 'bid-5', agent_id: 'agent-2', agent_name: '小北', round: 4, score: 0 }), + ]; + render(); + + expect(screen.getByText('胜者为王')).toBeTruthy(); + expect(screen.getByText('淘汰')).toBeTruthy(); + expect(screen.getByText('HP 97')).toBeTruthy(); + expect(screen.getByText('HP 0')).toBeTruthy(); + expect(screen.getByTestId('arena-hp-agent-1').style.width).toBe('97%'); + expect(screen.getByTestId('arena-hp-agent-2').style.width).toBe('0%'); + expect(screen.getAllByText('陈述内容').length).toBeGreaterThan(0); + expect(screen.getByText('反驳内容')).toBeTruthy(); + expect(screen.getByText('反驳')).toBeTruthy(); + }); +}); diff --git a/frontend-enterprise/src/components/BiddingArena.tsx b/frontend-enterprise/src/components/BiddingArena.tsx new file mode 100644 index 00000000..5ff5447c --- /dev/null +++ b/frontend-enterprise/src/components/BiddingArena.tsx @@ -0,0 +1,162 @@ +import { Crown } from 'lucide-react'; + +import { Badge } from '@/components/ui'; +import { cn } from '@/lib/utils'; + +import type { AgentProfileRead, TeamTaskBidRead } from '../types'; + +import EmployeeAvatar from './EmployeeAvatar'; + +export const BID_HP_MAX = 100; + +/** HP 扣减 = (10 - 该轮得分) × 3,下限 0;无得分的历史数据按满血显示。 */ +export function computeBidHp(bids: TeamTaskBidRead[]): number { + let hp = BID_HP_MAX; + bids.forEach((bid) => { + if (bid.score == null) return; + hp -= (10 - bid.score) * 3; + }); + return Math.max(0, Math.min(BID_HP_MAX, hp)); +} + +function hpBarClass(hp: number): string { + if (hp <= 0) return 'bg-[#c3c8d4]'; + if (hp < 30) return 'bg-[#f5483b]'; + if (hp < 60) return 'bg-[#f5a83b]'; + return 'bg-[#35b26f]'; +} + +const BUBBLE_STYLES = [ + 'bg-[#e8f0ff]', + 'bg-[#eaf7ee]', + 'bg-[#fdf1e3]', + 'bg-[#f3e8ff]', + 'bg-[#e6f6f7]', +]; + +function bidKindLabel(kind: string): string { + if (kind === 'rebuttal') return '反驳'; + if (kind === 'statement') return '陈述'; + return kind; +} + +type BiddingArenaProps = { + bids: TeamTaskBidRead[]; + /** 已裁决时的胜者 agent_id;竞标中或未裁决传 null。 */ + winnerId?: string | null; + /** 用于解析候选人头像。 */ + agents?: AgentProfileRead[]; + /** bid.agent_name 缺失时的兜底名称解析。 */ + resolveName?: (agentId: string) => string; +}; + +export default function BiddingArena({ + bids, + winnerId = null, + agents = [], + resolveName, +}: BiddingArenaProps) { + const agentById = new Map(agents.map((agent) => [agent.id, agent])); + + const candidateIds: string[] = []; + const bidsByAgent = new Map(); + bids.forEach((bid) => { + if (!bidsByAgent.has(bid.agent_id)) { + bidsByAgent.set(bid.agent_id, []); + candidateIds.push(bid.agent_id); + } + bidsByAgent.get(bid.agent_id)?.push(bid); + }); + + const rounds = [...new Set(bids.map((bid) => bid.round))].sort((a, b) => a - b); + + function candidateName(bid: TeamTaskBidRead | undefined, agentId: string): string { + return bid?.agent_name || resolveName?.(agentId) || agentId; + } + + return ( +
+ {candidateIds.map((agentId, index) => { + const candidateBids = bidsByAgent.get(agentId) || []; + const hp = computeBidHp(candidateBids); + const eliminated = hp <= 0; + const isWinner = winnerId === agentId; + const bubbleStyle = BUBBLE_STYLES[index % BUBBLE_STYLES.length]; + return ( +
+
+ + + {candidateName(candidateBids[0], agentId)} + + {isWinner && ( + + + 胜者为王 + + )} + {eliminated && ( + + 淘汰 + + )} + {`HP ${hp}`} +
+
+
+
+
+ {rounds.map((round) => { + const roundBids = candidateBids.filter((bid) => bid.round === round); + if (roundBids.length === 0) return null; + return ( +
+

{`第 ${round} 轮`}

+
+ {roundBids.map((bid) => ( +
+
+ {bidKindLabel(bid.kind)} + {bid.score != null && {`得分:${bid.score}`}} +
+

+ {bid.content} +

+ {bid.score_rationale && ( +

+ {bid.score_rationale} +

+ )} +
+ ))} +
+
+ ); + })} +
+
+ ); + })} +
+ ); +} diff --git a/frontend-enterprise/src/components/KnowledgeGraphCanvas.tsx b/frontend-enterprise/src/components/KnowledgeGraphCanvas.tsx new file mode 100644 index 00000000..28a3dbf5 --- /dev/null +++ b/frontend-enterprise/src/components/KnowledgeGraphCanvas.tsx @@ -0,0 +1,602 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; +import type { KnowledgeConceptRead } from '@/types'; + +const CANVAS_WIDTH = 1000; +const DEFAULT_HEIGHT = 560; +const CLICK_SLOP_PX = 4; +const RING_GAP = 28; +const ORPHANS_PER_ROW = 6; + +type GraphNode = { + id: string; + concept: KnowledgeConceptRead; + label: string; + color: string; + isSource: boolean; + width: number; + height: number; + x: number; + y: number; +}; + +type GraphEdge = { + source: number; + target: number; + kind: 'link' | 'citation'; +}; + +type GraphData = { + nodes: GraphNode[]; + edges: GraphEdge[]; +}; + +type ViewTransform = { + k: number; + tx: number; + ty: number; +}; + +const TYPE_STYLES: Record = { + 'Source Document': { color: '#1a71ff', label: '原始资料' }, + 'Source Section': { color: '#0ea5e9', label: '资料页' }, + Topic: { color: '#22c55e', label: '主题' }, + Playbook: { color: '#a855f7', label: '流程知识' }, + 'Business Rule': { color: '#f59e0b', label: '业务规则' }, + 'Query Analysis': { color: '#d946ef', label: '查询分析' }, +}; +const FALLBACK_TYPE_STYLE = { color: '#94a3b8', label: '概念' }; +const TYPE_ORDER = Object.keys(TYPE_STYLES); + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function normalizeConceptRef(target: string) { + let value = target.trim(); + if (value.startsWith('/')) value = value.slice(1); + if (value.endsWith('.md')) value = value.slice(0, -3); + return value; +} + +function extractCitationDocumentId(target: string) { + const match = /documents\/([^/?#\s]+)/.exec(target); + return match ? decodeURIComponent(match[1]) : null; +} + +function truncateLabel(value: string, max = 12) { + return value.length > max ? `${value.slice(0, max)}…` : value; +} + +function measureLabel(text: string, fontSize: number) { + let width = 0; + for (const char of text) { + width += /[⺀-鿿＀-・¢-¥]/.test(char) ? fontSize : fontSize * 0.56; + } + return width; +} + +function buildGraph(concepts: KnowledgeConceptRead[]): GraphData { + const nodes: GraphNode[] = []; + const indexById = new Map(); + const indexByDocument = new Map(); + + concepts.forEach((concept) => { + if (!concept.concept_id || indexById.has(concept.concept_id)) return; + const style = TYPE_STYLES[concept.concept_type] || FALLBACK_TYPE_STYLE; + const isSource = concept.concept_type === 'Source Document'; + const label = truncateLabel(concept.title || concept.concept_id); + const fontSize = isSource ? 12 : 11; + const textWidth = measureLabel(label, fontSize); + indexById.set(concept.concept_id, nodes.length); + nodes.push({ + id: concept.concept_id, + concept, + label, + color: style.color, + isSource, + width: Math.ceil(textWidth + (isSource ? 42 : 36)), + height: isSource ? 32 : 28, + x: 0, + y: 0, + }); + }); + + nodes.forEach((node, index) => { + const documentId = node.concept.document_id; + if (!documentId) return; + const existing = indexByDocument.get(documentId); + if (existing === undefined || node.concept.concept_type === 'Source Document') { + indexByDocument.set(documentId, index); + } + }); + + const edges: GraphEdge[] = []; + const seen = new Set(); + nodes.forEach((node, source) => { + (Array.isArray(node.concept.links) ? node.concept.links : []).forEach((link) => { + const rawTarget = typeof link?.target === 'string' ? link.target : ''; + if (!rawTarget) return; + const target = indexById.get(normalizeConceptRef(rawTarget)); + if (target === undefined || target === source) return; + const key = `${source}->${target}:link`; + if (seen.has(key)) return; + seen.add(key); + edges.push({ source, target, kind: 'link' }); + }); + (Array.isArray(node.concept.citations) ? node.concept.citations : []).forEach((citation) => { + const rawTarget = typeof citation?.target === 'string' ? citation.target : ''; + const documentId = rawTarget ? extractCitationDocumentId(rawTarget) : null; + const target = documentId ? indexByDocument.get(documentId) : undefined; + if (target === undefined || target === source) return; + const key = `${source}->${target}:citation`; + if (seen.has(key)) return; + seen.add(key); + edges.push({ source, target, kind: 'citation' }); + }); + }); + + return { nodes, edges }; +} + +function compareNodeIndices(nodes: GraphNode[]) { + return (left: number, right: number) => { + const leftRank = TYPE_ORDER.indexOf(nodes[left].concept.concept_type); + const rightRank = TYPE_ORDER.indexOf(nodes[right].concept.concept_type); + const leftOrder = leftRank === -1 ? TYPE_ORDER.length : leftRank; + const rightOrder = rightRank === -1 ? TYPE_ORDER.length : rightRank; + if (leftOrder !== rightOrder) return leftOrder - rightOrder; + return nodes[left].label.localeCompare(nodes[right].label, 'zh-CN'); + }; +} + +// Deterministic layered radial layout: source document at the center, its direct +// neighbors on the first ring, remaining connected nodes on a second ring, and +// orphan nodes in rows below. Positions are computed once; interactions never +// re-run the layout, so dragging a node can never move any other node. +function layoutGraph(graph: GraphData) { + const { nodes, edges } = graph; + if (nodes.length === 0) return; + + const degree = new Array(nodes.length).fill(0); + const neighbors = nodes.map(() => new Set()); + edges.forEach((edge) => { + degree[edge.source] += 1; + degree[edge.target] += 1; + neighbors[edge.source].add(edge.target); + neighbors[edge.target].add(edge.source); + }); + + let center = 0; + let bestScore = -1; + nodes.forEach((node, index) => { + const score = (node.isSource ? 100000 : 0) + degree[index]; + if (score > bestScore) { + bestScore = score; + center = index; + } + }); + + const byType = compareNodeIndices(nodes); + const ring1 = [...neighbors[center]].sort(byType); + const assigned = new Set([center, ...ring1]); + const rest = nodes.map((_, index) => index).filter((index) => !assigned.has(index)); + const ring2 = rest.filter((index) => degree[index] > 0).sort(byType); + const orphans = rest.filter((index) => degree[index] === 0).sort(byType); + + nodes[center].x = 0; + nodes[center].y = 0; + + const ring1Arc = ring1.reduce((sum, index) => sum + nodes[index].width + RING_GAP, 0); + const radius1 = Math.max(200, ring1Arc / (2 * Math.PI)); + ring1.forEach((index, position) => { + const angle = -Math.PI / 2 + (position / ring1.length) * Math.PI * 2; + nodes[index].x = Math.cos(angle) * radius1; + nodes[index].y = Math.sin(angle) * radius1; + }); + + const ring2Arc = ring2.reduce((sum, index) => sum + nodes[index].width + RING_GAP, 0); + const radius2 = Math.max(radius1 + 130, ring2Arc / (2 * Math.PI)); + ring2.forEach((index, position) => { + const angle = -Math.PI / 2 + ((position + 0.5) / ring2.length) * Math.PI * 2; + nodes[index].x = Math.cos(angle) * radius2; + nodes[index].y = Math.sin(angle) * radius2; + }); + + const orphanTop = (ring2.length > 0 ? radius2 : radius1) + 140; + for (let row = 0; row * ORPHANS_PER_ROW < orphans.length; row += 1) { + const rowItems = orphans.slice(row * ORPHANS_PER_ROW, (row + 1) * ORPHANS_PER_ROW); + const rowWidth = + rowItems.reduce((sum, index) => sum + nodes[index].width, 0) + (rowItems.length - 1) * 20; + let cursor = -rowWidth / 2; + rowItems.forEach((index) => { + nodes[index].x = cursor + nodes[index].width / 2; + nodes[index].y = orphanTop + row * 48; + cursor += nodes[index].width + 20; + }); + } +} + +function computeFitView(graph: GraphData, height: number): ViewTransform { + if (graph.nodes.length === 0) { + return { k: 1, tx: CANVAS_WIDTH / 2, ty: height / 2 }; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + graph.nodes.forEach((node) => { + minX = Math.min(minX, node.x - node.width / 2); + minY = Math.min(minY, node.y - node.height / 2); + maxX = Math.max(maxX, node.x + node.width / 2); + maxY = Math.max(maxY, node.y + node.height / 2); + }); + const boundsWidth = Math.max(maxX - minX, 1); + const boundsHeight = Math.max(maxY - minY, 1); + const k = clamp(Math.min((CANVAS_WIDTH - 120) / boundsWidth, (height - 120) / boundsHeight), 0.2, 1.1); + return { + k, + tx: CANVAS_WIDTH / 2 - ((minX + maxX) / 2) * k, + ty: height / 2 - ((minY + maxY) / 2) * k, + }; +} + +// Distance from a pill node center to its border along a unit direction, +// approximating the pill with an ellipse. +function nodeBorderRadius(node: GraphNode, ux: number, uy: number) { + const a = node.width / 2 + 2; + const b = node.height / 2 + 2; + return (a * b) / Math.sqrt(b * b * ux * ux + a * a * uy * uy); +} + +export default function KnowledgeGraphCanvas({ + concepts, + onSelectConcept, + height = DEFAULT_HEIGHT, +}: { + concepts: KnowledgeConceptRead[]; + onSelectConcept: (concept: KnowledgeConceptRead) => void; + height?: number; +}) { + const graph = useMemo(() => { + const data = buildGraph(concepts); + layoutGraph(data); + return data; + }, [concepts]); + const fitView = useMemo(() => computeFitView(graph, height), [graph, height]); + const svgRef = useRef(null); + const viewRef = useRef(fitView); + const nodeDragRef = useRef<{ + id: string; + startX: number; + startY: number; + baseX: number; + baseY: number; + moved: boolean; + } | null>(null); + const panRef = useRef<{ startX: number; startY: number; tx: number; ty: number } | null>(null); + const [view, setView] = useState(fitView); + const [offsets, setOffsets] = useState>({}); + const [hoverId, setHoverId] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const [isPanning, setIsPanning] = useState(false); + + useEffect(() => { + viewRef.current = view; + }, [view]); + + useEffect(() => { + setView(fitView); + viewRef.current = fitView; + setOffsets({}); + setSelectedId(null); + setHoverId(null); + }, [fitView]); + + const positions = useMemo(() => { + const map = new Map(); + graph.nodes.forEach((node) => { + const offset = offsets[node.id]; + map.set(node.id, { x: node.x + (offset?.x ?? 0), y: node.y + (offset?.y ?? 0) }); + }); + return map; + }, [graph, offsets]); + + useEffect(() => { + const svg = svgRef.current; + if (!svg) return undefined; + const onWheel = (event: WheelEvent) => { + event.preventDefault(); + const rect = svg.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + const localX = ((event.clientX - rect.left) / rect.width) * CANVAS_WIDTH; + const localY = ((event.clientY - rect.top) / rect.height) * height; + const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15; + setView((current) => { + const k = clamp(current.k * factor, 0.2, 4); + const worldX = (localX - current.tx) / current.k; + const worldY = (localY - current.ty) / current.k; + return { k, tx: localX - worldX * k, ty: localY - worldY * k }; + }); + }; + svg.addEventListener('wheel', onWheel, { passive: false }); + return () => svg.removeEventListener('wheel', onWheel); + }, [height]); + + const zoomBy = useCallback( + (factor: number) => { + setView((current) => { + const k = clamp(current.k * factor, 0.2, 4); + const centerX = CANVAS_WIDTH / 2; + const centerY = height / 2; + const worldX = (centerX - current.tx) / current.k; + const worldY = (centerY - current.ty) / current.k; + return { k, tx: centerX - worldX * k, ty: centerY - worldY * k }; + }); + }, + [height], + ); + + const resetView = useCallback(() => { + setView(fitView); + }, [fitView]); + + const handleNodePointerDown = useCallback( + (event: ReactPointerEvent, node: GraphNode) => { + if (event.button !== 0) return; + event.stopPropagation(); + const offset = offsets[node.id]; + nodeDragRef.current = { + id: node.id, + startX: event.clientX, + startY: event.clientY, + baseX: offset?.x ?? 0, + baseY: offset?.y ?? 0, + moved: false, + }; + svgRef.current?.setPointerCapture(event.pointerId); + }, + [offsets], + ); + + const handleBackgroundPointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) return; + panRef.current = { + startX: event.clientX, + startY: event.clientY, + tx: viewRef.current.tx, + ty: viewRef.current.ty, + }; + svgRef.current?.setPointerCapture(event.pointerId); + setIsPanning(true); + }, []); + + const handlePointerMove = useCallback( + (event: ReactPointerEvent) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const nodeDrag = nodeDragRef.current; + if (nodeDrag) { + if (Math.hypot(event.clientX - nodeDrag.startX, event.clientY - nodeDrag.startY) >= CLICK_SLOP_PX) { + nodeDrag.moved = true; + } + const k = viewRef.current.k; + const dx = ((event.clientX - nodeDrag.startX) * CANVAS_WIDTH) / Math.max(rect.width, 1) / k; + const dy = ((event.clientY - nodeDrag.startY) * height) / Math.max(rect.height, 1) / k; + setOffsets((current) => ({ + ...current, + [nodeDrag.id]: { x: nodeDrag.baseX + dx, y: nodeDrag.baseY + dy }, + })); + return; + } + const pan = panRef.current; + if (pan) { + const dx = ((event.clientX - pan.startX) / Math.max(rect.width, 1)) * CANVAS_WIDTH; + const dy = ((event.clientY - pan.startY) / Math.max(rect.height, 1)) * height; + setView((current) => ({ ...current, tx: pan.tx + dx, ty: pan.ty + dy })); + } + }, + [height], + ); + + const handlePointerEnd = useCallback( + (event: ReactPointerEvent) => { + const nodeDrag = nodeDragRef.current; + if (nodeDrag) { + const node = graph.nodes.find((item) => item.id === nodeDrag.id); + if (node && !nodeDrag.moved) { + setSelectedId(node.id); + onSelectConcept(node.concept); + } + nodeDragRef.current = null; + } + panRef.current = null; + setIsPanning(false); + if (svgRef.current?.hasPointerCapture(event.pointerId)) { + svgRef.current.releasePointerCapture(event.pointerId); + } + }, + [graph, onSelectConcept], + ); + + const neighborIds = useMemo(() => { + if (hoverId === null) return null; + const ids = new Set([hoverId]); + graph.edges.forEach((edge) => { + const sourceId = graph.nodes[edge.source]?.id; + const targetId = graph.nodes[edge.target]?.id; + if (sourceId === hoverId && targetId) ids.add(targetId); + if (targetId === hoverId && sourceId) ids.add(sourceId); + }); + return ids; + }, [graph, hoverId]); + + const legendItems = useMemo(() => { + const present = new Set(graph.nodes.map((node) => node.concept.concept_type)); + const ordered = TYPE_ORDER.filter((type) => present.has(type)); + present.forEach((type) => { + if (!TYPE_STYLES[type]) ordered.push(type); + }); + return ordered.map((type) => ({ type, ...(TYPE_STYLES[type] || FALLBACK_TYPE_STYLE) })); + }, [graph]); + + if (graph.nodes.length === 0) { + return
暂无知识图谱数据
; + } + + return ( +
+ + + + + + + + + + + + + + + + + {graph.edges.map((edge) => { + const source = graph.nodes[edge.source]; + const target = graph.nodes[edge.target]; + const sourcePos = source ? positions.get(source.id) : undefined; + const targetPos = target ? positions.get(target.id) : undefined; + if (!source || !target || !sourcePos || !targetPos) return null; + const dx = targetPos.x - sourcePos.x; + const dy = targetPos.y - sourcePos.y; + const distance = Math.hypot(dx, dy) || 1; + const ux = dx / distance; + const uy = dy / distance; + const direction = edge.source < edge.target ? 1 : -1; + const bend = direction * clamp(distance * 0.12, 6, 34); + const controlX = (sourcePos.x + targetPos.x) / 2 - uy * bend; + const controlY = (sourcePos.y + targetPos.y) / 2 + ux * bend; + const startTrim = nodeBorderRadius(source, ux, uy) + 3; + const endTrim = nodeBorderRadius(target, ux, uy) + 8; + const x1 = sourcePos.x + ux * startTrim; + const y1 = sourcePos.y + uy * startTrim; + const x2 = targetPos.x - ux * endTrim; + const y2 = targetPos.y - uy * endTrim; + const isCitation = edge.kind === 'citation'; + const isActive = hoverId !== null && (source.id === hoverId || target.id === hoverId); + const isDimmed = neighborIds !== null && !isActive; + const markerName = `knowledge-graph-arrow-${isCitation ? 'citation' : 'link'}${isActive ? '-active' : ''}`; + return ( + ${target.id}:${edge.kind}`} + className={`knowledge-graph-edge${isCitation ? ' is-citation' : ''}${isActive ? ' is-active' : ''}${isDimmed ? ' is-dimmed' : ''}`} + d={`M ${x1} ${y1} Q ${controlX} ${controlY} ${x2} ${y2}`} + markerEnd={`url(#${markerName})`} + /> + ); + })} + {graph.nodes.map((node) => { + const position = positions.get(node.id); + if (!position) return null; + const isDimmed = neighborIds !== null && !neighborIds.has(node.id); + const dotX = -node.width / 2 + 13; + return ( + handleNodePointerDown(event, node)} + onPointerEnter={() => setHoverId(node.id)} + onPointerLeave={() => setHoverId(null)} + > + + + + + {node.label} + + + + ); + })} + + +
+ {legendItems.map((item) => ( + + + {item.label} + + ))} +
+
+ + + +
+
+ ); +} diff --git a/frontend-enterprise/src/components/TeamCard.tsx b/frontend-enterprise/src/components/TeamCard.tsx new file mode 100644 index 00000000..f0cc7688 --- /dev/null +++ b/frontend-enterprise/src/components/TeamCard.tsx @@ -0,0 +1,81 @@ +import { Badge } from '@/components/ui'; + +import type { AgentProfileRead, TeamMemberRead, TeamRead } from '../types'; +import EmployeeAvatar from './EmployeeAvatar'; + +export type TeamCardProps = { + team: TeamRead; + /** 画廊已加载的员工列表,用于把成员 agent_id 映射到头像。 */ + agents: AgentProfileRead[]; + busy?: boolean; + onOpen: () => void; +}; + +export function teamLeader(team: TeamRead): TeamMemberRead | null { + return (team.members || []).find((member) => member.role === 'leader') || null; +} + +export default function TeamCard({ team, agents, busy = false, onOpen }: TeamCardProps) { + const members = team.members || []; + const leader = teamLeader(team); + const stacked = members.slice(0, 3); + const extraCount = members.length - stacked.length; + const agentById = (agentId: string) => agents.find((agent) => agent.id === agentId) || null; + + return ( +
{ + if (!busy) onOpen(); + }} + onKeyDown={(event) => { + if (!busy && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onOpen(); + } + }} + className="flex cursor-pointer flex-col gap-[12px] rounded-[20px] border border-[#F6F6F6] bg-white p-[20px] transition-shadow hover:shadow-[0_16px_30px_0_rgba(0,0,0,0.10)]" + > +
+ + {team.name} + + + {`${members.length} 名成员`} + +
+

+ {team.description || '暂无描述'} +

+
+ + {leader && ( + + )} + {`TL:${leader?.agent_name || '未设置'}`} + + + {stacked.map((member) => ( + + ))} + {extraCount > 0 && ( + + {`+${extraCount}`} + + )} + +
+
+ ); +} diff --git a/frontend-enterprise/src/enums/routes.ts b/frontend-enterprise/src/enums/routes.ts index 2301f61d..77c01737 100644 --- a/frontend-enterprise/src/enums/routes.ts +++ b/frontend-enterprise/src/enums/routes.ts @@ -4,6 +4,7 @@ export enum EnterpriseRoute { Platform = '/enterprise/platform', Gallery = '/workspace/gallery', Agents = '/enterprise/agents', + Teams = '/enterprise/teams', Dashboard = '/enterprise/dashboard', ScheduledTasks = '/enterprise/scheduled-tasks', Memories = '/enterprise/memories', diff --git a/frontend-enterprise/src/i18n/en.json b/frontend-enterprise/src/i18n/en.json index 64bd46a2..53d03bd3 100644 --- a/frontend-enterprise/src/i18n/en.json +++ b/frontend-enterprise/src/i18n/en.json @@ -87,6 +87,7 @@ "账号级": "Account-level", "账号全量密钥": "Account full-access key", "以当前账号身份访问和管理数字员工": "Access and manage digital employees as the current account", + "卡片视图": "Card View", "可浏览广场、选择加入员工,并运行当前账号可访问的全部数字员工。": "Browse the gallery, add employees, and run every digital employee available to the current account.", "可创建和管理本人有权管理的员工;权限随账号角色、员工归属和发布状态自动更新。": "Create and manage employees allowed by your account; permissions update automatically with role, ownership, and publication status.", "可按本人界面权限管理员工、SOP、知识、技能、工具和定时任务;不能越过账号权限、跨租户访问、读取供应商密钥或管理其他账号密钥。": "Manage employees, SOPs, knowledge, skills, tools, and scheduled tasks within your UI permissions; the key cannot exceed account permissions, cross tenants, read provider secrets, or manage other account keys.", @@ -305,7 +306,7 @@ "待补充岗位": "Role Not Set", "待补充职位": "Position Not Set", "待补足": "Incomplete", - "待处理": "Pending", + "待处理": "Needs attention", "待分析": "Pending Analysis", "待改进 SOP": "Needs Improvement SOP", "待回答": "Awaiting Reply", @@ -335,6 +336,7 @@ "当前没有内容变化": "There are no changes.", "当前没有内容变化,无需保存草稿。": "There are no changes to save as a draft.", "当前员工": "Current Employee", + "当前团队": "Current Team", "当前员工下你的长期记忆": "Your Long-term Memory for the Current Employee", "当前员工暂无本地 SOP": "No local SOPs for the current employee.", "当前员工暂无工具": "No tools for the current employee.", @@ -1203,6 +1205,8 @@ "头像会显示在我的数字员工、数字员工档案页和对话端的员工选择中。": "The avatar appears in My Digital Employees, Employee Profile, and the employee picker in Chat.", "头像图片不能超过 5MB": "Avatar images cannot exceed 5 MB", "图片": "Image", + "图谱视图": "Graph View", + "复位": "Reset View", "推荐场景": "Recommended Scenarios", "推荐单端口启动:企业端、对话端和 API 文档都由同一个 FastAPI 进程挂载,适合本地演示和外部隧道测试。": "Recommended single-port startup: the console, chat, and API docs are mounted by one FastAPI process for local demos and external tunnel testing.", "推进执行": "Advance Execute", @@ -1211,6 +1215,7 @@ "退出登录": "Log out", "退款、退货、换货流程需要确认订单、查询资格、收集原因并控制承诺边界,是最适合验证 SOP 和工具调用的场景。": "Refund, return, and exchange flows require order confirmation, eligibility checks, reason collection, and controlled commitments, making them ideal for validating SOPs and tool calls.", "拖拽文档到这里,或点击上传": "Drag a document here or click to upload", + "拖拽节点调整布局 · 滚轮缩放 · 点击查看详情": "Drag nodes to adjust the layout · Scroll to zoom · Click a node for details", "外部能力": "External Capabilities", "完成": "Complete", "完成步骤判断": "Complete Step Determine", @@ -1651,6 +1656,7 @@ "暂无知识库": "No knowledge bases", "暂无知识库,点击「新增」创建一个吧": "No Knowledge Bases Yet. Click \"Add\" to Create One.", "暂无知识图谱": "No knowledge graph", + "暂无知识图谱数据": "No knowledge graph data", "暂无执行记录": "No execution record", "摘要": "Summary", "展开": "Expand", @@ -1735,6 +1741,7 @@ "知识链接与引用": "Knowledge Link and Citation", "知识缺失": "Missing Knowledge", "知识图谱": "Knowledge Graph", + "知识图谱画布": "Knowledge Graph Canvas", "知识图谱 {1}": "Knowledge Graph {1}", "知识图谱标题": "Knowledge Graph Title", "知识图谱检查": "Knowledge Graph Check", @@ -2309,5 +2316,294 @@ "价格查询": "Price lookup", "查询商品价格": "Look up product prices", "/skill weather 查询北京天气": "/skill weather Check Beijing weather", - "/skill 天气": "/skill weather" + "/skill 天气": "/skill weather", + "团队": "Teams", + "待认领": "To Claim", + "进行中": "In Progress", + "待验收": "In Review", + "已退回": "Rework", + "已升级": "Escalated", + "高": "High", + "中": "Medium", + "低": "Low", + "通过": "Approve", + "退回重做": "Send Back for Rework", + "升级": "Escalate", + "加载团队详情失败": "Failed to load team details", + "加载任务失败": "Failed to load tasks", + "未分配": "Unassigned", + "请选择要添加的员工": "Select an employee to add", + "成员已添加": "Member added", + "添加成员失败": "Failed to add member", + "成员已移除": "Member removed", + "移除成员失败": "Failed to remove member", + "已更换团队 TL": "Team TL updated", + "更换 TL 失败": "Failed to change TL", + "已提交改判": "Override submitted", + "改判失败": "Failed to submit override", + "团队详情": "Team Details", + "返回团队列表": "Back to team list", + "成员管理": "Members", + "设为 TL": "Make TL", + "移除成员 {1}": "Remove member {1}", + "暂无成员": "No members yet", + "选择员工": "Select employee", + "添加成员": "Add Member", + "TL 对话": "TL Chat", + "已派发 {1} 个任务": "Dispatched {1} tasks", + "TL 正在处理,请稍候…": "TL is processing, please wait…", + "向 TL 描述目标,TL 会拆解并派发任务": "Describe your goal to the TL; it will break it down and dispatch tasks", + "输入消息,回车发送": "Type a message, press Enter to send", + "发送中…": "Sending…", + "任务看板": "Task Board", + "暂无任务": "No tasks", + "任务详情": "Task Details", + "负责人:{1}": "Assignee: {1}", + "优先级:{1}": "Priority: {1}", + "任务描述": "Task Description", + "执行报告": "Execution Report", + "验收结论": "Review Verdict", + "事件时间线": "Event Timeline", + "暂无事件": "No events", + "人工改判": "Manual Override", + "改判意见(可选)": "Override comment (optional)", + "竞标中": "Bidding", + "新建任务": "New Task", + "请输入任务标题": "Enter a task title", + "任务已创建": "Task created", + "创建任务失败": "Failed to create task", + "任务标题": "Task title", + "任务描述(可选)": "Task description (optional)", + "执行者": "Assignee", + "投入任务池竞标": "Submit to task pool for bidding", + "竞标记录": "Bid History", + "第 {1} 轮": "Round {1}", + "陈述": "Statement", + "反驳": "Rebuttal", + "竞标胜出": "Winning Bid", + "得分:{1}": "Score: {1}", + "改判执行者": "Override Assignee", + "选择执行者": "Select an assignee", + "请选择执行者": "Select an assignee first", + "改判说明(可选)": "Override note (optional)", + "确认改判": "Confirm Override", + "提交中…": "Submitting…", + "竞标方案": "Bidding Proposal", + "竞标已裁决": "Bid Awarded", + "我擅长数据分析": "I am good at data analysis", + "我可以快速交付": "I can deliver quickly", + "对方缺少落地案例": "The opponent lacks practical cases", + "方案具体": "Concrete proposal", + "第 1 轮": "Round 1", + "第 2 轮": "Round 2", + "得分:8": "Score: 8", + "池化任务": "Pooled Task", + "直派任务": "Directly Assigned Task", + "更信任小艾": "I trust Xiaoai more", + "团队黑板": "Team Blackboard", + "加载黑板失败": "Failed to load blackboard", + "请输入黑板内容": "Enter blackboard content", + "黑板条目已添加": "Blackboard entry added", + "添加黑板条目失败": "Failed to add blackboard entry", + "已置顶": "Pinned", + "已取消置顶": "Unpinned", + "更新黑板条目失败": "Failed to update blackboard entry", + "黑板条目已保存": "Blackboard entry saved", + "保存黑板条目失败": "Failed to save blackboard entry", + "确认归档该黑板条目?归档后不再展示。": "Archive this blackboard entry? It will no longer be shown.", + "黑板条目已归档": "Blackboard entry archived", + "归档黑板条目失败": "Failed to archive blackboard entry", + "置顶": "Pin", + "取消置顶": "Unpin", + "归档": "Archive", + "· 关联任务:{1}": "· Linked task: {1}", + "暂无黑板条目": "No blackboard entries yet", + "输入黑板内容": "Enter blackboard content", + "标签(逗号分隔,可选)": "Tags (comma-separated, optional)", + "添加中…": "Adding…", + "编辑黑板条目": "Edit Blackboard Entry", + "黑板内容": "Blackboard content", + "编辑标签(逗号分隔,可选)": "Edit tags (comma-separated, optional)", + "保存中…": "Saving…", + "正常": "Active", + "已归档": "Archived", + "未设置": "Not set", + "加载团队失败": "Failed to load teams", + "发起团队对话失败": "Failed to start the team chat", + "团队 · {1}": "Team · {1}", + "选择绑定对象": "Select Binding Target", + "绑定团队": "Bound Team", + "团队:{1}(TL:{2})": "Team: {1} (TL: {2})", + "暂无可用团队": "No teams available", + "去创建团队": "Create a Team", + "创建微信接入": "Create WeChat Connection", + "团队:增长团队(TL:小艾)": "Team: Growth Team (TL: Xiaoai)", + "TL:{1}": "TL: {1}", + "请输入团队名称": "Enter a team name", + "团队已创建": "Team created", + "创建团队失败": "Failed to create team", + "团队已删除": "Team deleted", + "删除团队失败": "Failed to delete team", + "组建多 Agent 团队,由 TL 拆解并派发任务": "Build multi-agent teams; the TL breaks down goals and dispatches tasks", + "新建团队": "New Team", + "{1} 名成员": "{1} members", + "删除团队 {1}": "Delete team {1}", + "暂无团队,点击右上角「新建团队」开始": "No teams yet. Click \"New Team\" in the top right to start", + "团队名称": "Team Name", + "团队描述": "Team Description", + "请输入团队描述(可选)": "Enter a team description (optional)", + "创建中…": "Creating…", + "删除团队「{1}」?": "Delete team \"{1}\"?", + "删除后团队及其任务将一并移除,操作不可撤销。": "Deleting removes the team and its tasks. This cannot be undone.", + "增长团队": "Growth Team", + "负责增长实验": "Owns growth experiments", + "负责增长实验与内容投放": "Owns growth experiments and content campaigns", + "小艾": "Xiaoai", + "小北": "Xiaobei", + "小南": "Xiaonan", + "小西": "Xiaoxi", + "4 名成员": "4 members", + "计划讨论": "Planning discussion", + "团队 · 增长团队": "Team · Growth Team", + "小丙": "Xiaobing", + "写周报": "Write weekly report", + "汇总本周数据": "Summarize this week's data", + "周报已完成": "Weekly report done", + "整理线索": "Organize leads", + "投放分析": "Campaign analysis", + "好的,我来安排": "Sure, I'll arrange it", + "已拆解完成": "Breakdown complete", + "新子任务": "New subtask", + "帮我拆解本周目标": "Help me break down this week's goals", + "已派发 1 个任务": "Dispatched 1 task", + "2 名成员": "2 members", + "TL:小艾": "TL: Xiaoai", + "新团队": "New Team Name", + "删除团队 增长团队": "Delete team Growth Team", + "团队动态": "Team Activity", + "暂无团队动态": "No team activity yet", + "任务": "Task", + "刚刚": "Just now", + "{1} 分钟前": "{1} min ago", + "{1} 小时前": "{1} hr ago", + "{1} 天前": "{1} d ago", + "团队设置": "Team Settings", + "成员并发上限": "Member Concurrency Limit", + "任务超时分钟": "Task Timeout (minutes)", + "竞标反驳轮数": "Bid Rebuttal Rounds", + "团队设置已保存": "Team settings saved", + "保存团队设置失败": "Failed to save team settings", + "请输入有效的数字": "Please enter valid numbers", + "系统": "System", + "沉淀到知识库": "Promote to Knowledge Base", + "沉淀中…": "Promoting…", + "已沉淀": "Promoted", + "已沉淀到知识库": "Promoted to knowledge base", + "沉淀到知识库失败": "Failed to promote to knowledge base", + "任务创建": "Task created", + "任务开始": "Task started", + "提交报告": "Report submitted", + "任务升级": "Task escalated", + "竞标开始": "Bidding started", + "竞标定标": "Bid awarded", + "提交竞标": "Bid submitted", + "跳过竞标": "Bid skipped", + "竞标失败": "Bid failed", + "定标解析失败": "Failed to parse award result", + "TL 免验收": "TL skipped review", + "TL 验收解析失败": "Failed to parse TL review", + "TL 验收修复失败": "TL review repair failed", + "TL 验收通过": "TL review approved", + "TL 退回重做": "TL requested rework", + "TL 升级": "TL escalated", + "人工改判通过": "Manual override: approved", + "人工改判退回": "Manual override: rework", + "人工改判升级": "Manual override: escalated", + "写入黑板": "Blackboard entry written", + "胜者为王": "Winner Takes All", + "淘汰": "Eliminated", + "验收通过": "Review Approved", + "打开 TL 对话失败": "Failed to open TL chat", + "其他": "Other", + "向 TL 描述目标,TL 会拆解并派发任务。对话将在工作区中进行,可查看完整的执行过程与产出。": "Describe your goal to the TL and it will break it down and dispatch tasks. The conversation runs in the workspace with full execution trace and outputs.", + "打开 TL 对话": "Open TL Chat", + "创建于 {1}": "Created {1}", + "查看执行会话": "View Execution Session", + "竞标竞技场": "Bidding Arena", + "未命名任务": "Untitled Task", + "打开中…": "Opening…", + "陈述内容": "Statement content", + "反驳内容": "Rebuttal content", + "数据不完整,请补充来源": "Data is incomplete; please add sources", + "旧任务": "Old task", + "新任务": "New task", + "10 分钟前": "10 minutes ago", + "1 小时前": "1 hour ago", + "任务执行": "Task Execution", + "竞标": "Bidding", + "验收": "Review", + "加载团队会话失败": "Failed to load team conversations", + "创建 TL 会话失败": "Failed to create TL session", + "加载消息失败": "Failed to load messages", + "与 TL 直接沟通": "Chat directly with the TL", + "团队聊天室": "Team Chat Room", + "返回团队": "Back to team", + "会话列表": "Conversation list", + "暂无员工会话": "No member conversations", + "暂无会话": "No conversations", + "消息区": "Message area", + "正在创建 TL 会话…": "Creating TL session…", + "向 TL 发送消息…": "Send a message to the TL…", + "输入消息": "Type a message", + "任务会话仅可查看": "Task conversations are read-only", + "向 TL 描述目标,TL 会拆解并派发任务。对话在团队专属聊天室中进行,可查看完整的执行过程与产出。": "Describe your goal to the TL, who will break it down and dispatch tasks. The conversation happens in the team's dedicated chat room, where you can see the full execution process and output.", + "好的,我来拆解": "OK, let me break it down", + "周报已完成,请验收": "The weekly report is done, please review", + "验收:写周报": "Review: Weekly report", + "请拆解本月目标": "Please break down this month's goals", + "已收到,开始执行": "Received, starting execution", + "收到,马上安排": "Got it, arranging right away", + "帮我规划下周任务": "Help me plan next week's tasks", + "5 分钟前": "5 minutes ago", + "在的,请讲": "I'm here, go ahead", + "团队 TL": "Team TL", + "队长": "Captain", + "团队任务:写公告": "Team task: write announcement", + "全部员工 · 2": "All employees · 2", + "我的团队": "My Teams", + "没有匹配的团队": "No matching teams", + "暂无团队": "No teams yet", + "换个关键词再试试": "Try another keyword", + "当前还没有属于你的团队": "You do not have any teams yet", + "Hello 我们是": "Hello, we are ", + "团队由 {1} 名成员组成,TL 是 {2}": "A team of {1} members, led by TL {2}", + "{1} · TL": "{1} · TL", + "成员数": "Members", + "任务数": "Tasks", + "黑板条目数": "Blackboard entries", + "展开团队 {1}": "Expand team {1}", + "收起团队 {1}": "Collapse team {1}", + "展开团队 增长团队": "Expand team Growth Team", + "收起团队 增长团队": "Collapse team Growth Team", + "线程": "threads", + "任务 ·": "tasks ·", + "团队任务": "Team task", + "团队任务:": "Team task: ", + "团队任务:": "Team task: ", + "团队任务验收:": "Team task review: ", + "团队任务验收:": "Team task review: ", + "团队竞标:": "Team bidding: ", + "团队竞标:": "Team bidding: ", + "团队总数": "Teams", + "进行中任务": "Active tasks", + "正在推进": "In progress", + "需要人工介入": "Needs human input", + "团队统计": "Team stats", + "创建新团队": "Create team", + "几步搭好你的团队": "Set up your team in a few steps", + "TL:未设置": "TL: not set", + "{1} 进行中": "{1} in progress", + "{1} 待处理": "{1} needs attention", + "暂无团队,点击上方「创建新团队」开始": "No teams yet — click \"Create team\" above to get started", + "需要补充信息": "More info needed" } diff --git a/frontend-enterprise/src/lib/agent-scope-storage.test.ts b/frontend-enterprise/src/lib/agent-scope-storage.test.ts new file mode 100644 index 00000000..d9c91dc2 --- /dev/null +++ b/frontend-enterprise/src/lib/agent-scope-storage.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + ENTERPRISE_AGENT_STORAGE_KEY, + isTeamScope, + readEmployeeScope, + teamIdFromScope, + toTeamScope, +} from './agent-scope-storage'; + +afterEach(() => { + window.localStorage.clear(); +}); + +describe('team agent-scope helpers', () => { + it('builds a team scope value with the team: prefix', () => { + expect(toTeamScope('team-1')).toBe('team:team-1'); + expect(toTeamScope('')).toBe(''); + }); + + it('detects team scope values and leaves employee ids alone', () => { + expect(isTeamScope('team:team-1')).toBe(true); + expect(isTeamScope('agent-1')).toBe(false); + expect(isTeamScope('team:')).toBe(false); + expect(isTeamScope('')).toBe(false); + expect(isTeamScope(null)).toBe(false); + expect(isTeamScope(undefined)).toBe(false); + }); + + it('extracts the team id from a team scope value', () => { + expect(teamIdFromScope('team:team-1')).toBe('team-1'); + expect(teamIdFromScope('team:team:with:colons')).toBe('team:with:colons'); + expect(teamIdFromScope('agent-1')).toBe(''); + expect(teamIdFromScope(null)).toBe(''); + }); + + it('reads team scopes as an empty employee scope', () => { + window.localStorage.setItem(ENTERPRISE_AGENT_STORAGE_KEY, 'team:team-1'); + expect(readEmployeeScope()).toBe(''); + }); + + it('reads employee scopes as-is', () => { + window.localStorage.setItem(ENTERPRISE_AGENT_STORAGE_KEY, 'agent-1'); + expect(readEmployeeScope()).toBe('agent-1'); + }); + + it('reads an empty storage slot as an empty employee scope', () => { + expect(readEmployeeScope()).toBe(''); + }); +}); diff --git a/frontend-enterprise/src/lib/agent-scope-storage.ts b/frontend-enterprise/src/lib/agent-scope-storage.ts index db5ff928..056d400c 100644 --- a/frontend-enterprise/src/lib/agent-scope-storage.ts +++ b/frontend-enterprise/src/lib/agent-scope-storage.ts @@ -17,6 +17,30 @@ export function clearSharedAgentScope(userId?: string): void { window.localStorage.removeItem(ENTERPRISE_AGENT_STORAGE_KEY); } +// Team scopes share the same storage slot as employee agent ids, prefixed so +// readers can tell "current team" apart from "current employee". +export const TEAM_SCOPE_PREFIX = 'team:'; + +export function toTeamScope(teamId: string): string { + return teamId ? `${TEAM_SCOPE_PREFIX}${teamId}` : ''; +} + +export function isTeamScope(value: string | null | undefined): boolean { + return typeof value === 'string' + && value.startsWith(TEAM_SCOPE_PREFIX) + && value.length > TEAM_SCOPE_PREFIX.length; +} + +export function teamIdFromScope(value: string | null | undefined): string { + return isTeamScope(value) ? String(value).slice(TEAM_SCOPE_PREFIX.length) : ''; +} + +/** 读取共享作用域;团队作用域对员工向页面视为"未选员工",返回空串。 */ +export function readEmployeeScope(): string { + const raw = window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''; + return isTeamScope(raw) ? '' : raw; +} + export function emitAgentScopeChange(agentId: string): void { window.dispatchEvent( new CustomEvent('ultrarag-enterprise-agent-scope-change', { diff --git a/frontend-enterprise/src/lib/timezone.test.ts b/frontend-enterprise/src/lib/timezone.test.ts new file mode 100644 index 00000000..a9bb7cd9 --- /dev/null +++ b/frontend-enterprise/src/lib/timezone.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { formatClientDateTime, parseBackendDateTime } from './timezone'; + +describe('parseBackendDateTime', () => { + it('treats naive ISO timestamps as UTC instead of local time', () => { + const parsed = parseBackendDateTime('2026-08-11T09:00:00'); + expect(parsed.getTime()).toBe(Date.UTC(2026, 7, 11, 9, 0, 0)); + }); + + it('keeps timestamps that already carry a timezone suffix', () => { + const withZ = parseBackendDateTime('2026-08-11T09:00:00Z'); + expect(withZ.getTime()).toBe(Date.UTC(2026, 7, 11, 9, 0, 0)); + const withOffset = parseBackendDateTime('2026-08-11T17:00:00+08:00'); + expect(withOffset.getTime()).toBe(Date.UTC(2026, 7, 11, 9, 0, 0)); + }); + + it('parses date-only strings as UTC without appending a suffix', () => { + const parsed = parseBackendDateTime('2026-08-11'); + expect(parsed.getTime()).toBe(Date.UTC(2026, 7, 11)); + }); + + it('returns an invalid date for empty or malformed input', () => { + expect(Number.isNaN(parseBackendDateTime('').getTime())).toBe(true); + expect(Number.isNaN(parseBackendDateTime('not-a-date').getTime())).toBe(true); + }); +}); + +describe('formatClientDateTime', () => { + it('renders naive UTC timestamps in the client timezone', () => { + const expected = new Date(Date.UTC(2026, 7, 11, 9, 0, 0)).toLocaleString('zh-CN', { + hour12: false, + }); + expect(formatClientDateTime('2026-08-11T09:00:00')).toBe(expected); + }); + + it('falls back to the empty text for missing values', () => { + expect(formatClientDateTime(undefined)).toBe('-'); + expect(formatClientDateTime('bad', '')).toBe(''); + }); +}); diff --git a/frontend-enterprise/src/lib/timezone.ts b/frontend-enterprise/src/lib/timezone.ts index 503c865e..e6f4f514 100644 --- a/frontend-enterprise/src/lib/timezone.ts +++ b/frontend-enterprise/src/lib/timezone.ts @@ -13,7 +13,10 @@ export function getClientTimeZone(): string { export function parseBackendDateTime(value?: string): Date { const text = String(value || '').trim(); if (!text) return new Date(''); - if (/[zZ]|[+-]\d{2}:\d{2}$/.test(text)) return new Date(text); + // 纯日期字符串本就被当作 UTC 解析,无需补时区后缀 + if (!text.includes('T')) return new Date(text); + // 后端时间戳为 naive UTC(无 Z 后缀),缺失时按 UTC 解析而非本地时间 + if (/([zZ]|[+-]\d{2}:\d{2})$/.test(text)) return new Date(text); return new Date(`${text}Z`); } diff --git a/frontend-enterprise/src/pages/AgentsPage.test.tsx b/frontend-enterprise/src/pages/AgentsPage.test.tsx new file mode 100644 index 00000000..bd7227b4 --- /dev/null +++ b/frontend-enterprise/src/pages/AgentsPage.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '@/i18n'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { ENTERPRISE_AGENT_STORAGE_KEY } from '@/lib/agent-scope-storage'; +import type { AgentProfileRead } from '@/types'; + +import AgentsPage from './AgentsPage'; + +const agent: AgentProfileRead = { + id: 'agent-1', + tenant_id: 'tenant_demo', + name: '小艾', + is_overall: false, + status: 'active', + metadata: {}, + resources: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(body ?? {}), + } as Response; +} + +beforeEach(() => { + if (!window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; + } +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + window.localStorage.clear(); +}); + +describe('AgentsPage team scope compatibility', () => { + it('renders gracefully when the stored scope is a team', async () => { + window.localStorage.setItem(ENTERPRISE_AGENT_STORAGE_KEY, 'team:team-1'); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/api/enterprise/agents')) return jsonResponse([agent]); + return jsonResponse([]); + })); + + render( + + + + + + + , + ); + + // 团队作用域匹配不到任何员工:不高亮、不报错,员工列表照常渲染。 + expect((await screen.findByText('小艾')).textContent).toBeTruthy(); + }); +}); diff --git a/frontend-enterprise/src/pages/AgentsPage.tsx b/frontend-enterprise/src/pages/AgentsPage.tsx index cf9fef43..e86b8114 100644 --- a/frontend-enterprise/src/pages/AgentsPage.tsx +++ b/frontend-enterprise/src/pages/AgentsPage.tsx @@ -24,7 +24,7 @@ import { employeeDisplayNameWithCreator, employeeProfile, } from '../employee'; -import { emitAgentScopeChange, persistSharedAgentScope } from '../lib/agent-scope-storage'; +import { emitAgentScopeChange, isTeamScope, persistSharedAgentScope, readEmployeeScope } from '../lib/agent-scope-storage'; import type { AgentProfileRead } from '../types'; const ENTERPRISE_AGENT_STORAGE_KEY = 'ultrarag_enterprise_agent_scope'; @@ -51,7 +51,7 @@ export default function AgentsPage({ const [searchTerm, setSearchTerm] = useState(''); const [employeeFilter, setEmployeeFilter] = useState<'all' | 'online' | 'offline' | 'pending'>('all'); const [selectedAgentId, setSelectedAgentId] = useState( - () => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY), + () => readEmployeeScope() || null, ); const navigate = useNavigate(); @@ -73,8 +73,8 @@ export default function AgentsPage({ useEffect(() => { const handler = (event: Event) => { - const detail = (event as CustomEvent<{ agentId?: string }>).detail; - setSelectedAgentId(detail?.agentId ?? window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY)); + const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; + setSelectedAgentId(next && !isTeamScope(next) ? next : readEmployeeScope() || null); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', handler); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', handler); @@ -179,7 +179,7 @@ export default function AgentsPage({ setDeleting(true); try { await api.delete(`/api/enterprise/agents/${row.id}?tenant_id=${TENANT_ID}`); - if (window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) === row.id) { + if (readEmployeeScope() === row.id) { const nextAgent = employees.find((item) => item.id !== row.id && item.status === 'active') || employees.find((item) => item.id !== row.id); if (nextAgent) { diff --git a/frontend-enterprise/src/pages/ChannelsPage.test.tsx b/frontend-enterprise/src/pages/ChannelsPage.test.tsx new file mode 100644 index 00000000..8c08b181 --- /dev/null +++ b/frontend-enterprise/src/pages/ChannelsPage.test.tsx @@ -0,0 +1,219 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '@/i18n'; +import type { EnterpriseAuthUser } from '@/auth'; +import type { + AgentProfileRead, + ChannelBindingRead, + ChannelMetaRead, + TeamRead, +} from '@/types'; + +import ChannelsPage from './ChannelsPage'; + +const adminUser: EnterpriseAuthUser = { + id: 'user-1', + tenant_id: 'tenant_demo', + username: 'admin', + role: 'admin', +}; + +const agent: AgentProfileRead = { + id: 'agent-1', + tenant_id: 'tenant_demo', + name: '小艾', + is_overall: false, + status: 'active', + metadata: {}, + resources: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +const team: TeamRead = { + id: 'team-1', + tenant_id: 'tenant_demo', + name: '增长团队', + description: '负责增长实验', + owner_user_id: 'user-1', + config: {}, + status: 'active', + members: [ + { + id: 'member-1', + team_id: 'team-1', + agent_id: 'agent-1', + role: 'leader', + agent_name: '小艾', + created_at: '2026-08-01T00:00:00Z', + }, + { + id: 'member-2', + team_id: 'team-1', + agent_id: 'agent-2', + role: 'member', + agent_name: '小北', + created_at: '2026-08-01T00:00:00Z', + }, + ], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +const channelMetas: ChannelMetaRead[] = [ + { channel: 'wechat', name: '微信', setup: 'qrcode', capabilities: [] }, +]; + +const teamBinding: ChannelBindingRead = { + id: 'binding-1', + tenant_id: 'tenant_demo', + agent_id: '', + channel: 'wechat', + status: 'active', + connected: true, + agents: [], + team_id: 'team-1', + team_name: '增长团队', + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(body ?? {}), + } as Response; +} + +function makeFetchMock(overrides: { bindings?: unknown; teams?: unknown; agents?: unknown } = {}) { + const bindings = overrides.bindings ?? []; + const teams = overrides.teams ?? [team]; + const agents = overrides.agents ?? [agent]; + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method || 'GET'; + if (method === 'POST' && url.endsWith('/api/enterprise/channels')) { + return jsonResponse({ ...teamBinding, id: 'binding-new' }); + } + if (url.includes('/channels/meta')) return jsonResponse(channelMetas); + if (url.includes('/my-identity-bindings')) return jsonResponse([]); + if (url.includes('/deliveries/days')) { + return jsonResponse({ days: [], total_days: 0, offset: 0, limit: 7 }); + } + if (url.includes('/conversations')) { + return jsonResponse({ items: [], total: 0, offset: 0, limit: 20 }); + } + if (url.includes('/api/enterprise/agents')) return jsonResponse(agents); + if (url.includes('/api/enterprise/teams')) return jsonResponse(teams); + if (url.includes('/api/enterprise/channels')) return jsonResponse(bindings); + return jsonResponse({}); + }); +} + +function renderPage() { + return render( + + + + + , + ); +} + +async function openCreateDialog(user: ReturnType) { + await user.click(screen.getAllByRole('button', { name: /接入渠道/ })[0]); + await user.click(await screen.findByRole('button', { name: /微信/ })); + await screen.findByText('选择绑定对象'); +} + +function createPostBody(fetchMock: ReturnType): Record { + const call = fetchMock.mock.calls.find( + ([input, init]) => init?.method === 'POST' && String(input).endsWith('/api/enterprise/channels'), + ); + expect(call).toBeTruthy(); + return JSON.parse(String(call?.[1]?.body)) as Record; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('ChannelsPage', () => { + it('creates a binding with agent_id when the agent target is selected', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock(); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + await openCreateDialog(user); + + await screen.findByText('小艾'); + await user.click(screen.getAllByRole('radio')[0]); + await user.click(screen.getByRole('button', { name: '创建微信接入' })); + + await waitFor(() => { + const body = createPostBody(fetchMock); + expect(body.agent_id).toBe('agent-1'); + expect(body).not.toHaveProperty('team_id'); + }); + }); + + it('creates a binding with team_id when the team target is selected', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock(); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + await openCreateDialog(user); + + await user.click(screen.getByRole('button', { name: '团队' })); + await screen.findByText('增长团队'); + expect(screen.getByText('TL:小艾')).toBeTruthy(); + expect(screen.getByText('2 名成员')).toBeTruthy(); + await user.click(screen.getAllByRole('radio')[0]); + await user.click(screen.getByRole('button', { name: '创建微信接入' })); + + await waitFor(() => { + const body = createPostBody(fetchMock); + expect(body.team_id).toBe('team-1'); + expect(body).not.toHaveProperty('agent_id'); + }); + }); + + it('shows the team as read-only in the detail of a team binding', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock({ bindings: [teamBinding] }); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + expect(await screen.findByText('团队 · 增长团队')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: /团队 · 增长团队/ })); + + const section = await screen.findByRole('region', { name: '可调度员工' }); + expect(await within(section).findByText('团队:增长团队(TL:小艾)')).toBeTruthy(); + expect(within(section).queryByRole('button', { name: '编辑' })).toBeNull(); + expect(within(section).queryByRole('radio')).toBeNull(); + }); + + it('shows an empty state with a create-team link when no team exists', async () => { + const user = userEvent.setup(); + const fetchMock = makeFetchMock({ teams: [] }); + vi.stubGlobal('fetch', fetchMock); + + renderPage(); + await openCreateDialog(user); + + await user.click(screen.getByRole('button', { name: '团队' })); + expect(await screen.findByText('暂无可用团队')).toBeTruthy(); + expect(screen.getByRole('button', { name: '去创建团队' })).toBeTruthy(); + }); +}); diff --git a/frontend-enterprise/src/pages/ChannelsPage.tsx b/frontend-enterprise/src/pages/ChannelsPage.tsx index 13249d1c..12a00ebc 100644 --- a/frontend-enterprise/src/pages/ChannelsPage.tsx +++ b/frontend-enterprise/src/pages/ChannelsPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { notify } from '@/components/ui/app-toast'; import AppHeader from '@/components/AppHeader'; @@ -40,6 +41,7 @@ import type { ChannelIdentityBindingRead, ChannelMetaRead, PagedResponse, + TeamRead, } from '../types'; import WechatSetup from './channels/WechatSetup'; import WecomSetup from './channels/WecomSetup'; @@ -166,7 +168,11 @@ export default function ChannelsPage({ const [createOpen, setCreateOpen] = useState(false); const [createStep, setCreateStep] = useState<'channel' | 'agent'>('channel'); const [createChannel, setCreateChannel] = useState('wechat'); + const [createTarget, setCreateTarget] = useState<'agent' | 'team'>('agent'); const [createAgentId, setCreateAgentId] = useState(''); + const [createTeamId, setCreateTeamId] = useState(''); + const [teams, setTeams] = useState([]); + const [teamsLoading, setTeamsLoading] = useState(false); const [creating, setCreating] = useState(false); const [unbindOpen, setUnbindOpen] = useState(false); const [unbinding, setUnbinding] = useState(false); @@ -187,6 +193,7 @@ export default function ChannelsPage({ const [unbindingIdentity, setUnbindingIdentity] = useState(false); const binding = bindings.find((item) => item.id === selectedId) || null; + const navigate = useNavigate(); const channelIdentities = identityBindings.filter((item) => item.channel === binding?.channel); const bindCodeChannelName = binding ? channelName(binding.channel) @@ -219,6 +226,7 @@ export default function ChannelsPage({ void load(); void loadIdentityBindings(); void loadChannelMetas(); + void loadTeams(); }, []); useEffect(() => { @@ -369,6 +377,29 @@ export default function ChannelsPage({ } } + async function loadTeams() { + setTeamsLoading(true); + try { + const rows = await api.get(`/api/enterprise/teams?tenant_id=${TENANT_ID}`); + setTeams(rows); + } catch { + // 团队列表仅用于绑定对象选择与名称映射,失败不影响主流程 + setTeams([]); + } finally { + setTeamsLoading(false); + } + } + + function teamNameFor(item: ChannelBindingRead): string { + if (!item.team_id) return ''; + return item.team_name || teams.find((team) => team.id === item.team_id)?.name || '团队'; + } + + function teamLeaderName(teamId: string): string { + const team = teams.find((item) => item.id === teamId); + return team?.members.find((member) => member.role === 'leader')?.agent_name || '未设置'; + } + async function loadAgentCandidates() { setCandidatesLoading(true); try { @@ -389,23 +420,30 @@ export default function ChannelsPage({ function openCreate() { setCreateStep('channel'); setCreateChannel(channelMetas[0]?.channel || 'wechat'); + setCreateTarget('agent'); setCreateAgentId(''); + setCreateTeamId(''); setCreateOpen(true); void loadAgentCandidates(); + void loadTeams(); } async function createBinding() { - if (!createAgentId || creating) return; + const agentId = createTarget === 'agent' ? createAgentId : ''; + const teamId = createTarget === 'team' ? createTeamId : ''; + if ((!agentId && !teamId) || creating) return; setCreating(true); try { const created = await api.post('/api/enterprise/channels', { tenant_id: TENANT_ID, - agent_id: createAgentId, + // agent_id 与 team_id 互斥,后端二选一 + ...(agentId ? { agent_id: agentId } : { team_id: teamId }), channel: createChannel, }); notify.success('渠道接入创建成功'); setCreateOpen(false); setCreateAgentId(''); + setCreateTeamId(''); await load(); setSelectedId(created.id); } catch (error) { @@ -661,8 +699,12 @@ export default function ChannelsPage({ {formatTime(item.created_at)}
- 可调度员工 - {(item.agents || []).length === 0 ? ( + + {item.team_id ? '绑定团队' : '可调度员工'} + + {item.team_id ? ( + {`团队 · ${teamNameFor(item)}`} + ) : (item.agents || []).length === 0 ? ( 暂无可调度员工 ) : ( (item.agents || []).map((agent) => ( @@ -830,7 +872,7 @@ export default function ChannelsPage({ 可调度员工
- {!agentEditing && ( + {!agentEditing && !binding.team_id && ( 编辑 @@ -839,7 +881,13 @@ export default function ChannelsPage({

挂载后,该渠道的所有用户均可与这些员工对话。

- {agentEditing ? ( + {binding.team_id ? ( +
+ + {`团队:${teamNameFor(binding)}(TL:${teamLeaderName(binding.team_id)})`} + +
+ ) : agentEditing ? (
{candidatesLoading ? ( 加载中… @@ -1131,9 +1179,7 @@ export default function ChannelsPage({ className="flex max-h-[calc(100dvh-4rem)] w-[calc(100%-2rem)] flex-col gap-[16px] overflow-hidden rounded-[14px] px-[20px] py-[16px] sm:max-w-[480px]" > - {createStep === 'channel' - ? '选择渠道' - : `选择${getChannelPresentation(createChannel, metaFor(createChannel)?.name).name}默认员工`} + {createStep === 'channel' ? '选择渠道' : '选择绑定对象'}
{createStep === 'channel' ? ( @@ -1173,26 +1219,96 @@ export default function ChannelsPage({ ))}
) - ) : candidatesLoading ? ( -
加载中…
- ) : agentCandidates.length === 0 ? ( -
暂无可用员工
) : ( - - {agentCandidates.map((agent) => ( -
- - {employeeDisplayName(agent)} +
+
+ {( + [ + { key: 'agent', label: '数字员工' }, + { key: 'team', label: '团队' }, + ] as const + ).map((option) => ( + + ))} +
+ {createTarget === 'agent' ? ( + candidatesLoading ? ( +
加载中…
+ ) : agentCandidates.length === 0 ? ( +
+ 暂无可用员工 +
+ ) : ( + + {agentCandidates.map((agent) => ( +
+ + + {employeeDisplayName(agent)} + +
+ ))} +
+ ) + ) : teamsLoading ? ( +
加载中…
+ ) : teams.length === 0 ? ( +
+ 暂无可用团队 + { + setCreateOpen(false); + navigate('/enterprise/teams'); + }} + className={OUTLINE_BUTTON_CLASS} + > + 去创建团队 +
- ))} - + ) : ( + + {teams.map((team) => ( +
+ + {team.name} + + {`TL:${team.members.find((member) => member.role === 'leader')?.agent_name || '未设置'}`} + + + {`${team.members.length} 名成员`} + +
+ ))} +
+ )} +
)}
@@ -1215,7 +1331,9 @@ export default function ChannelsPage({ {createStep === 'agent' && ( void createBinding()} - disabled={!createAgentId || creating} + disabled={ + (createTarget === 'agent' ? !createAgentId : !createTeamId) || creating + } className={PRIMARY_BUTTON_CLASS} > {`创建${getChannelPresentation(createChannel, metaFor(createChannel)?.name).name}接入`} diff --git a/frontend-enterprise/src/pages/DistillPage.tsx b/frontend-enterprise/src/pages/DistillPage.tsx index 72d4f046..08fb73a6 100644 --- a/frontend-enterprise/src/pages/DistillPage.tsx +++ b/frontend-enterprise/src/pages/DistillPage.tsx @@ -69,6 +69,7 @@ import AppHeader from '@/components/AppHeader'; import { CapabilityScopeBadge } from '@/components/CapabilityScopeControl'; import { ModelConfigDropdown } from '@/components/ModelConfigDropdown'; import { cn } from '@/lib/utils'; +import { isTeamScope, readEmployeeScope } from '@/lib/agent-scope-storage'; import { SELECT_TRIGGER_CLASS } from '@/lib/enterprise-ui'; import type { EnterpriseAuthUser } from '../auth'; import { @@ -483,7 +484,6 @@ const DEFAULT_DISTILL_MESSAGES: ChatItem[] = [ content: '请粘贴原始技能说明,或点击右侧某一块后告诉我需要怎样改写。', }, ]; -const ENTERPRISE_AGENT_STORAGE_KEY = 'ultrarag_enterprise_agent_scope'; const DISTILL_REWRITE_MODEL_STORAGE_KEY = 'skill-distill-rewrite-model'; type DistillCacheSnapshot = { @@ -568,7 +568,7 @@ export default function DistillPage({ active = true, searchParamsOverride, curre const skillId = searchParams.get('skill_id'); const mode = searchParams.get('mode') || ''; const workspaceId = searchParams.get('workspace_id') || ''; - const [selectedAgentId, setSelectedAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const [selectedAgentId, setSelectedAgentId] = useState(readEmployeeScope); const activeAgentId = searchParams.get('agent_id') || selectedAgentId; const agentQuery = activeAgentId ? `&agent_id=${encodeURIComponent(activeAgentId)}` : ''; const agentSearchParam = activeAgentId ? `agent_id=${encodeURIComponent(activeAgentId)}` : ''; @@ -633,8 +633,8 @@ export default function DistillPage({ active = true, searchParamsOverride, curre useEffect(() => { const onScopeChange = (event: Event) => { - const agentId = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; - setSelectedAgentId(agentId || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; + setSelectedAgentId(next && !isTeamScope(next) ? next : readEmployeeScope()); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); diff --git a/frontend-enterprise/src/pages/EmployeeGalleryPage.test.tsx b/frontend-enterprise/src/pages/EmployeeGalleryPage.test.tsx new file mode 100644 index 00000000..1c801d46 --- /dev/null +++ b/frontend-enterprise/src/pages/EmployeeGalleryPage.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '@/i18n'; +import type { TeamRead } from '@/types'; + +import EmployeeGalleryPage from './EmployeeGalleryPage'; + +const team: TeamRead = { + id: 'team-1', + tenant_id: 'tenant_demo', + name: '增长团队', + description: '负责增长实验与内容投放', + owner_user_id: 'user-1', + config: {}, + status: 'active', + members: [ + { id: 'm-1', team_id: 'team-1', agent_id: 'agent-1', role: 'leader', agent_name: '小艾', created_at: '2026-08-01T00:00:00Z' }, + { id: 'm-2', team_id: 'team-1', agent_id: 'agent-2', role: 'member', agent_name: '小北', created_at: '2026-08-01T00:00:00Z' }, + { id: 'm-3', team_id: 'team-1', agent_id: 'agent-3', role: 'member', agent_name: '小南', created_at: '2026-08-01T00:00:00Z' }, + { id: 'm-4', team_id: 'team-1', agent_id: 'agent-4', role: 'member', agent_name: '小西', created_at: '2026-08-01T00:00:00Z' }, + ], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(body ?? {}), + } as Response; +} + +function stubGalleryFetch(teams: TeamRead[]) { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (init?.method === 'POST' && url.includes('/tl/session')) { + return jsonResponse({ session_id: 'session-tl-1' }); + } + if (url.includes('/api/enterprise/teams')) return jsonResponse(teams); + if (url.includes('/api/enterprise/agents')) return jsonResponse([]); + return jsonResponse({}); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function LocationEcho() { + const location = useLocation(); + return
{location.pathname}
; +} + +function renderGallery() { + return render( + + + + } /> + } /> + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('EmployeeGalleryPage teams tab', () => { + it('renders team cards with member count, TL and avatar stack under the teams tab', async () => { + const user = userEvent.setup(); + stubGalleryFetch([team]); + renderGallery(); + + await user.click(await screen.findByRole('tab', { name: '我的团队' })); + + const section = await screen.findByRole('region', { name: '团队' }); + expect(within(section).getByText('增长团队')).toBeTruthy(); + expect(within(section).getByText('负责增长实验与内容投放')).toBeTruthy(); + expect(within(section).getByText('4 名成员')).toBeTruthy(); + expect(within(section).getByText('TL:小艾')).toBeTruthy(); + // 前 3 个成员头像叠放,其余折叠为 +N + expect(within(section).getByText('+1')).toBeTruthy(); + }); + + it('creates the TL session on click and navigates to the chat', async () => { + const user = userEvent.setup(); + const fetchMock = stubGalleryFetch([team]); + renderGallery(); + + await user.click(await screen.findByRole('tab', { name: '我的团队' })); + const section = await screen.findByRole('region', { name: '团队' }); + await user.click(within(section).getByRole('button', { name: '增长团队' })); + + expect((await screen.findByTestId('location')).textContent).toBe('/workspace/chat/session-tl-1'); + await waitFor(() => { + const postCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST'); + expect(postCall).toBeTruthy(); + expect(String(postCall?.[0])).toContain('/api/enterprise/teams/team-1/tl/session'); + const body = JSON.parse(String(postCall?.[1]?.body)) as Record; + expect(body.tenant_id).toBeTruthy(); + }); + }); + + it('does not render the team section on employee tabs', async () => { + const user = userEvent.setup(); + stubGalleryFetch([team]); + renderGallery(); + + await screen.findByText('暂无数字员工'); + expect(screen.queryByRole('region', { name: '团队' })).toBeNull(); + + await user.click(screen.getByRole('tab', { name: '我的数字员工' })); + expect(screen.queryByRole('region', { name: '团队' })).toBeNull(); + }); + + it('shows the teams empty state when there are no teams', async () => { + const user = userEvent.setup(); + stubGalleryFetch([]); + renderGallery(); + + await user.click(await screen.findByRole('tab', { name: '我的团队' })); + + expect(await screen.findByText('暂无团队')).toBeTruthy(); + }); +}); diff --git a/frontend-enterprise/src/pages/EmployeeGalleryPage.tsx b/frontend-enterprise/src/pages/EmployeeGalleryPage.tsx index 71797622..e672bb42 100644 --- a/frontend-enterprise/src/pages/EmployeeGalleryPage.tsx +++ b/frontend-enterprise/src/pages/EmployeeGalleryPage.tsx @@ -14,6 +14,7 @@ import { ConfirmDialog } from '../components/ConfirmDialog'; import EmployeeAvatarEditor from '../components/EmployeeAvatarEditor'; import EmployeeCard from '../components/EmployeeCard'; import EmployeeProfileEditor from '../components/EmployeeProfileEditor'; +import TeamCard, { teamLeader } from '../components/TeamCard'; import { canManageEmployeeAgent, employeeDisplayName, @@ -22,11 +23,11 @@ import { isMyEmployeeAgent, visibleEmployeeAgents, } from '../employee'; -import type { AgentProfileRead } from '../types'; +import type { AgentProfileRead, TeamRead } from '../types'; const ENTERPRISE_AGENT_STORAGE_KEY = 'ultrarag_enterprise_agent_scope'; -type GalleryScope = 'all' | 'mine' | 'gallery'; +type GalleryScope = 'all' | 'mine' | 'teams' | 'gallery'; export default function EmployeeGalleryPage({ currentUser, @@ -40,12 +41,14 @@ export default function EmployeeGalleryPage({ onLogout?: () => void; }) { const [agents, setAgents] = useState([]); + const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(false); const [avatarAgent, setAvatarAgent] = useState(null); const [profileAgent, setProfileAgent] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); const [startingAgentId, setStartingAgentId] = useState(null); + const [startingTeamId, setStartingTeamId] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [scope, setScope] = useState('all'); const navigate = useNavigate(); @@ -62,13 +65,24 @@ export default function EmployeeGalleryPage({ } } + async function loadTeams() { + try { + const rows = await api.get(`/api/enterprise/teams?tenant_id=${TENANT_ID}`); + setTeams(rows); + } catch (error) { + notify.error(error instanceof Error ? error.message : '加载团队失败'); + } + } + useEffect(() => { void load(); + void loadTeams(); }, []); // Keep these tabs aligned with the rest of the app: // - 所有员工: employees the current user can access and chat with // - 我的数字员工: employees the current user can manage/edit + // - 我的团队: teams owned by the current user // - 数字员工广场: public employees not already listed as mine const availableAgents = useMemo( () => visibleEmployeeAgents(agents, currentUser, { activeOnly: true }), @@ -103,6 +117,22 @@ export default function EmployeeGalleryPage({ ].some((value) => value.toLowerCase().includes(keyword)); }); + // 与「我的数字员工」语义对齐:优先展示当前用户拥有的团队; + // 没有用户信息时(如未登录预览)回退为全部团队。 + const myTeams = useMemo( + () => (currentUser ? teams.filter((team) => team.owner_user_id === currentUser.id) : teams), + [teams, currentUser], + ); + const filteredTeams = myTeams.filter((team) => { + const keyword = searchTerm.trim().toLowerCase(); + if (!keyword) return true; + return [ + team.name, + team.description || '', + teamLeader(team)?.agent_name || '', + ].some((value) => value.toLowerCase().includes(keyword)); + }); + async function startEmployeeChat(row: AgentProfileRead) { if (startingAgentId) return; setStartingAgentId(row.id); @@ -119,6 +149,22 @@ export default function EmployeeGalleryPage({ } } + async function startTeamChat(team: TeamRead) { + if (startingTeamId) return; + setStartingTeamId(team.id); + try { + const result = await api.post<{ session_id: string }>( + `/api/enterprise/teams/${team.id}/tl/session`, + { tenant_id: TENANT_ID }, + ); + navigate(`/workspace/chat/${result.session_id}`); + } catch (error) { + notify.error(error instanceof Error ? error.message : '发起团队对话失败'); + } finally { + setStartingTeamId(null); + } + } + async function updateStatus(row: AgentProfileRead, status: 'active' | 'archived') { try { await api.put(`/api/enterprise/agents/${row.id}`, { @@ -193,6 +239,7 @@ export default function EmployeeGalleryPage({ const galleryTabs: UnderlineTabItem[] = [ { value: 'all', label: '所有员工' }, { value: 'mine', label: '我的数字员工' }, + { value: 'teams', label: '我的团队' }, { value: 'gallery', label: '数字员工广场' }, ]; @@ -201,6 +248,10 @@ export default function EmployeeGalleryPage({ const emptyDescription = hasSearchTerm ? '换个关键词,或切换员工分类再试试' : '当前分类还没有可用员工'; + const teamsEmptyText = hasSearchTerm ? '没有匹配的团队' : '暂无团队'; + const teamsEmptyDescription = hasSearchTerm + ? '换个关键词再试试' + : '当前还没有属于你的团队'; return (
@@ -234,27 +285,46 @@ export default function EmployeeGalleryPage({ tabClassName="max-[560px]:min-h-[54px] max-[560px]:w-auto max-[560px]:flex-1 max-[560px]:px-[6px] max-[560px]:text-[12px] max-[560px]:leading-[16px]" /> -
- {filteredEmployees.map((employee) => ( - void startEmployeeChat(employee)} - onStatus={(status) => void updateStatus(employee, status)} - onGallery={(published) => void updateGalleryState(employee, published)} - onDelete={() => setDeleteTarget(employee)} - onAvatar={() => setAvatarAgent(employee)} - onEdit={() => setProfileAgent(employee)} - onChat={() => void startEmployeeChat(employee)} - /> - ))} - {!filteredEmployees.length && ( - - )} -
+ {scope === 'teams' ? ( +
+
+ {filteredTeams.map((team) => ( + void startTeamChat(team)} + /> + ))} + {!filteredTeams.length && ( + + )} +
+
+ ) : ( +
+ {filteredEmployees.map((employee) => ( + void startEmployeeChat(employee)} + onStatus={(status) => void updateStatus(employee, status)} + onGallery={(published) => void updateGalleryState(employee, published)} + onDelete={() => setDeleteTarget(employee)} + onAvatar={() => setAvatarAgent(employee)} + onEdit={() => setProfileAgent(employee)} + onChat={() => void startEmployeeChat(employee)} + /> + ))} + {!filteredEmployees.length && ( + + )} +
+ )} ('all'); - const [agentId, setAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const [agentId, setAgentId] = useState(readEmployeeScope); const [isOverallAgent, setIsOverallAgent] = useState(true); const [agents, setAgents] = useState([]); const [clawhubModalOpen, setClawhubModalOpen] = useState(false); @@ -415,8 +416,8 @@ export default function GeneralSkillsPage({ embedded = false, currentUser, onLog useEffect(() => { const onScopeChange = (event: Event) => { - const detail = (event as CustomEvent<{ agentId?: string }>).detail; - setAgentId(detail?.agentId || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; + setAgentId(next && !isTeamScope(next) ? next : readEmployeeScope()); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); @@ -1456,7 +1457,7 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | const [agentImportSourceAgentId, setAgentImportSourceAgentId] = useState(''); const [agentImportSourceSkills, setAgentImportSourceSkills] = useState([]); const [agentImportSelectedSkillIds, setAgentImportSelectedSkillIds] = useState([]); - const [agentId, setAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const [agentId, setAgentId] = useState(readEmployeeScope); const [isOverallAgent, setIsOverallAgent] = useState(true); const [agents, setAgents] = useState([]); const [deleteSkillTarget, setDeleteSkillTarget] = useState(null); @@ -1576,8 +1577,8 @@ function GeneralSkillEditorPage({ mode, currentUser, onLogout }: { mode: 'new' | useEffect(() => { const onScopeChange = (event: Event) => { if (forceGalleryScope) return; - const detail = (event as CustomEvent<{ agentId?: string }>).detail; - setAgentId(detail?.agentId || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; + setAgentId(next && !isTeamScope(next) ? next : readEmployeeScope()); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); diff --git a/frontend-enterprise/src/pages/KnowledgePage.tsx b/frontend-enterprise/src/pages/KnowledgePage.tsx index d4227c7d..9f803735 100644 --- a/frontend-enterprise/src/pages/KnowledgePage.tsx +++ b/frontend-enterprise/src/pages/KnowledgePage.tsx @@ -30,6 +30,7 @@ import { } from '@/components/CapabilityScopeControl'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { DataTable, type DataTableColumn } from '@/components/DataTable'; +import KnowledgeGraphCanvas from '@/components/KnowledgeGraphCanvas'; import { ModelConfigDropdown } from '@/components/ModelConfigDropdown'; import { Paginator } from '@/components/Paginator'; import { ResourceImportDialog } from '@/components/ResourceImportDialog'; @@ -63,8 +64,9 @@ import { DIALOG_CANCEL_BUTTON_CLASS, DIALOG_FOOTER_CLASS, DIALOG_PRIMARY_BUTTON_ import { clearSharedAgentScope, emitAgentScopeChange, - ENTERPRISE_AGENT_STORAGE_KEY, + isTeamScope, persistSharedAgentScope, + readEmployeeScope, } from '@/lib/agent-scope-storage'; import IconAdd from '../assets/icons/add.svg?react'; import IconChevronDown from '../assets/icons/chevron-down.svg?react'; @@ -175,7 +177,7 @@ export default function KnowledgeManagePage({ currentUser, onLogout }: Knowledge const [selectedDocument, setSelectedDocument] = useState(null); const [buckets, setBuckets] = useState([]); const [loading, setLoading] = useState(false); - const [agentId, setAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const [agentId, setAgentId] = useState(readEmployeeScope); const [agentScopeLoaded, setAgentScopeLoaded] = useState(false); const [agents, setAgents] = useState([]); const [importOpen, setImportOpen] = useState(false); @@ -344,7 +346,8 @@ export default function KnowledgeManagePage({ currentUser, onLogout }: Knowledge useEffect(() => { const onScopeChange = (event: Event) => { - setAgentId((event as CustomEvent<{ agentId?: string }>).detail?.agentId || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; + setAgentId(next && !isTeamScope(next) ? next : readEmployeeScope()); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); @@ -1634,7 +1637,7 @@ export function KnowledgeAddPage({ currentUser }: KnowledgePageProps = {}) { const [knowledgeBases, setKnowledgeBases] = useState([]); const [capabilityScope, setCapabilityScope] = useState('general'); const [jobs, setJobs] = useState>({}); - const [agentId, setAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const [agentId, setAgentId] = useState(readEmployeeScope); const [agentScopeLoaded, setAgentScopeLoaded] = useState(false); const [checkedDiscoveryJobIds, setCheckedDiscoveryJobIds] = useState([]); const [pendingDiscoveries, setPendingDiscoveries] = useState([]); @@ -1690,7 +1693,8 @@ export function KnowledgeAddPage({ currentUser }: KnowledgePageProps = {}) { useEffect(() => { const onScopeChange = (event: Event) => { - setAgentId((event as CustomEvent<{ agentId?: string }>).detail?.agentId || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; + setAgentId(next && !isTeamScope(next) ? next : readEmployeeScope()); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); @@ -2146,6 +2150,7 @@ function 目录索引Overview({ const [detailFocusKey, setDetailFocusKey] = useState(null); const [activeContentView, setActiveContentView] = useState('evidence'); const [wikiPresentation, setWikiPresentation] = useState<'graph' | 'cards'>('graph'); + const [wikiViewMode, setWikiViewMode] = useState<'graph' | 'cards'>('graph'); const metadata = document.metadata || {}; const documentCard = isRecord(metadata.document_card) ? metadata.document_card : {}; const wikiStructureConcepts = useMemo(() => sortWikiConcepts(okfConcepts), [okfConcepts]); @@ -2390,7 +2395,7 @@ function 目录索引Overview({ setDetailView(null)} > @@ -2530,50 +2535,72 @@ function 目录索引Overview({ {okfConcepts.length === 0 ? ( ) : ( - okfConcepts.map((concept) => ( -
onViewConcept(concept)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - onViewConcept(concept); - } - }} - > -
-
-
- {conceptTypeLabel(concept.concept_type)} - {statusTag(concept.status)} -
-
{concept.title || concept.concept_id}
-
- { - event.stopPropagation(); - onEditConcept(concept); + <> +
+ + +
+ {wikiViewMode === 'graph' ? ( + + ) : ( + okfConcepts.map((concept) => ( +
onViewConcept(concept)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onViewConcept(concept); + } }} > - - 编辑 - -
-

{concept.description || conceptSummary(concept)}

-
- {concept.concept_id} - {concept.links.length} 个链接 - {concept.citations.length} 个引用 - {concept.document_id ? 来源文档 {concept.document_id} : null} -
-
- )) +
+
+
+ {conceptTypeLabel(concept.concept_type)} + {statusTag(concept.status)} +
+
{concept.title || concept.concept_id}
+
+ { + event.stopPropagation(); + onEditConcept(concept); + }} + > + + 编辑 + +
+

{concept.description || conceptSummary(concept)}

+
+ {concept.concept_id} + {concept.links.length} 个链接 + {concept.citations.length} 个引用 + {concept.document_id ? 来源文档 {concept.document_id} : null} +
+
+ )) + )} + )}
)} diff --git a/frontend-enterprise/src/pages/OpenPlatformPage.tsx b/frontend-enterprise/src/pages/OpenPlatformPage.tsx index b92cd649..696defca 100644 --- a/frontend-enterprise/src/pages/OpenPlatformPage.tsx +++ b/frontend-enterprise/src/pages/OpenPlatformPage.tsx @@ -42,6 +42,7 @@ import { type PlatformResourceAccent, type PlatformStat, } from '@/components/openPlatform'; +import { isTeamScope, readEmployeeScope } from '@/lib/agent-scope-storage'; const ENTERPRISE_AGENT_STORAGE_KEY = 'ultrarag_enterprise_agent_scope'; @@ -189,16 +190,14 @@ export default function OpenPlatformPage({ const [tools, setTools] = useState([]); const [loading, setLoading] = useState(false); const [deletingItemKey, setDeletingItemKey] = useState(''); - const [agentId, setAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const [agentId, setAgentId] = useState(readEmployeeScope); const [detailItem, setDetailItem] = useState<{ kind: PlatformKind; item: PlatformItem } | null>(null); const [confirmTarget, setConfirmTarget] = useState<{ kind: PlatformKind; item: PlatformItem } | null>(null); useEffect(() => { const onScopeChange = (event: Event) => { - const nextAgentId = (event as CustomEvent<{ agentId?: string }>).detail?.agentId - || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) - || ''; - setAgentId(nextAgentId); + const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; + setAgentId(next && !isTeamScope(next) ? next : readEmployeeScope()); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); diff --git a/frontend-enterprise/src/pages/PersonaPage.tsx b/frontend-enterprise/src/pages/PersonaPage.tsx index 3712df6c..50ab19ef 100644 --- a/frontend-enterprise/src/pages/PersonaPage.tsx +++ b/frontend-enterprise/src/pages/PersonaPage.tsx @@ -11,10 +11,9 @@ import { notify, } from '@/components/ui'; import { api, TENANT_ID } from '../api/client'; +import { isTeamScope, readEmployeeScope } from '../lib/agent-scope-storage'; import type { AgentProfileRead, PersonaRead } from '../types'; -const ENTERPRISE_AGENT_STORAGE_KEY = 'ultrarag_enterprise_agent_scope'; - type PersonaForm = { agent_name: string; agent_description: string; @@ -37,7 +36,7 @@ export default function PersonaPage() { const [loading, setLoading] = useState(false); const [updatedAt, setUpdatedAt] = useState(''); const [agents, setAgents] = useState([]); - const [selectedAgentId, setSelectedAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const [selectedAgentId, setSelectedAgentId] = useState(readEmployeeScope); const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) || null; const isOverallPersona = !selectedAgent || selectedAgent.is_overall; @@ -50,7 +49,7 @@ export default function PersonaPage() { useEffect(() => { const onScopeChange = (event: Event) => { const agentId = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; - if (agentId) setSelectedAgentId(agentId); + if (agentId && !isTeamScope(agentId)) setSelectedAgentId(agentId); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); @@ -95,7 +94,7 @@ export default function PersonaPage() { const rows = await api.get(`/api/enterprise/agents?tenant_id=${TENANT_ID}`); setAgents(rows); setSelectedAgentId((current) => { - const stored = window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY); + const stored = readEmployeeScope(); const candidate = current || stored || ''; if (candidate && rows.some((agent) => agent.id === candidate)) return candidate; return rows.find((agent) => agent.is_overall)?.id || rows[0]?.id || ''; diff --git a/frontend-enterprise/src/pages/SkillsPage.tsx b/frontend-enterprise/src/pages/SkillsPage.tsx index 0bc40525..c4e45d46 100644 --- a/frontend-enterprise/src/pages/SkillsPage.tsx +++ b/frontend-enterprise/src/pages/SkillsPage.tsx @@ -55,11 +55,11 @@ import { visibleEmployeeAgents, } from '../employee'; import { useClientPagination } from '../hooks/useClientPagination'; +import { isTeamScope, readEmployeeScope } from '../lib/agent-scope-storage'; import { StatusBadge } from './scheduled-tasks/StatusBadge'; import type { BadgeTone } from './scheduled-tasks/shared'; import type { AgentProfileRead, SkillRead, SkillVersionRead } from '../types'; -const ENTERPRISE_AGENT_STORAGE_KEY = 'ultrarag_enterprise_agent_scope'; const SKILL_PAGE_SIZE = 10; const RANKING_PAGE_SIZE = 10; @@ -110,9 +110,9 @@ export default function SkillsPage({ const [negativeScope, setNegativeScope] = useState('current'); const [versionModalOpen, setVersionModalOpen] = useState(false); const [loading, setLoading] = useState(false); - const [agentId, setAgentId] = useState(() => window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const [agentId, setAgentId] = useState(readEmployeeScope); const [isOverallAgent, setIsOverallAgent] = useState(() => { - const stored = window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''; + const stored = readEmployeeScope(); return !stored || stored.includes('overall'); }); const [searchText, setSearchText] = useState(''); @@ -173,8 +173,8 @@ export default function SkillsPage({ useEffect(() => { const onScopeChange = (event: Event) => { - const detail = (event as CustomEvent<{ agentId?: string }>).detail; - setAgentId(detail?.agentId || window.localStorage.getItem(ENTERPRISE_AGENT_STORAGE_KEY) || ''); + const next = (event as CustomEvent<{ agentId?: string }>).detail?.agentId || ''; + setAgentId(next && !isTeamScope(next) ? next : readEmployeeScope()); }; window.addEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); return () => window.removeEventListener('ultrarag-enterprise-agent-scope-change', onScopeChange); diff --git a/frontend-enterprise/src/pages/TeamChatPage.test.tsx b/frontend-enterprise/src/pages/TeamChatPage.test.tsx new file mode 100644 index 00000000..48b23ed6 --- /dev/null +++ b/frontend-enterprise/src/pages/TeamChatPage.test.tsx @@ -0,0 +1,304 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '@/i18n'; +import type { AgentProfileRead } from '@/types'; + +import TeamChatPage, { + type TeamChatMessage, + type TeamConversationsResponse, +} from './TeamChatPage'; + +function minutesAgo(minutes: number): string { + return new Date(Date.now() - minutes * 60000).toISOString(); +} + +const agents: AgentProfileRead[] = [ + { + id: 'agent-1', + tenant_id: 'tenant_demo', + name: '小艾', + is_overall: false, + status: 'active', + metadata: {}, + resources: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', + }, + { + id: 'agent-2', + tenant_id: 'tenant_demo', + name: '小北', + is_overall: false, + status: 'active', + metadata: {}, + resources: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', + }, +]; + +function makeConversations(tlSessionId: string | null = 'session-tl-1'): TeamConversationsResponse { + return { + team_id: 'team-1', + team_name: '增长团队', + tl: { agent_id: 'agent-1', agent_name: '小艾', session_id: tlSessionId }, + conversations: [ + ...(tlSessionId + ? [ + { + session_id: tlSessionId, + kind: 'tl_chat' as const, + agent_id: 'agent-1', + agent_name: '小艾', + task_id: null, + title: 'TL 对话', + preview: '在的,请讲', + updated_at: minutesAgo(2), + }, + ] + : []), + { + session_id: 'session-task-1', + kind: 'member_task' as const, + agent_id: 'agent-2', + agent_name: '小北', + task_id: 'task-1', + title: '写周报', + preview: '周报已完成,请验收', + updated_at: minutesAgo(5), + }, + { + session_id: 'session-bid-1', + kind: 'member_bid' as const, + agent_id: 'agent-2', + agent_name: '小北', + task_id: 'task-2', + title: '竞标方案', + preview: '我擅长数据分析', + updated_at: minutesAgo(30), + }, + { + session_id: 'session-review-1', + kind: 'tl_review' as const, + agent_id: 'agent-2', + agent_name: '小北', + task_id: 'task-1', + title: '验收:写周报', + preview: '验收通过', + updated_at: minutesAgo(60), + }, + ], + }; +} + +const tlMessages: TeamChatMessage[] = [ + { id: 'm-1', role: 'user', content: '请拆解本月目标', created_at: minutesAgo(4) }, + { id: 'm-2', role: 'assistant', content: '好的,我来拆解', created_at: minutesAgo(2) }, +]; + +const taskMessages: TeamChatMessage[] = [ + { id: 'm-3', role: 'assistant', content: '周报已完成,请验收', created_at: minutesAgo(5) }, +]; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(body ?? {}), + } as Response; +} + +function stubChatFetch(overrides?: { + tlSessionId?: string | null; + createdSessionId?: string; + reply?: string; +}) { + const tlSessionId = overrides && 'tlSessionId' in overrides ? overrides.tlSessionId : 'session-tl-1'; + const createdSessionId = overrides?.createdSessionId || 'session-tl-created'; + const reply = overrides?.reply ?? '已收到,开始执行'; + const messagesBySession: Record = { + 'session-tl-1': tlMessages, + 'session-task-1': taskMessages, + [createdSessionId]: [], + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method || 'GET').toUpperCase(); + if (url.includes('/tl/session')) { + return jsonResponse({ session_id: createdSessionId }); + } + if (url.includes('/api/chat/turn')) { + return jsonResponse({ reply, session_id: tlSessionId || createdSessionId }); + } + const messagesMatch = url.match(/\/conversations\/([^/?]+)\/messages/); + if (messagesMatch) { + return jsonResponse(messagesBySession[messagesMatch[1]] ?? []); + } + if (url.includes('/conversations')) { + return jsonResponse(makeConversations(tlSessionId ?? null)); + } + if (url.includes('/api/enterprise/agents')) return jsonResponse(agents); + return jsonResponse({}); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function LocationEcho() { + const location = useLocation(); + return
{location.pathname}
; +} + +function renderChat() { + return render( + + + + } /> + } /> + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('TeamChatPage', () => { + it('renders the ownership header with team name, TL identity and avatar', async () => { + stubChatFetch(); + renderChat(); + + expect(await screen.findByRole('heading', { name: '增长团队' })).toBeTruthy(); + expect(screen.getByText('TL:小艾')).toBeTruthy(); + expect(screen.getAllByLabelText(/员工头像/).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: '返回团队' })).toBeTruthy(); + }); + + it('navigates back to the team detail page', async () => { + const user = userEvent.setup(); + stubChatFetch(); + renderChat(); + + await screen.findByRole('heading', { name: '增长团队' }); + await user.click(screen.getByRole('button', { name: '返回团队' })); + + expect((await screen.findByTestId('location')).textContent).toBe('/enterprise/teams/team-1'); + }); + + it('lists the TL conversation first with member conversations grouped below', async () => { + const user = userEvent.setup(); + stubChatFetch(); + renderChat(); + + const list = await screen.findByLabelText('会话列表'); + const tlItem = await within(list).findByRole('button', { name: /TL 对话/ }); + const taskItem = within(list).getByRole('button', { name: /员工头像 写周报/ }); + // TL 对话固定在最上 + expect(tlItem.compareDocumentPosition(taskItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(tlItem).getByText('TL')).toBeTruthy(); + // kind 中文标签 + expect(within(list).getByText('任务执行')).toBeTruthy(); + expect(within(list).getByText('竞标')).toBeTruthy(); + expect(within(list).getByText('验收')).toBeTruthy(); + // preview 与相对时间 + expect(within(taskItem).getByText('周报已完成,请验收')).toBeTruthy(); + expect(within(taskItem).getByText('5 分钟前')).toBeTruthy(); + // 默认选中 TL 对话,点击后切换高亮 + expect(tlItem.getAttribute('aria-current')).toBe('true'); + await user.click(taskItem); + expect(taskItem.getAttribute('aria-current')).toBe('true'); + expect(tlItem.getAttribute('aria-current')).toBeNull(); + }); + + it('renders user messages on the right and agent replies on the left with avatar', async () => { + stubChatFetch(); + renderChat(); + + const area = await screen.findByLabelText('消息区'); + expect(await within(area).findByText('请拆解本月目标')).toBeTruthy(); + const userRow = within(area).getByText('请拆解本月目标').closest('[data-role]') as HTMLElement; + expect(userRow.getAttribute('data-role')).toBe('user'); + expect(userRow.className).toContain('justify-end'); + + const agentRow = within(area).getByText('好的,我来拆解').closest('[data-role]') as HTMLElement; + expect(agentRow.getAttribute('data-role')).toBe('agent'); + expect(agentRow.className).toContain('justify-start'); + expect(within(agentRow).getByLabelText(/员工头像/)).toBeTruthy(); + }); + + it('sends a TL message via /api/chat/turn and appends both messages', async () => { + const user = userEvent.setup(); + const fetchMock = stubChatFetch({ reply: '收到,马上安排' }); + renderChat(); + + const input = await screen.findByLabelText('输入消息'); + await user.type(input, '帮我规划下周任务'); + await user.click(screen.getByRole('button', { name: '发送' })); + + await waitFor(() => { + const turnCall = fetchMock.mock.calls.find(([input, init]) => + String(input).includes('/api/chat/turn') && String(init?.method || '').toUpperCase() === 'POST', + ); + expect(turnCall).toBeTruthy(); + const body = JSON.parse(String(turnCall?.[1]?.body)) as Record; + expect(body.session_id).toBe('session-tl-1'); + expect(body.message).toBe('帮我规划下周任务'); + expect(body.tenant_id).toBeTruthy(); + }); + expect(await screen.findByText('帮我规划下周任务')).toBeTruthy(); + expect(await screen.findByText('收到,马上安排')).toBeTruthy(); + expect((screen.getByLabelText('输入消息') as HTMLInputElement).value).toBe(''); + }); + + it('shows a read-only notice instead of the composer for member conversations', async () => { + const user = userEvent.setup(); + stubChatFetch(); + renderChat(); + + const list = await screen.findByLabelText('会话列表'); + await user.click(within(list).getByRole('button', { name: /员工头像 写周报/ })); + + expect(await screen.findByText('任务会话仅可查看')).toBeTruthy(); + expect(screen.queryByLabelText('输入消息')).toBeNull(); + // 只读会话仍能查看历史消息 + const area = screen.getByLabelText('消息区'); + expect(await within(area).findByText('周报已完成,请验收')).toBeTruthy(); + }); + + it('creates the TL session first when tl.session_id is null', async () => { + const fetchMock = stubChatFetch({ tlSessionId: null, createdSessionId: 'session-tl-created' }); + renderChat(); + + await waitFor(() => { + const createCall = fetchMock.mock.calls.find(([input, init]) => + String(input).includes('/teams/team-1/tl/session') + && String(init?.method || '').toUpperCase() === 'POST', + ); + expect(createCall).toBeTruthy(); + const body = JSON.parse(String(createCall?.[1]?.body)) as Record; + expect(body.tenant_id).toBeTruthy(); + }); + // 创建成功后才拉取新会话的消息 + await waitFor(() => { + const messagesCall = fetchMock.mock.calls.find(([input]) => + String(input).includes('/conversations/session-tl-created/messages'), + ); + expect(messagesCall).toBeTruthy(); + }); + // 会话就绪后输入框可用 + await waitFor(() => { + expect((screen.getByLabelText('输入消息') as HTMLInputElement).disabled).toBe(false); + }); + }); +}); diff --git a/frontend-enterprise/src/pages/TeamChatPage.tsx b/frontend-enterprise/src/pages/TeamChatPage.tsx new file mode 100644 index 00000000..9d6108c9 --- /dev/null +++ b/frontend-enterprise/src/pages/TeamChatPage.tsx @@ -0,0 +1,435 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import { Badge, Button, Input } from '@/components/ui'; +import { notify } from '@/components/ui/app-toast'; +import { cn } from '@/lib/utils'; + +import { api, TENANT_ID } from '../api/client'; +import type { EnterpriseAuthUser } from '../auth'; +import AppHeader from '../components/AppHeader'; +import EmployeeAvatar from '../components/EmployeeAvatar'; +import { formatClientDateTime } from '../lib/timezone'; +import type { AgentProfileRead } from '../types'; + +import { relativeTimeLabel } from './TeamsPage'; + +export type TeamConversationKind = 'tl_chat' | 'member_task' | 'member_bid' | 'tl_review'; + +export interface TeamConversationSummary { + session_id: string; + kind: TeamConversationKind; + agent_id: string | null; + agent_name: string | null; + task_id: string | null; + title: string; + preview: string; + updated_at: string; +} + +export interface TeamConversationsResponse { + team_id: string; + team_name: string; + tl: { agent_id: string; agent_name: string | null; session_id: string | null }; + conversations: TeamConversationSummary[]; +} + +export interface TeamChatMessage { + id: string; + role: string; + content: string; + created_at: string; +} + +const CONVERSATION_KIND_LABELS: Record = { + tl_chat: 'TL 对话', + member_task: '任务执行', + member_bid: '竞标', + tl_review: '验收', +}; + +export function conversationKindLabel(kind: string): string { + return CONVERSATION_KIND_LABELS[kind as TeamConversationKind] || kind; +} + +const TL_KEY = '__tl__'; + +export default function TeamChatPage({ + currentUser, + onLogout, +}: { + currentUser?: EnterpriseAuthUser; + isAdmin?: boolean; + onLogout?: () => void; +}) { + const { teamId = '' } = useParams<{ teamId: string }>(); + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedKey, setSelectedKey] = useState(TL_KEY); + const [tlSessionId, setTlSessionId] = useState(null); + const [creatingTlSession, setCreatingTlSession] = useState(false); + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [draft, setDraft] = useState(''); + const [sending, setSending] = useState(false); + const listRef = useRef(null); + + useEffect(() => { + setLoading(true); + void Promise.all([ + api + .get( + `/api/enterprise/teams/${teamId}/conversations?tenant_id=${TENANT_ID}`, + ) + .then((response) => { + setData(response); + setTlSessionId(response.tl.session_id); + }) + .catch((error) => { + notify.error(error instanceof Error ? error.message : '加载团队会话失败'); + }), + api + .get(`/api/enterprise/agents?tenant_id=${TENANT_ID}`) + .then(setAgents) + .catch(() => setAgents([])), + ]).finally(() => setLoading(false)); + }, [teamId]); + + const agentById = useMemo(() => new Map(agents.map((agent) => [agent.id, agent])), [agents]); + + const tlConversation = useMemo( + () => data?.conversations.find((item) => item.kind === 'tl_chat') || null, + [data], + ); + const memberConversations = useMemo( + () => (data?.conversations || []).filter((item) => item.kind !== 'tl_chat'), + [data], + ); + + const selectedSessionId = selectedKey === TL_KEY ? tlSessionId : selectedKey; + + const selectedConversation = useMemo(() => { + if (!data) return null; + if (selectedKey === TL_KEY) { + return { + kind: 'tl_chat' as TeamConversationKind, + agent_id: data.tl.agent_id, + agent_name: data.tl.agent_name, + title: tlConversation?.title || 'TL 对话', + }; + } + const found = memberConversations.find((item) => item.session_id === selectedKey); + if (!found) return null; + return { + kind: found.kind, + agent_id: found.agent_id, + agent_name: found.agent_name, + title: found.title, + }; + }, [data, selectedKey, tlConversation, memberConversations]); + + // TL 会话不存在时,进入 TL 对话先创建会话 + useEffect(() => { + if (!data || selectedKey !== TL_KEY || tlSessionId || creatingTlSession) return; + setCreatingTlSession(true); + api + .post<{ session_id: string }>(`/api/enterprise/teams/${teamId}/tl/session`, { + tenant_id: TENANT_ID, + }) + .then((response) => setTlSessionId(response.session_id || null)) + .catch((error) => { + notify.error(error instanceof Error ? error.message : '创建 TL 会话失败'); + }) + .finally(() => setCreatingTlSession(false)); + }, [data, selectedKey, tlSessionId, creatingTlSession, teamId]); + + useEffect(() => { + if (!selectedSessionId) { + setMessages([]); + return; + } + let cancelled = false; + setMessagesLoading(true); + api + .get( + `/api/enterprise/teams/${teamId}/conversations/${selectedSessionId}/messages?tenant_id=${TENANT_ID}`, + ) + .then((rows) => { + if (!cancelled) setMessages(rows); + }) + .catch((error) => { + if (!cancelled) { + setMessages([]); + notify.error(error instanceof Error ? error.message : '加载消息失败'); + } + }) + .finally(() => { + if (!cancelled) setMessagesLoading(false); + }); + return () => { + cancelled = true; + }; + }, [teamId, selectedSessionId]); + + useEffect(() => { + const list = listRef.current; + if (list) list.scrollTop = list.scrollHeight; + }, [messages, selectedKey, messagesLoading]); + + const selectConversation = useCallback((key: string) => { + setSelectedKey(key); + setDraft(''); + }, []); + + async function sendMessage() { + const text = draft.trim(); + if (!text || sending || selectedKey !== TL_KEY || !tlSessionId) return; + setSending(true); + try { + const response = await api.post<{ reply?: string; session_id?: string }>('/api/chat/turn', { + tenant_id: TENANT_ID, + session_id: tlSessionId, + message: text, + }); + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { id: `local-user-${now}`, role: 'user', content: text, created_at: now }, + { + id: `local-assistant-${now}`, + role: 'assistant', + content: response.reply || '', + created_at: new Date().toISOString(), + }, + ]); + setDraft(''); + } catch (error) { + notify.error(error instanceof Error ? error.message : '发送失败'); + } finally { + setSending(false); + } + } + + const tlName = data?.tl.agent_name || 'TL'; + const isTlSelected = selectedKey === TL_KEY; + const canSend = isTlSelected && Boolean(tlSessionId) && !creatingTlSession; + + function conversationItem(options: { + itemKey: string; + agentId: string | null; + title: string; + preview: string; + updatedAt: string; + kind: string; + isTl?: boolean; + }) { + const { itemKey, agentId, title, preview, updatedAt, kind, isTl } = options; + const selected = selectedKey === itemKey; + return ( + + ); + } + + return ( +
+ + +
+

+ {data?.team_name || '团队聊天室'} +

+

{`TL:${tlName}`}

+
+
+ )} + /> + +
+ +
+ +
+
+ {data && ( + <> + {conversationItem({ + itemKey: TL_KEY, + agentId: data.tl.agent_id, + title: tlConversation?.title || 'TL 对话', + preview: tlConversation?.preview || '', + updatedAt: tlConversation?.updated_at || '', + kind: 'tl_chat', + isTl: true, + })} + {memberConversations.length > 0 &&
} + {memberConversations.map((item) => + conversationItem({ + itemKey: item.session_id, + agentId: item.agent_id, + title: item.title, + preview: item.preview, + updatedAt: item.updated_at, + kind: item.kind, + }), + )} + {memberConversations.length === 0 && ( +

暂无员工会话

+ )} + + )} + {!data && !loading && ( +

暂无会话

+ )} +
+ +
+
+

+ {selectedConversation?.title || ''} +

+ {selectedConversation && ( + + {conversationKindLabel(selectedConversation.kind)} + + )} +
+ +
+ {messagesLoading && ( +

加载中…

+ )} + {!messagesLoading && messages.length === 0 && ( +
+

+ {creatingTlSession ? '正在创建 TL 会话…' : '暂无消息'} +

+
+ )} + {!messagesLoading && + messages.map((message) => { + const isUser = message.role === 'user'; + return ( +
+ {!isUser && ( + + )} +
+

{message.content}

+ + {formatClientDateTime(message.created_at, '')} + +
+
+ ); + })} +
+ +
+ {isTlSelected ? ( +
{ + event.preventDefault(); + void sendMessage(); + }} + > + setDraft(event.target.value)} + placeholder="向 TL 发送消息…" + aria-label="输入消息" + disabled={!canSend || sending} + className="h-[40px] flex-1 rounded-[10px] border-[#e3e7f1] text-[14px]" + /> + +
+ ) : ( +

+ 任务会话仅可查看 +

+ )} +
+
+
+
+ ); +} diff --git a/frontend-enterprise/src/pages/TeamDetailPage.test.tsx b/frontend-enterprise/src/pages/TeamDetailPage.test.tsx new file mode 100644 index 00000000..a314fe31 --- /dev/null +++ b/frontend-enterprise/src/pages/TeamDetailPage.test.tsx @@ -0,0 +1,806 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '@/i18n'; +import type { AgentProfileRead, TeamBlackboardEntryRead, TeamEventRead, TeamRead, TeamTaskBidRead, TeamTaskRead } from '@/types'; + +import TeamDetailPage from './TeamDetailPage'; + +const team: TeamRead = { + id: 'team-1', + tenant_id: 'tenant_demo', + name: '增长团队', + description: '负责增长实验', + owner_user_id: 'user-1', + config: {}, + status: 'active', + members: [ + { + id: 'member-1', + team_id: 'team-1', + agent_id: 'agent-1', + role: 'leader', + agent_name: '小艾', + created_at: '2026-08-01T00:00:00Z', + }, + { + id: 'member-2', + team_id: 'team-1', + agent_id: 'agent-2', + role: 'member', + agent_name: '小北', + created_at: '2026-08-01T00:00:00Z', + }, + ], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', +}; + +function makeTask(overrides: Partial): TeamTaskRead { + return { + id: 'task-1', + team_id: 'team-1', + tenant_id: 'tenant_demo', + parent_task_id: null, + title: '写周报', + description: '汇总本周数据', + priority: 'high', + status: 'review', + created_by_user_id: null, + created_by_tl: true, + assignee_agent_id: 'agent-2', + session_id: null, + report: { summary: '周报已完成' }, + review: {}, + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', + ...overrides, + }; +} + +const tasks: TeamTaskRead[] = [ + makeTask({ id: 'task-1', title: '写周报', status: 'review' }), + makeTask({ id: 'task-2', title: '整理线索', status: 'pending', priority: 'low', assignee_agent_id: null }), + makeTask({ id: 'task-3', title: '投放分析', status: 'in_progress', priority: 'medium' }), + makeTask({ id: 'task-4', title: '竞标方案', status: 'bidding', assignee_agent_id: null }), + makeTask({ id: 'task-5', title: '竞标已裁决', status: 'pending', assignee_agent_id: 'agent-1' }), +]; + +function makeBid(overrides: Partial): TeamTaskBidRead { + return { + id: 'bid-1', + task_id: 'task-4', + agent_id: 'agent-1', + agent_name: '小艾', + round: 1, + kind: 'statement', + content: '我擅长数据分析', + score: null, + score_rationale: null, + created_at: '2026-08-02T00:00:00Z', + ...overrides, + }; +} + +const awardedBids: TeamTaskBidRead[] = [ + makeBid({ id: 'bid-1', round: 1, kind: 'statement', content: '我擅长数据分析', score: 8, score_rationale: '方案具体' }), + makeBid({ id: 'bid-2', agent_id: 'agent-2', agent_name: '小北', round: 1, kind: 'statement', content: '我可以快速交付', score: 6, score_rationale: null }), + makeBid({ id: 'bid-3', round: 2, kind: 'rebuttal', content: '对方缺少落地案例', score: 7, score_rationale: null }), +]; + +const agents: AgentProfileRead[] = [ + { + id: 'agent-3', + tenant_id: 'tenant_demo', + name: '小丙', + is_overall: false, + status: 'active', + metadata: {}, + resources: [], + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-01T00:00:00Z', + }, +]; + +function jsonResponse(body: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(body ?? {}), + } as Response; +} + +function makeEntry(overrides: Partial): TeamBlackboardEntryRead { + return { + id: 'entry-1', + team_id: 'team-1', + tenant_id: 'tenant_demo', + content: 'entry content', + tags: [], + source_type: 'human', + source_agent_id: null, + source_task_id: null, + citation: {}, + status: 'active', + pinned: false, + created_at: '2026-08-01T00:00:00Z', + updated_at: '2026-08-02T00:00:00Z', + ...overrides, + }; +} + +function stubDetailFetch(overrides?: { + entries?: TeamBlackboardEntryRead[]; + events?: TeamEventRead[]; + teamOverride?: TeamRead; + taskList?: TeamTaskRead[]; + onTlSession?: () => { session_id: string }; + taskDetails?: Record; +}) { + let boardRows = [...(overrides?.entries ?? [])]; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes('/blackboard')) { + const method = (init?.method || 'GET').toUpperCase(); + if (url.includes('/archive')) { + const entryId = url.split('/blackboard/')[1]?.split('/')[0]; + boardRows = boardRows.filter((row) => row.id !== entryId); + return jsonResponse({}); + } + if (url.includes('/promote')) { + const entryId = url.split('/blackboard/')[1]?.split('/')[0]; + boardRows = boardRows.map((row) => + row.id === entryId ? { ...row, citation: { ...row.citation, knowledge_base_id: 'kb-1' } } : row, + ); + return jsonResponse(boardRows.find((row) => row.id === entryId) ?? {}); + } + if (method === 'POST') { + const body = JSON.parse(String(init?.body)) as { content: string; tags?: string[] }; + const created = makeEntry({ id: 'entry-new', content: body.content, tags: body.tags ?? [] }); + boardRows = [...boardRows, created]; + return jsonResponse(created); + } + if (method === 'PUT') { + const body = JSON.parse(String(init?.body)) as Record; + const entryId = url.split('/blackboard/')[1]?.split('?')[0]; + boardRows = boardRows.map((row) => (row.id === entryId ? { ...row, ...body } : row)); + return jsonResponse(boardRows.find((row) => row.id === entryId) ?? {}); + } + return jsonResponse(boardRows); + } + if (url.includes('/tl/session')) { + return jsonResponse(overrides?.onTlSession?.() ?? { session_id: 'session-1' }); + } + if (url.includes('/award-override')) { + return jsonResponse(makeTask({ status: 'pending', assignee_agent_id: 'agent-1' })); + } + if (url.includes('/override')) return jsonResponse(makeTask({ status: 'done' })); + const detailMatch = url.match(/\/tasks\/([^/?]+)\?/); + if (detailMatch && overrides?.taskDetails?.[detailMatch[1]]) { + return jsonResponse(overrides.taskDetails[detailMatch[1]]); + } + if (url.includes('/tasks/task-1')) { + return jsonResponse( + makeTask({ + events: [ + { + id: 'event-1', + task_id: 'task-1', + team_id: 'team-1', + actor_type: 'tl', + actor_id: 'agent-1', + event_type: 'submitted', + payload: {}, + created_at: '2026-08-02T00:00:00Z', + }, + ], + }), + ); + } + if (url.includes('/tasks')) { + const method = (init?.method || 'GET').toUpperCase(); + if (method === 'POST') { + const body = JSON.parse(String(init?.body)) as { title: string; assignee_agent_id?: string }; + return jsonResponse( + makeTask({ + id: 'task-new', + title: body.title, + status: body.assignee_agent_id ? 'pending' : 'bidding', + assignee_agent_id: body.assignee_agent_id ?? null, + }), + ); + } + return jsonResponse(overrides?.taskList ?? tasks); + } + if (url.includes('/api/enterprise/agents')) return jsonResponse(agents); + if (url.includes('/events')) return jsonResponse(overrides?.events ?? []); + if (url.includes('/api/enterprise/teams/team-1')) { + const method = (init?.method || 'GET').toUpperCase(); + const currentTeam = overrides?.teamOverride ?? team; + if (method === 'PUT') { + const body = JSON.parse(String(init?.body)) as { config?: Record }; + return jsonResponse(body.config ? { ...currentTeam, config: body.config } : currentTeam); + } + return jsonResponse(currentTeam); + } + return jsonResponse({}); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function LocationEcho() { + const location = useLocation(); + return
{`${location.pathname}${location.search}`}
; +} + +function renderDetail(initialEntry = '/enterprise/teams/team-1') { + return render( + + + + } /> + } /> + } /> + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +beforeAll(() => { + // Radix Select 在 jsdom 中需要 pointer capture API + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + window.HTMLElement.prototype.hasPointerCapture = vi.fn(); + window.HTMLElement.prototype.releasePointerCapture = vi.fn(); +}); + +describe('TeamDetailPage', () => { + it('renders members and groups kanban tasks by status', async () => { + stubDetailFetch(); + renderDetail(); + + expect(await screen.findByText('增长团队')).toBeTruthy(); + + const members = screen.getByLabelText('成员管理'); + expect(within(members).getByText('小艾')).toBeTruthy(); + expect(within(members).getByText('TL')).toBeTruthy(); + expect(within(members).getByText('小北')).toBeTruthy(); + + const board = screen.getByLabelText('任务看板'); + expect(await within(board).findByText('写周报')).toBeTruthy(); + const reviewColumn = within(board).getByText('待验收').closest('div')?.parentElement as HTMLElement; + expect(within(reviewColumn).getByText('写周报')).toBeTruthy(); + const pendingColumn = within(board).getByText('待认领').closest('div')?.parentElement as HTMLElement; + expect(within(pendingColumn).getByText('整理线索')).toBeTruthy(); + expect(within(pendingColumn).getByText('未分配')).toBeTruthy(); + const progressColumn = within(board).getByText('进行中').closest('div')?.parentElement as HTMLElement; + expect(within(progressColumn).getByText('投放分析')).toBeTruthy(); + }); + + it('submits an override verdict from the task detail dialog', async () => { + const user = userEvent.setup(); + const fetchMock = stubDetailFetch(); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(await within(board).findByText('写周报')); + + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByText('周报已完成')).toBeTruthy(); + expect(within(dialog).getByText('submitted')).toBeTruthy(); + + await user.click(within(dialog).getByRole('button', { name: '通过' })); + + await waitFor(() => { + const overrideCall = fetchMock.mock.calls.find(([input]) => + String(input).includes('/tasks/task-1/override'), + ); + expect(overrideCall).toBeTruthy(); + const body = JSON.parse(String(overrideCall?.[1]?.body)) as Record; + expect(body.verdict).toBe('approve'); + }); + }); + + it('navigates to the team chat room from the entry card', async () => { + const user = userEvent.setup(); + const fetchMock = stubDetailFetch(); + renderDetail(); + + const chat = await screen.findByLabelText('TL 对话'); + await user.click(within(chat).getByRole('button', { name: '打开 TL 对话' })); + + expect((await screen.findByTestId('location')).textContent).toBe('/enterprise/teams/team-1/chat'); + // TL 会话创建逻辑已移至团队聊天室页面,这里不应再调用 tl/session + const sessionCall = fetchMock.mock.calls.find(([input]) => + String(input).includes('/teams/team-1/tl/session'), + ); + expect(sessionCall).toBeUndefined(); + }); + + it('renders blackboard entries pinned first with tags and sources', async () => { + stubDetailFetch({ + entries: [ + makeEntry({ + id: 'entry-1', + content: 'member note', + tags: ['okr'], + source_type: 'member', + source_agent_id: 'agent-2', + citation: { task_title: '写周报' }, + }), + makeEntry({ id: 'entry-2', content: 'pinned note', pinned: true, source_type: 'leader' }), + ], + }); + renderDetail(); + + const board = screen.getByLabelText('团队黑板'); + const pinned = await within(board).findByText('pinned note'); + const plain = within(board).getByText('member note'); + expect(pinned.compareDocumentPosition(plain) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(board).getByText('okr')).toBeTruthy(); + expect(within(board).getByText((content) => content.startsWith('TL'))).toBeTruthy(); + expect(within(board).getByText((content) => content.startsWith('小北'))).toBeTruthy(); + expect(within(board).getByText(/关联任务:写周报/)).toBeTruthy(); + expect(within(board).getAllByText('置顶').length).toBeGreaterThan(0); + }); + + it('shows an empty placeholder when the blackboard has no entries', async () => { + stubDetailFetch(); + renderDetail(); + + const board = screen.getByLabelText('团队黑板'); + expect(await within(board).findByText('暂无黑板条目')).toBeTruthy(); + }); + + it('submits a human-written blackboard entry with tags', async () => { + const user = userEvent.setup(); + const fetchMock = stubDetailFetch(); + renderDetail(); + + const board = screen.getByLabelText('团队黑板'); + await user.type(within(board).getByLabelText('输入黑板内容'), 'release risk'); + await user.type(within(board).getByLabelText('标签(逗号分隔,可选)'), 'risk, launch'); + await user.click(within(board).getByRole('button', { name: '添加' })); + + expect(await within(board).findByText('release risk')).toBeTruthy(); + const postCall = fetchMock.mock.calls.find( + ([input, init]) => + String(input).includes('/blackboard') && String(init?.method || '').toUpperCase() === 'POST', + ); + expect(postCall).toBeTruthy(); + const body = JSON.parse(String(postCall?.[1]?.body)) as Record; + expect(body.content).toBe('release risk'); + expect(body.tags).toEqual(['risk', 'launch']); + }); + + it('archives a blackboard entry after confirmation', async () => { + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + const fetchMock = stubDetailFetch({ + entries: [makeEntry({ id: 'entry-1', content: 'stale note' })], + }); + renderDetail(); + + const board = screen.getByLabelText('团队黑板'); + await within(board).findByText('stale note'); + await user.click(within(board).getByRole('button', { name: '归档' })); + + await waitFor(() => { + const archiveCall = fetchMock.mock.calls.find(([input]) => + String(input).includes('/blackboard/entry-1/archive'), + ); + expect(archiveCall).toBeTruthy(); + }); + await waitFor(() => { + expect(within(board).queryByText('stale note')).toBeNull(); + }); + expect(within(board).getByText('暂无黑板条目')).toBeTruthy(); + }); + + it('renders the bidding column with bidding tasks', async () => { + stubDetailFetch(); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + const biddingColumn = within(board).getByText('竞标中').closest('div')?.parentElement as HTMLElement; + expect(await within(biddingColumn).findByText('竞标方案')).toBeTruthy(); + }); + + it('renders the bidding arena with HP bars and the winner crown', async () => { + const user = userEvent.setup(); + stubDetailFetch({ + taskDetails: { + 'task-5': makeTask({ + id: 'task-5', + title: '竞标已裁决', + status: 'pending', + assignee_agent_id: 'agent-1', + bids: awardedBids, + }), + }, + }); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(await within(board).findByText('竞标已裁决')); + + const dialog = await screen.findByRole('dialog'); + const arena = await within(dialog).findByLabelText('竞标竞技场'); + // 小艾:第 1 轮 8 分扣 6,第 2 轮 7 分扣 9 → HP 85;小北:6 分扣 12 → HP 88 + expect(within(arena).getByText('HP 85')).toBeTruthy(); + expect(within(arena).getByText('HP 88')).toBeTruthy(); + expect(within(arena).getByTestId('arena-hp-agent-1').style.width).toBe('85%'); + expect(within(arena).getByTestId('arena-hp-agent-2').style.width).toBe('88%'); + expect(within(arena).getByText('胜者为王')).toBeTruthy(); + // 每个候选人卡片各有一份轮次标签:两人都有第 1 轮,只有小艾有第 2 轮 + expect(within(arena).getAllByText('第 1 轮').length).toBe(2); + expect(within(arena).getAllByText('第 2 轮').length).toBe(1); + expect(within(arena).getAllByText('陈述').length).toBe(2); + expect(within(arena).getByText('反驳')).toBeTruthy(); + expect(within(arena).getByText('我擅长数据分析')).toBeTruthy(); + expect(within(arena).getByText('对方缺少落地案例')).toBeTruthy(); + expect(within(arena).getByText('得分:8')).toBeTruthy(); + expect(within(arena).getByText('方案具体')).toBeTruthy(); + }); + + it('marks eliminated candidates and shows full HP for legacy bids without scores', async () => { + const user = userEvent.setup(); + stubDetailFetch({ + taskDetails: { + 'task-4': makeTask({ + id: 'task-4', + title: '竞标方案', + status: 'bidding', + assignee_agent_id: null, + bids: [ + ...[1, 2, 3, 4].map((round) => + makeBid({ id: `bid-w${round}`, agent_id: 'agent-1', agent_name: '小艾', round, score: 0 }), + ), + makeBid({ id: 'bid-legacy', agent_id: 'agent-2', agent_name: '小北', round: 1, score: null }), + ], + }), + }, + }); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(await within(board).findByText('竞标方案')); + + const dialog = await screen.findByRole('dialog'); + const arena = await within(dialog).findByLabelText('竞标竞技场'); + // 4 轮 0 分扣尽 HP → 淘汰;无得分的历史数据保持满血 + expect(within(arena).getByText('HP 0')).toBeTruthy(); + expect(within(arena).getByText('淘汰')).toBeTruthy(); + expect(within(arena).getByText('HP 100')).toBeTruthy(); + expect(within(arena).queryByText('胜者为王')).toBeNull(); + }); + + it('creates a task into the bidding pool without an assignee', async () => { + const user = userEvent.setup(); + const fetchMock = stubDetailFetch(); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(within(board).getByRole('button', { name: '新建任务' })); + + const dialog = await screen.findByRole('dialog'); + await user.type(within(dialog).getByLabelText('任务标题'), '池化任务'); + await user.click(within(dialog).getByRole('button', { name: '创建' })); + + await waitFor(() => { + const postCall = fetchMock.mock.calls.find( + ([input, init]) => + String(input).includes('/tasks') && String(init?.method || '').toUpperCase() === 'POST', + ); + expect(postCall).toBeTruthy(); + const body = JSON.parse(String(postCall?.[1]?.body)) as Record; + expect(body.title).toBe('池化任务'); + expect('assignee_agent_id' in body).toBe(false); + }); + }); + + it('creates a task with direct assignment to a member', async () => { + const user = userEvent.setup(); + const fetchMock = stubDetailFetch(); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(within(board).getByRole('button', { name: '新建任务' })); + + const dialog = await screen.findByRole('dialog'); + await user.type(within(dialog).getByLabelText('任务标题'), '直派任务'); + await user.click(within(dialog).getByRole('combobox', { name: '执行者' })); + await user.click(await screen.findByRole('option', { name: '小北' })); + await user.click(within(dialog).getByRole('button', { name: '创建' })); + + await waitFor(() => { + const postCall = fetchMock.mock.calls.find( + ([input, init]) => + String(input).includes('/tasks') && String(init?.method || '').toUpperCase() === 'POST', + ); + expect(postCall).toBeTruthy(); + const body = JSON.parse(String(postCall?.[1]?.body)) as Record; + expect(body.title).toBe('直派任务'); + expect(body.assignee_agent_id).toBe('agent-2'); + }); + }); + + it('submits an award override for a bidding task', async () => { + const user = userEvent.setup(); + const fetchMock = stubDetailFetch({ + taskDetails: { + 'task-4': makeTask({ + id: 'task-4', + title: '竞标方案', + status: 'bidding', + assignee_agent_id: null, + bids: awardedBids, + }), + }, + }); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(await within(board).findByText('竞标方案')); + + const dialog = await screen.findByRole('dialog'); + const awardSection = await within(dialog).findByLabelText('改判执行者'); + await user.click(within(awardSection).getByRole('combobox', { name: '选择执行者' })); + await user.click(await screen.findByRole('option', { name: '小艾' })); + await user.type(within(awardSection).getByLabelText('改判说明(可选)'), '更信任小艾'); + await user.click(within(awardSection).getByRole('button', { name: '确认改判' })); + + await waitFor(() => { + const overrideCall = fetchMock.mock.calls.find(([input]) => + String(input).includes('/tasks/task-4/award-override'), + ); + expect(overrideCall).toBeTruthy(); + const body = JSON.parse(String(overrideCall?.[1]?.body)) as Record; + expect(body.agent_id).toBe('agent-1'); + expect(body.comment).toBe('更信任小艾'); + }); + }); + + it('opens the task detail dialog from the ?task= query param', async () => { + stubDetailFetch(); + renderDetail('/enterprise/teams/team-1?task=task-1'); + + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByText('周报已完成')).toBeTruthy(); + expect(within(dialog).getByText('submitted')).toBeTruthy(); + }); + + it('saves team settings via PUT with the merged config', async () => { + const user = userEvent.setup(); + const fetchMock = stubDetailFetch({ + teamOverride: { ...team, config: { member_concurrency: 2, custom_flag: 'keep' } }, + }); + renderDetail(); + + const settings = await screen.findByLabelText('团队设置'); + expect((within(settings).getByLabelText('成员并发上限') as HTMLInputElement).value).toBe('2'); + const timeoutInput = within(settings).getByLabelText('任务超时分钟') as HTMLInputElement; + expect(timeoutInput.value).toBe('30'); + expect((within(settings).getByLabelText('竞标反驳轮数') as HTMLInputElement).value).toBe('1'); + + await user.clear(timeoutInput); + await user.type(timeoutInput, '45'); + await user.click(within(settings).getByRole('button', { name: '保存设置' })); + + await waitFor(() => { + const putCall = fetchMock.mock.calls.find( + ([input, init]) => + String(input).includes('/api/enterprise/teams/team-1') && + String(init?.method || '').toUpperCase() === 'PUT', + ); + expect(putCall).toBeTruthy(); + const body = JSON.parse(String(putCall?.[1]?.body)) as { config: Record }; + expect(body.config.member_concurrency).toBe(2); + expect(body.config.task_timeout_minutes).toBe(45); + expect(body.config.bid_rebuttal_rounds).toBe(1); + expect(body.config.custom_flag).toBe('keep'); + }); + }); + + it('promotes a blackboard entry and disables already promoted entries', async () => { + const user = userEvent.setup(); + const fetchMock = stubDetailFetch({ + entries: [ + makeEntry({ id: 'entry-1', content: 'fresh note' }), + makeEntry({ id: 'entry-2', content: 'old note', citation: { knowledge_base_id: 'kb-9' } }), + ], + }); + renderDetail(); + + const board = screen.getByLabelText('团队黑板'); + await within(board).findByText('fresh note'); + const promotedButton = within(board).getByRole('button', { name: '已沉淀' }) as HTMLButtonElement; + expect(promotedButton.disabled).toBe(true); + + await user.click(within(board).getByRole('button', { name: '沉淀到知识库' })); + + await waitFor(() => { + const promoteCall = fetchMock.mock.calls.find(([input]) => + String(input).includes('/blackboard/entry-1/promote'), + ); + expect(promoteCall).toBeTruthy(); + const body = JSON.parse(String(promoteCall?.[1]?.body)) as Record; + expect(body.tenant_id).toBeTruthy(); + }); + await waitFor(() => { + expect(within(board).getAllByRole('button', { name: '已沉淀' }).length).toBe(2); + }); + }); + + it('groups team activity by task and opens the task from the group header', async () => { + const user = userEvent.setup(); + const now = Date.now(); + stubDetailFetch({ + events: [ + { + id: 'event-1', + task_id: 'task-1', + task_title: '写周报', + actor_type: 'agent', + actor_id: 'agent-2', + event_type: 'task_reported', + payload: {}, + created_at: new Date(now - 60000).toISOString(), + }, + { + id: 'event-3', + task_id: 'task-1', + task_title: '写周报', + actor_type: 'tl', + actor_id: 'agent-1', + event_type: 'tl_review_rework', + payload: {}, + created_at: new Date(now - 120000).toISOString(), + }, + { + id: 'event-4', + task_id: 'task-3', + task_title: '投放分析', + actor_type: 'agent', + actor_id: 'agent-2', + event_type: 'task_started', + payload: {}, + created_at: new Date(now).toISOString(), + }, + { + id: 'event-2', + task_id: null, + task_title: null, + actor_type: 'system', + actor_id: null, + event_type: 'tl_review_skipped', + payload: {}, + created_at: new Date(now - 30000).toISOString(), + }, + ], + }); + renderDetail(); + + const activity = await screen.findByLabelText('团队动态'); + // 同任务事件聚合到一张分组卡片下 + expect(await within(activity).findByText('提交报告')).toBeTruthy(); + expect(within(activity).getByText('TL 退回重做')).toBeTruthy(); + expect(within(activity).getByText('TL 免验收')).toBeTruthy(); + expect(within(activity).getByText('其他')).toBeTruthy(); + // 组间按最新事件倒序:投放分析 > 其他 > 写周报 + const headerNewest = within(activity).getByRole('button', { name: '投放分析' }); + const headerOldest = within(activity).getByRole('button', { name: '写周报' }); + expect( + headerNewest.compareDocumentPosition(headerOldest) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + // 组头点击打开任务详情 + await user.click(headerOldest); + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByText('周报已完成')).toBeTruthy(); + }); + + it('renders the review verdict as a prominent banner with the comment quote', async () => { + const user = userEvent.setup(); + stubDetailFetch({ + taskDetails: { + 'task-1': makeTask({ + review: { verdict: 'rework', comment: '数据不完整,请补充来源' }, + events: [], + }), + }, + }); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(await within(board).findByText('写周报')); + + const dialog = await screen.findByRole('dialog'); + const verdict = await within(dialog).findByLabelText('验收结论'); + expect(within(verdict).getByText('退回重做')).toBeTruthy(); + const quote = within(verdict).getByText('数据不完整,请补充来源'); + expect(quote.tagName).toBe('BLOCKQUOTE'); + }); + + it('renders an approve banner and hides the section when there is no verdict', async () => { + const user = userEvent.setup(); + stubDetailFetch({ + taskDetails: { + 'task-1': makeTask({ review: { verdict: 'approve' }, events: [] }), + 'task-3': makeTask({ id: 'task-3', title: '投放分析', status: 'in_progress', review: {}, events: [] }), + }, + }); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(await within(board).findByText('写周报')); + let dialog = await screen.findByRole('dialog'); + const verdict = await within(dialog).findByLabelText('验收结论'); + expect(within(verdict).getByText('验收通过')).toBeTruthy(); + await user.click(within(dialog).getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + + await user.click(within(board).getByText('投放分析')); + dialog = await screen.findByRole('dialog'); + expect(within(dialog).queryByLabelText('验收结论')).toBeNull(); + }); + + it('navigates to the execution session from the task detail', async () => { + const user = userEvent.setup(); + stubDetailFetch({ + taskDetails: { + 'task-1': makeTask({ session_id: 'session-exec-1', events: [] }), + }, + }); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + await user.click(await within(board).findByText('写周报')); + const dialog = await screen.findByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: '查看执行会话' })); + + expect((await screen.findByTestId('location')).textContent).toBe('/workspace/chat/session-exec-1'); + }); + + it('shows creation timestamps on kanban cards sorted newest first', async () => { + stubDetailFetch({ + taskList: [ + makeTask({ id: 'task-old', title: '旧任务', status: 'in_progress', created_at: '2026-08-01T09:00:00' }), + makeTask({ id: 'task-new', title: '新任务', status: 'in_progress', created_at: '2026-08-02T09:00:00' }), + ], + }); + renderDetail(); + + const board = screen.getByLabelText('任务看板'); + const progressColumn = within(board).getByText('进行中').closest('div')?.parentElement as HTMLElement; + expect(await within(progressColumn).findByText('新任务')).toBeTruthy(); + expect(within(progressColumn).getAllByText(/创建于/).length).toBe(2); + const newer = within(progressColumn).getByText('新任务'); + const older = within(progressColumn).getByText('旧任务'); + expect(newer.compareDocumentPosition(older) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('renders the member tree with employee avatars', async () => { + stubDetailFetch(); + renderDetail(); + + const members = screen.getByLabelText('成员管理'); + await within(members).findByText('小艾'); + expect(within(members).getAllByLabelText(/员工头像/).length).toBe(2); + expect(within(members).getByText('TL')).toBeTruthy(); + expect(within(members).getByText('成员')).toBeTruthy(); + expect(within(members).getByRole('button', { name: '设为 TL' })).toBeTruthy(); + }); +}); diff --git a/frontend-enterprise/src/pages/TeamDetailPage.tsx b/frontend-enterprise/src/pages/TeamDetailPage.tsx new file mode 100644 index 00000000..2535f47a --- /dev/null +++ b/frontend-enterprise/src/pages/TeamDetailPage.tsx @@ -0,0 +1,1374 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; + +import { + Badge, + Button, + Dialog, + DialogContent, + DialogTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from '@/components/ui'; +import { notify } from '@/components/ui/app-toast'; +import { cn } from '@/lib/utils'; + +import { api, TENANT_ID } from '../api/client'; +import type { EnterpriseAuthUser } from '../auth'; +import AppHeader from '../components/AppHeader'; +import BiddingArena from '../components/BiddingArena'; +import EmployeeAvatar from '../components/EmployeeAvatar'; +import { employeeDisplayName } from '../employee'; +import { EnterpriseRoute } from '../enums/routes'; +import { formatClientDateTime, parseBackendDateTime } from '../lib/timezone'; +import type { + AgentProfileRead, + TeamBlackboardEntryRead, + TeamEventRead, + TeamMemberRead, + TeamRead, + TeamReviewVerdict, + TeamTaskBidRead, + TeamTaskRead, +} from '../types'; + +import { relativeTimeLabel, teamStatusLabel } from './TeamsPage'; + +const TEAM_EVENT_TYPE_LABELS: Record = { + task_created: '任务创建', + task_started: '任务开始', + task_rework_started: '退回重做', + task_reported: '提交报告', + task_escalated: '任务升级', + task_needs_input: '需要补充信息', + task_bidding_started: '竞标开始', + task_awarded: '竞标定标', + bid_submitted: '提交竞标', + bid_skipped: '跳过竞标', + bid_failed: '竞标失败', + bid_award_unparsed: '定标解析失败', + tl_review_skipped: 'TL 免验收', + tl_review_unparsed: 'TL 验收解析失败', + tl_review_repair_failed: 'TL 验收修复失败', + tl_review_approve: 'TL 验收通过', + tl_review_rework: 'TL 退回重做', + tl_review_escalate: 'TL 升级', + review_override_approve: '人工改判通过', + review_override_rework: '人工改判退回', + review_override_escalate: '人工改判升级', + blackboard_written: '写入黑板', +}; + +export function teamEventTypeLabel(eventType: string): string { + return TEAM_EVENT_TYPE_LABELS[eventType] || eventType; +} + +const TASK_STATUS_COLUMNS: { status: string; label: string }[] = [ + { status: 'bidding', label: '竞标中' }, + { status: 'pending', label: '待认领' }, + { status: 'in_progress', label: '进行中' }, + { status: 'review', label: '待验收' }, + { status: 'done', label: '已完成' }, + { status: 'rework', label: '已退回' }, + { status: 'escalated', label: '已升级' }, +]; + +const OVERRIDABLE_STATUSES = new Set(['review', 'escalated']); + +const AWARD_OVERRIDABLE_STATUSES = new Set(['bidding', 'pending']); + +const POOL_ASSIGNEE_VALUE = '__pool__'; + +export function taskPriorityLabel(priority: string): string { + if (priority === 'high' || priority === 'urgent') return '高'; + if (priority === 'medium' || priority === 'normal') return '中'; + if (priority === 'low') return '低'; + return priority; +} + +const REVIEW_BANNERS: Record = { + approve: { + label: '验收通过', + bannerClass: 'border-[#bfe6cf] bg-[#eefaf3] text-[#1e7a4c]', + quoteClass: 'border-[#35b26f]', + }, + rework: { + label: '退回重做', + bannerClass: 'border-[#f5ddba] bg-[#fdf6ea] text-[#a3620a]', + quoteClass: 'border-[#f5a83b]', + }, + escalate: { + label: '已升级', + bannerClass: 'border-[#f6c8c4] bg-[#fdeeec] text-[#c0342b]', + quoteClass: 'border-[#f5483b]', + }, +}; + +const DEFAULT_REVIEW_BANNER = { + label: '', + bannerClass: 'border-[#e3e7f1] bg-[#f8f9fb] text-[#464c5e]', + quoteClass: 'border-[#a7adbb]', +}; + +function textField(source: Record | undefined, key: string): string { + const value = source?.[key]; + return typeof value === 'string' ? value : ''; +} + +function parseTags(raw: string): string[] { + return raw + .split(/[,,]/) + .map((tag) => tag.trim()) + .filter(Boolean); +} + +export default function TeamDetailPage({ + currentUser, + onLogout, +}: { + currentUser?: EnterpriseAuthUser; + isAdmin?: boolean; + onLogout?: () => void; +}) { + const { teamId = '' } = useParams<{ teamId: string }>(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [team, setTeam] = useState(null); + const [tasks, setTasks] = useState([]); + const [agents, setAgents] = useState([]); + const [loading, setLoading] = useState(false); + const [addAgentId, setAddAgentId] = useState(''); + const [addingMember, setAddingMember] = useState(false); + const [activeTask, setActiveTask] = useState(null); + const [overrideComment, setOverrideComment] = useState(''); + const [overriding, setOverriding] = useState(false); + const [boardEntries, setBoardEntries] = useState([]); + const [boardContent, setBoardContent] = useState(''); + const [boardTags, setBoardTags] = useState(''); + const [postingEntry, setPostingEntry] = useState(false); + const [editingEntry, setEditingEntry] = useState(null); + const [editContent, setEditContent] = useState(''); + const [editTags, setEditTags] = useState(''); + const [savingEntry, setSavingEntry] = useState(false); + const [taskDialogOpen, setTaskDialogOpen] = useState(false); + const [newTaskTitle, setNewTaskTitle] = useState(''); + const [newTaskDescription, setNewTaskDescription] = useState(''); + const [newTaskPriority, setNewTaskPriority] = useState('medium'); + const [newTaskAssignee, setNewTaskAssignee] = useState(POOL_ASSIGNEE_VALUE); + const [creatingTask, setCreatingTask] = useState(false); + const [awardAgentId, setAwardAgentId] = useState(''); + const [awardComment, setAwardComment] = useState(''); + const [awarding, setAwarding] = useState(false); + const [teamEvents, setTeamEvents] = useState([]); + const [configConcurrency, setConfigConcurrency] = useState('1'); + const [configTaskTimeout, setConfigTaskTimeout] = useState('30'); + const [configBidRounds, setConfigBidRounds] = useState('1'); + const [savingConfig, setSavingConfig] = useState(false); + const [promotingEntryId, setPromotingEntryId] = useState(null); + const openedTaskParamRef = useRef(null); + + const loadTeam = useCallback(async () => { + try { + const detail = await api.get(`/api/enterprise/teams/${teamId}?tenant_id=${TENANT_ID}`); + setTeam(detail); + } catch (error) { + notify.error(error instanceof Error ? error.message : '加载团队详情失败'); + } + }, [teamId]); + + const loadTasks = useCallback(async () => { + try { + const rows = await api.get(`/api/enterprise/teams/${teamId}/tasks?tenant_id=${TENANT_ID}`); + setTasks(rows); + } catch (error) { + notify.error(error instanceof Error ? error.message : '加载任务失败'); + } + }, [teamId]); + + const loadBoard = useCallback(async () => { + try { + const rows = await api.get( + `/api/enterprise/teams/${teamId}/blackboard?tenant_id=${TENANT_ID}&status=active`, + ); + setBoardEntries(rows); + } catch (error) { + notify.error(error instanceof Error ? error.message : '加载黑板失败'); + } + }, [teamId]); + + const loadEvents = useCallback(async () => { + try { + const rows = await api.get( + `/api/enterprise/teams/${teamId}/events?tenant_id=${TENANT_ID}&limit=50`, + ); + setTeamEvents(rows); + } catch { + setTeamEvents([]); + } + }, [teamId]); + + useEffect(() => { + setLoading(true); + void Promise.all([ + loadTeam(), + loadTasks(), + loadBoard(), + loadEvents(), + api + .get(`/api/enterprise/agents?tenant_id=${TENANT_ID}`) + .then(setAgents) + .catch(() => setAgents([])), + ]).finally(() => setLoading(false)); + }, [loadTeam, loadTasks, loadBoard, loadEvents]); + + useEffect(() => { + const config = team?.config || {}; + setConfigConcurrency(String(config.member_concurrency ?? 1)); + setConfigTaskTimeout(String(config.task_timeout_minutes ?? 30)); + setConfigBidRounds(String(config.bid_rebuttal_rounds ?? 1)); + }, [team]); + + const taskParam = searchParams.get('task'); + useEffect(() => { + if (!taskParam || openedTaskParamRef.current === taskParam) return; + const target = tasks.find((item) => item.id === taskParam); + if (!target) return; + openedTaskParamRef.current = taskParam; + void openTask(target); + }, [taskParam, tasks]); + + const memberNameByAgentId = useMemo(() => { + const map = new Map(); + (team?.members || []).forEach((member) => { + if (member.agent_name) map.set(member.agent_id, member.agent_name); + }); + agents.forEach((agent) => { + if (!map.has(agent.id)) map.set(agent.id, employeeDisplayName(agent)); + }); + return map; + }, [team, agents]); + + function assigneeName(task: TeamTaskRead): string { + if (!task.assignee_agent_id) return '未分配'; + return memberNameByAgentId.get(task.assignee_agent_id) || task.assignee_agent_id; + } + + const candidateAgents = useMemo(() => { + const memberIds = new Set((team?.members || []).map((member) => member.agent_id)); + return agents.filter((agent) => !agent.is_overall && !memberIds.has(agent.id)); + }, [agents, team]); + + async function addMember() { + if (!addAgentId) { + notify.error('请选择要添加的员工'); + return; + } + setAddingMember(true); + try { + await api.post(`/api/enterprise/teams/${teamId}/members`, { + tenant_id: TENANT_ID, + agent_id: addAgentId, + }); + notify.success('成员已添加'); + setAddAgentId(''); + await loadTeam(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '添加成员失败'); + } finally { + setAddingMember(false); + } + } + + async function removeMember(agentId: string) { + try { + await api.delete(`/api/enterprise/teams/${teamId}/members/${agentId}?tenant_id=${TENANT_ID}`); + notify.success('成员已移除'); + await loadTeam(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '移除成员失败'); + } + } + + async function promoteLeader(agentId: string) { + try { + await api.put(`/api/enterprise/teams/${teamId}/leader`, { + tenant_id: TENANT_ID, + agent_id: agentId, + }); + notify.success('已更换团队 TL'); + await loadTeam(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '更换 TL 失败'); + } + } + + function openTlSession() { + // TL 会话的创建在新页面内按需触发(tl.session_id 为空时先建会话再进入) + navigate(`/enterprise/teams/${teamId}/chat`); + } + + async function createTask() { + const title = newTaskTitle.trim(); + if (!title) { + notify.error('请输入任务标题'); + return; + } + if (creatingTask) return; + setCreatingTask(true); + try { + await api.post(`/api/enterprise/teams/${teamId}/tasks`, { + tenant_id: TENANT_ID, + title, + description: newTaskDescription.trim() || undefined, + priority: newTaskPriority, + assignee_agent_id: newTaskAssignee === POOL_ASSIGNEE_VALUE ? undefined : newTaskAssignee, + }); + notify.success('任务已创建'); + setTaskDialogOpen(false); + setNewTaskTitle(''); + setNewTaskDescription(''); + setNewTaskPriority('medium'); + setNewTaskAssignee(POOL_ASSIGNEE_VALUE); + await loadTasks(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '创建任务失败'); + } finally { + setCreatingTask(false); + } + } + + async function addBoardEntry() { + const content = boardContent.trim(); + if (!content) { + notify.error('请输入黑板内容'); + return; + } + if (postingEntry) return; + setPostingEntry(true); + try { + await api.post(`/api/enterprise/teams/${teamId}/blackboard`, { + tenant_id: TENANT_ID, + content, + tags: parseTags(boardTags), + }); + notify.success('黑板条目已添加'); + setBoardContent(''); + setBoardTags(''); + await loadBoard(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '添加黑板条目失败'); + } finally { + setPostingEntry(false); + } + } + + async function togglePinEntry(entry: TeamBlackboardEntryRead) { + try { + await api.put(`/api/enterprise/teams/${teamId}/blackboard/${entry.id}`, { + tenant_id: TENANT_ID, + pinned: !entry.pinned, + }); + notify.success(entry.pinned ? '已取消置顶' : '已置顶'); + await loadBoard(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '更新黑板条目失败'); + } + } + + function openEditEntry(entry: TeamBlackboardEntryRead) { + setEditingEntry(entry); + setEditContent(entry.content); + setEditTags(entry.tags.join(', ')); + } + + async function saveEditEntry() { + const entry = editingEntry; + if (!entry || savingEntry) return; + const content = editContent.trim(); + if (!content) { + notify.error('请输入黑板内容'); + return; + } + setSavingEntry(true); + try { + await api.put(`/api/enterprise/teams/${teamId}/blackboard/${entry.id}`, { + tenant_id: TENANT_ID, + content, + tags: parseTags(editTags), + }); + notify.success('黑板条目已保存'); + setEditingEntry(null); + await loadBoard(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '保存黑板条目失败'); + } finally { + setSavingEntry(false); + } + } + + async function archiveBoardEntry(entry: TeamBlackboardEntryRead) { + if (!window.confirm('确认归档该黑板条目?归档后不再展示。')) return; + try { + await api.post(`/api/enterprise/teams/${teamId}/blackboard/${entry.id}/archive`, { + tenant_id: TENANT_ID, + }); + notify.success('黑板条目已归档'); + await loadBoard(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '归档黑板条目失败'); + } + } + + function boardSourceLabel(entry: TeamBlackboardEntryRead): string { + if (entry.source_type === 'human') return '人'; + if (entry.source_type === 'leader') return 'TL'; + if (entry.source_agent_id) { + return memberNameByAgentId.get(entry.source_agent_id) || entry.source_agent_id; + } + return '成员'; + } + + async function promoteBoardEntry(entry: TeamBlackboardEntryRead) { + if (promotingEntryId) return; + setPromotingEntryId(entry.id); + try { + await api.post(`/api/enterprise/teams/${teamId}/blackboard/${entry.id}/promote`, { + tenant_id: TENANT_ID, + }); + notify.success('已沉淀到知识库'); + await loadBoard(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '沉淀到知识库失败'); + } finally { + setPromotingEntryId(null); + } + } + + function eventActorLabel(event: TeamEventRead): string { + if (event.actor_id) { + const name = memberNameByAgentId.get(event.actor_id); + if (name) return name; + } + if (event.actor_type === 'user') return '用户'; + if (event.actor_type === 'system') return '系统'; + if (event.actor_type === 'tl') return 'TL'; + return event.actor_type; + } + + async function saveTeamConfig() { + if (!team || savingConfig) return; + const concurrency = Number(configConcurrency); + const timeoutMinutes = Number(configTaskTimeout); + const rebuttalRounds = Number(configBidRounds); + const valid = + Number.isInteger(concurrency) && concurrency >= 1 && + Number.isInteger(timeoutMinutes) && timeoutMinutes >= 1 && + Number.isInteger(rebuttalRounds) && rebuttalRounds >= 0; + if (!valid) { + notify.error('请输入有效的数字'); + return; + } + setSavingConfig(true); + try { + await api.put(`/api/enterprise/teams/${teamId}`, { + tenant_id: TENANT_ID, + config: { + ...(team.config || {}), + member_concurrency: concurrency, + task_timeout_minutes: timeoutMinutes, + bid_rebuttal_rounds: rebuttalRounds, + }, + }); + notify.success('团队设置已保存'); + await loadTeam(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '保存团队设置失败'); + } finally { + setSavingConfig(false); + } + } + + async function openTask(task: TeamTaskRead) { + setActiveTask(task); + setOverrideComment(''); + setAwardAgentId(''); + setAwardComment(''); + try { + const detail = await api.get( + `/api/enterprise/teams/${teamId}/tasks/${task.id}?tenant_id=${TENANT_ID}`, + ); + setActiveTask(detail); + } catch { + // 详情加载失败时保留列表中的概要数据 + } + } + + async function awardOverride() { + const task = activeTask; + if (!task || awarding) return; + if (!awardAgentId) { + notify.error('请选择执行者'); + return; + } + setAwarding(true); + try { + await api.post( + `/api/enterprise/teams/${teamId}/tasks/${task.id}/award-override`, + { + tenant_id: TENANT_ID, + agent_id: awardAgentId, + comment: awardComment.trim() || undefined, + }, + ); + notify.success('已提交改判'); + setActiveTask(null); + await loadTasks(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '改判失败'); + } finally { + setAwarding(false); + } + } + + async function overrideTask(verdict: TeamReviewVerdict) { + const task = activeTask; + if (!task || overriding) return; + setOverriding(true); + try { + await api.post( + `/api/enterprise/teams/${teamId}/tasks/${task.id}/override`, + { + tenant_id: TENANT_ID, + verdict, + comment: overrideComment.trim() || undefined, + }, + ); + notify.success('已提交改判'); + setActiveTask(null); + await loadTasks(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '改判失败'); + } finally { + setOverriding(false); + } + } + + const tasksByStatus = useMemo(() => { + const grouped = new Map(); + TASK_STATUS_COLUMNS.forEach((column) => grouped.set(column.status, [])); + tasks.forEach((task) => { + const bucket = grouped.get(task.status) || []; + bucket.push(task); + grouped.set(task.status, bucket); + }); + grouped.forEach((bucket) => { + bucket.sort( + (a, b) => parseBackendDateTime(b.created_at).getTime() - parseBackendDateTime(a.created_at).getTime(), + ); + }); + return grouped; + }, [tasks]); + + const agentById = useMemo(() => { + return new Map(agents.map((agent) => [agent.id, agent])); + }, [agents]); + + type EventGroup = { + key: string; + task: TeamTaskRead | null; + title: string; + events: TeamEventRead[]; + latest: number; + }; + + const eventGroups = useMemo(() => { + const groups = new Map(); + teamEvents.forEach((event) => { + const key = event.task_id || '__other__'; + let group = groups.get(key); + if (!group) { + const task = event.task_id + ? tasks.find((item) => item.id === event.task_id) || null + : null; + group = { + key, + task, + title: event.task_id ? event.task_title || task?.title || '未命名任务' : '其他', + events: [], + latest: 0, + }; + groups.set(key, group); + } + group.events.push(event); + }); + const result = [...groups.values()]; + result.forEach((group) => { + group.events.sort( + (a, b) => parseBackendDateTime(b.created_at).getTime() - parseBackendDateTime(a.created_at).getTime(), + ); + group.latest = group.events[0] + ? parseBackendDateTime(group.events[0].created_at).getTime() || 0 + : 0; + }); + result.sort((a, b) => b.latest - a.latest); + return result; + }, [teamEvents, tasks]); + + const sortedBoardEntries = useMemo(() => { + return [...boardEntries].sort((a, b) => Number(b.pinned) - Number(a.pinned)); + }, [boardEntries]); + + const bidRounds = useMemo(() => { + const grouped = new Map(); + (activeTask?.bids || []).forEach((bid) => { + const list = grouped.get(bid.round) || []; + list.push(bid); + grouped.set(bid.round, list); + }); + return [...grouped.entries()].sort((a, b) => a[0] - b[0]); + }, [activeTask]); + + // 已裁决:存在竞标记录且已有负责人(竞标中状态视为未裁决) + const biddingWinnerId = + activeTask && activeTask.status !== 'bidding' && bidRounds.length > 0 + ? activeTask.assignee_agent_id || null + : null; + + const awardCandidates = useMemo(() => { + const members = team?.members || []; + const bidderIds = new Set((activeTask?.bids || []).map((bid) => bid.agent_id)); + return [...members].sort( + (a, b) => Number(bidderIds.has(b.agent_id)) - Number(bidderIds.has(a.agent_id)), + ); + }, [team, activeTask]); + + const reportSummary = textField(activeTask?.report, 'summary'); + const reportFullReply = textField(activeTask?.report, 'full_reply'); + const reviewVerdict = textField(activeTask?.review, 'verdict'); + const reviewComment = textField(activeTask?.review, 'comment'); + + return ( +
+ + +
+ +
+ +
+
+
+

成员管理

+ + {team ? teamStatusLabel(team.status) : ''} + +
+
+ {(() => { + const members = team?.members || []; + const leader = members.find((member) => member.role === 'leader'); + const others = members.filter((member) => member.role !== 'leader'); + + function memberNode(member: TeamMemberRead, isLeader: boolean) { + return ( +
+ + + {member.agent_name || member.agent_id} + + + {isLeader ? 'TL' : '成员'} + +
+ {!isLeader && ( + + )} + +
+
+ ); + } + + return ( +
+ {leader && memberNode(leader, true)} + {leader && others.length > 0 &&
} + {others.length > 0 && ( +
+ {others.map((member, index) => ( +
+ {leader && ( + <> +
+
0 && 'bg-[#dbe1ec]', + )} + /> +
+
+
+ + )} + {memberNode(member, false)} +
+ ))} +
+ )} + {team && members.length === 0 && ( +

暂无成员

+ )} +
+ ); + })()} +
+
+ + +
+
+ +
+

TL 对话

+
+

+ 向 TL 描述目标,TL 会拆解并派发任务。对话在团队专属聊天室中进行,可查看完整的执行过程与产出。 +

+ +
+
+ +
+

团队设置

+
+ + + +
+
+ +
+
+
+ +
+

团队黑板

+
+ {sortedBoardEntries.map((entry) => { + const taskTitle = textField(entry.citation, 'task_title'); + const promoted = Boolean(textField(entry.citation, 'knowledge_base_id')); + return ( +
+
+

+ {entry.content} +

+ {entry.pinned && ( + + 置顶 + + )} +
+ {entry.tags.length > 0 && ( +
+ {entry.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +
+ + {boardSourceLabel(entry)} + {taskTitle ? ` · 关联任务:${taskTitle}` : ''} + {` · ${formatClientDateTime(entry.updated_at)}`} + + + + + + + +
+
+ ); + })} + {sortedBoardEntries.length === 0 && ( +

暂无黑板条目

+ )} +
+
+ setBoardContent(event.target.value)} + placeholder="输入黑板内容" + aria-label="输入黑板内容" + disabled={postingEntry} + className="h-[36px] flex-1 rounded-[10px] border-[#e3e7f1] text-[14px]" + /> + setBoardTags(event.target.value)} + placeholder="标签(逗号分隔,可选)" + aria-label="标签(逗号分隔,可选)" + disabled={postingEntry} + className="h-[36px] w-[200px] shrink-0 rounded-[10px] border-[#e3e7f1] text-[14px]" + /> + +
+
+ +
+
+

任务看板

+ +
+
+ {TASK_STATUS_COLUMNS.map((column) => { + const columnTasks = tasksByStatus.get(column.status) || []; + return ( +
+
+ {column.label} + {columnTasks.length} +
+ {columnTasks.map((task) => ( + + ))} + {columnTasks.length === 0 && ( +

暂无任务

+ )} +
+ ); + })} +
+
+ +
+

团队动态

+ {teamEvents.length === 0 ? ( +

暂无团队动态

+ ) : ( +
+ {eventGroups.map((group) => ( +
+ {group.task ? ( + + ) : ( +

{group.title}

+ )} +
    + {group.events.map((event) => ( +
  1. + {teamEventTypeLabel(event.event_type)} + {eventActorLabel(event)} + + {relativeTimeLabel(event.created_at)} + +
  2. + ))} +
+
+ ))} +
+ )} +
+ + { + if (!open) setActiveTask(null); + }} + > + + + {activeTask?.title || '任务详情'} + + {activeTask && ( +
+
+ + {TASK_STATUS_COLUMNS.find((column) => column.status === activeTask.status)?.label || activeTask.status} + + {`负责人:${assigneeName(activeTask)}`} + {biddingWinnerId && ( + + 竞标胜出 + + )} + {`优先级:${taskPriorityLabel(activeTask.priority)}`} + {activeTask.session_id && ( + + )} +
+ + {activeTask.description && ( +
+

描述

+

{activeTask.description}

+
+ )} + + {(reportSummary || reportFullReply) && ( +
+

执行报告

+ {reportSummary && ( +

{reportSummary}

+ )} + {reportFullReply && ( +
+                      {reportFullReply}
+                    
+ )} +
+ )} + + {reviewVerdict && (() => { + const banner = REVIEW_BANNERS[reviewVerdict] || { + ...DEFAULT_REVIEW_BANNER, + label: reviewVerdict, + }; + return ( +
+
+

{banner.label}

+ {reviewComment && ( +
+ {reviewComment} +
+ )} +
+
+ ); + })()} + + {bidRounds.length > 0 && ( +
+

竞标竞技场

+ memberNameByAgentId.get(agentId) || ''} + /> +
+ )} + + {AWARD_OVERRIDABLE_STATUSES.has(activeTask.status) && ( +
+

改判执行者

+
+ +