From 4802eb7d6ace21a30cfd50af687f29290be7f07b Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 15 Mar 2026 03:38:36 +0800 Subject: [PATCH] feat(team): add OpenFang Team API client and WebSocket events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API Client (lib/team-client.ts): - Team CRUD: listTeams, getTeam, createTeam, updateTeam, deleteTeam - Member management: addTeamMember, removeTeamMember, updateMemberRole - Task management: addTeamTask, updateTaskStatus, assignTask, submitDeliverable - Dev↔QA loops: startDevQALoop, submitReview, updateLoopState - Metrics & Events: getTeamMetrics, getTeamEvents, subscribeToTeamEvents - TeamAPIError class for error handling WebSocket Events (lib/useTeamEvents.ts): - useTeamEvents hook for subscribing to team events - useTeamEventStream hook for specific team events - useAllTeamEvents hook for all team events - Real-time task status updates - Real-time Dev↔QA loop state changes - Auto-refresh on team/member updates Event Types: - team.created/updated/deleted - member.added/removed/status_changed - task.created/assigned/status_changed/completed - loop.started/state_changed/completed - review.submitted Co-Authored-By: Claude Opus 4.6 --- desktop/src/lib/team-client.ts | 437 +++++++++++++++++++++++++++++++ desktop/src/lib/useTeamEvents.ts | 202 ++++++++++++++ docs/SYSTEM_ANALYSIS.md | 8 +- 3 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 desktop/src/lib/team-client.ts create mode 100644 desktop/src/lib/useTeamEvents.ts diff --git a/desktop/src/lib/team-client.ts b/desktop/src/lib/team-client.ts new file mode 100644 index 0000000..d58a747 --- /dev/null +++ b/desktop/src/lib/team-client.ts @@ -0,0 +1,437 @@ +/** + * OpenFang Team API Client + * + * REST API client for multi-agent team collaboration endpoints. + * Communicates with OpenFang Kernel for team management, + * task coordination, and Dev↔QA loops. + * + * @module lib/team-client + */ + +import type { + Team, + TeamMember, + TeamTask, + TeamMemberRole, + DevQALoop, + CreateTeamRequest, + AddTeamTaskRequest, + TeamResponse, + ReviewFeedback, + TaskDeliverable, + CollaborationEvent, + TeamMetrics, +} from '../types/team'; + +// === Configuration === + +const API_BASE = '/api'; // Uses Vite proxy + +// === Error Types === + +export class TeamAPIError extends Error { + constructor( + message: string, + public statusCode: number, + public endpoint: string, + public details?: unknown + ) { + super(message); + this.name = 'TeamAPIError'; + } +} + +// === Helper Functions === + +async function request( + endpoint: string, + options: RequestInit = {} +): Promise { + const url = `${API_BASE}${endpoint}`; + + try { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new TeamAPIError( + errorData.message || `HTTP ${response.status}`, + response.status, + endpoint, + errorData + ); + } + + return response.json(); + } catch (error) { + if (error instanceof TeamAPIError) { + throw error; + } + throw new TeamAPIError( + (error as Error).message, + 0, + endpoint, + error + ); + } +} + +// === Team API === + +/** + * List all teams + */ +export async function listTeams(): Promise<{ teams: Team[]; total: number }> { + return request<{ teams: Team[]; total: number }>('/teams'); +} + +/** + * Get a specific team by ID + */ +export async function getTeam(teamId: string): Promise { + return request(`/teams/${teamId}`); +} + +/** + * Create a new team + */ +export async function createTeam(data: CreateTeamRequest): Promise { + return request('/teams', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * Update a team + */ +export async function updateTeam( + teamId: string, + data: Partial> +): Promise { + return request(`/teams/${teamId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +/** + * Delete a team + */ +export async function deleteTeam(teamId: string): Promise<{ success: boolean }> { + return request<{ success: boolean }>(`/teams/${teamId}`, { + method: 'DELETE', + }); +} + +// === Team Member API === + +/** + * Add a member to a team + */ +export async function addTeamMember( + teamId: string, + agentId: string, + role: TeamMemberRole +): Promise<{ member: TeamMember; success: boolean }> { + return request<{ member: TeamMember; success: boolean }>( + `/teams/${teamId}/members`, + { + method: 'POST', + body: JSON.stringify({ agentId, role }), + } + ); +} + +/** + * Remove a member from a team + */ +export async function removeTeamMember( + teamId: string, + memberId: string +): Promise<{ success: boolean }> { + return request<{ success: boolean }>( + `/teams/${teamId}/members/${memberId}`, + { method: 'DELETE' } + ); +} + +/** + * Update a member's role + */ +export async function updateMemberRole( + teamId: string, + memberId: string, + role: TeamMemberRole +): Promise<{ member: TeamMember; success: boolean }> { + return request<{ member: TeamMember; success: boolean }>( + `/teams/${teamId}/members/${memberId}`, + { + method: 'PUT', + body: JSON.stringify({ role }), + } + ); +} + +// === Team Task API === + +/** + * Add a task to a team + */ +export async function addTeamTask( + data: AddTeamTaskRequest +): Promise<{ task: TeamTask; success: boolean }> { + return request<{ task: TeamTask; success: boolean }>( + `/teams/${data.teamId}/tasks`, + { + method: 'POST', + body: JSON.stringify({ + title: data.title, + description: data.description, + priority: data.priority, + type: data.type, + assigneeId: data.assigneeId, + dependencies: data.dependencies, + estimate: data.estimate, + }), + } + ); +} + +/** + * Update a task's status + */ +export async function updateTaskStatus( + teamId: string, + taskId: string, + status: TeamTask['status'] +): Promise<{ task: TeamTask; success: boolean }> { + return request<{ task: TeamTask; success: boolean }>( + `/teams/${teamId}/tasks/${taskId}`, + { + method: 'PUT', + body: JSON.stringify({ status }), + } + ); +} + +/** + * Assign a task to a member + */ +export async function assignTask( + teamId: string, + taskId: string, + memberId: string +): Promise<{ task: TeamTask; success: boolean }> { + return request<{ task: TeamTask; success: boolean }>( + `/teams/${teamId}/tasks/${taskId}/assign`, + { + method: 'POST', + body: JSON.stringify({ memberId }), + } + ); +} + +/** + * Submit a deliverable for a task + */ +export async function submitDeliverable( + teamId: string, + taskId: string, + deliverable: TaskDeliverable +): Promise<{ task: TeamTask; success: boolean }> { + return request<{ task: TeamTask; success: boolean }>( + `/teams/${teamId}/tasks/${taskId}/deliverable`, + { + method: 'POST', + body: JSON.stringify(deliverable), + } + ); +} + +// === Dev↔QA Loop API === + +/** + * Start a Dev↔QA loop for a task + */ +export async function startDevQALoop( + teamId: string, + taskId: string, + developerId: string, + reviewerId: string +): Promise<{ loop: DevQALoop; success: boolean }> { + return request<{ loop: DevQALoop; success: boolean }>( + `/teams/${teamId}/loops`, + { + method: 'POST', + body: JSON.stringify({ taskId, developerId, reviewerId }), + } + ); +} + +/** + * Submit a review for a Dev↔QA loop + */ +export async function submitReview( + teamId: string, + loopId: string, + feedback: Omit +): Promise<{ loop: DevQALoop; success: boolean }> { + return request<{ loop: DevQALoop; success: boolean }>( + `/teams/${teamId}/loops/${loopId}/review`, + { + method: 'POST', + body: JSON.stringify(feedback), + } + ); +} + +/** + * Update a Dev↔QA loop state + */ +export async function updateLoopState( + teamId: string, + loopId: string, + state: DevQALoop['state'] +): Promise<{ loop: DevQALoop; success: boolean }> { + return request<{ loop: DevQALoop; success: boolean }>( + `/teams/${teamId}/loops/${loopId}`, + { + method: 'PUT', + body: JSON.stringify({ state }), + } + ); +} + +// === Metrics & Events === + +/** + * Get team metrics + */ +export async function getTeamMetrics(teamId: string): Promise { + return request(`/teams/${teamId}/metrics`); +} + +/** + * Get recent collaboration events + */ +export async function getTeamEvents( + teamId: string, + limit?: number +): Promise<{ events: CollaborationEvent[]; total: number }> { + const query = limit ? `?limit=${limit}` : ''; + return request<{ events: CollaborationEvent[]; total: number }>( + `/teams/${teamId}/events${query}` + ); +} + +// === WebSocket Event Subscription === + +export type TeamEventType = + | 'team.created' + | 'team.updated' + | 'team.deleted' + | 'member.added' + | 'member.removed' + | 'member.status_changed' + | 'task.created' + | 'task.assigned' + | 'task.status_changed' + | 'task.completed' + | 'loop.started' + | 'loop.state_changed' + | 'loop.completed' + | 'review.submitted'; + +export interface TeamEventMessage { + type: 'team_event'; + eventType: TeamEventType; + teamId: string; + payload: Record; + timestamp: string; +} + +/** + * Subscribe to team events via WebSocket + * Returns an unsubscribe function + */ +export function subscribeToTeamEvents( + teamId: string | null, // null = all teams + callback: (event: TeamEventMessage) => void, + ws: WebSocket +): () => void { + const handleMessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'team_event') { + // Filter by teamId if specified + if (teamId === null || data.teamId === teamId) { + callback(data as TeamEventMessage); + } + } + } catch { + // Ignore non-JSON messages + } + }; + + ws.addEventListener('message', handleMessage); + + // Send subscription message + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'subscribe', + topic: teamId ? `team:${teamId}` : 'teams', + })); + } + + // Return unsubscribe function + return () => { + ws.removeEventListener('message', handleMessage); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + type: 'unsubscribe', + topic: teamId ? `team:${teamId}` : 'teams', + })); + } + }; +} + +// === Export singleton client === + +export const teamClient = { + // Teams + listTeams, + getTeam, + createTeam, + updateTeam, + deleteTeam, + + // Members + addTeamMember, + removeTeamMember, + updateMemberRole, + + // Tasks + addTeamTask, + updateTaskStatus, + assignTask, + submitDeliverable, + + // Dev↔QA Loops + startDevQALoop, + submitReview, + updateLoopState, + + // Metrics & Events + getTeamMetrics, + getTeamEvents, + subscribeToTeamEvents, +}; + +export default teamClient; diff --git a/desktop/src/lib/useTeamEvents.ts b/desktop/src/lib/useTeamEvents.ts new file mode 100644 index 0000000..10e880b --- /dev/null +++ b/desktop/src/lib/useTeamEvents.ts @@ -0,0 +1,202 @@ +/** + * useTeamEvents - WebSocket Real-time Event Sync Hook + * + * Subscribes to team collaboration events via WebSocket + * and updates the team store in real-time. + * + * @module lib/useTeamEvents + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { useTeamStore } from '../store/teamStore'; +import { useGatewayStore } from '../store/gatewayStore'; +import type { TeamEventMessage, TeamEventType } from '../lib/team-client'; + +interface UseTeamEventsOptions { + /** Subscribe to specific team only, or null for all teams */ + teamId?: string | null; + /** Event types to subscribe to (default: all) */ + eventTypes?: TeamEventType[]; + /** Maximum events to keep in history (default: 100) */ + maxEvents?: number; +} + +/** + * Hook for subscribing to real-time team collaboration events + */ +export function useTeamEvents(options: UseTeamEventsOptions = {}) { + const { teamId = null, eventTypes, maxEvents = 100 } = options; + const unsubscribeRef = useRef<(() => void) | null>(null); + + const { + addEvent, + setActiveTeam, + updateTaskStatus, + updateLoopState, + loadTeams, + } = useTeamStore(); + + const { connectionState } = useGatewayStore(); + + const handleTeamEvent = useCallback( + (message: TeamEventMessage) => { + // Filter by event types if specified + if (eventTypes && !eventTypes.includes(message.eventType)) { + return; + } + + // Create collaboration event for store + const event = { + type: mapEventType(message.eventType), + teamId: message.teamId, + sourceAgentId: (message.payload.sourceAgentId as string) || 'system', + payload: message.payload, + timestamp: message.timestamp, + }; + + // Add to event history + addEvent(event); + + // Handle specific event types + switch (message.eventType) { + case 'task.status_changed': + if (message.payload.taskId && message.payload.status) { + updateTaskStatus( + message.teamId, + message.payload.taskId as string, + message.payload.status as any + ); + } + break; + + case 'loop.state_changed': + if (message.payload.loopId && message.payload.state) { + updateLoopState( + message.teamId, + message.payload.loopId as string, + message.payload.state as any + ); + } + break; + + case 'team.updated': + case 'member.added': + case 'member.removed': + // Reload teams to get updated data + loadTeams().catch(() => {}); + break; + } + }, + [eventTypes, addEvent, updateTaskStatus, updateLoopState, loadTeams] + ); + + useEffect(() => { + // Only subscribe when connected + if (connectionState !== 'connected') { + return; + } + + // Get WebSocket from gateway client + const client = getGatewayClientSafe(); + if (!client || !client.ws) { + return; + } + + const ws = client.ws; + + // Subscribe to team events + const handleMessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'team_event') { + handleTeamEvent(data as TeamEventMessage); + } + } catch { + // Ignore non-JSON messages + } + }; + + ws.addEventListener('message', handleMessage); + + // Send subscription message + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: 'subscribe', + topic: teamId ? `team:${teamId}` : 'teams', + }) + ); + } + + unsubscribeRef.current = () => { + ws.removeEventListener('message', handleMessage); + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: 'unsubscribe', + topic: teamId ? `team:${teamId}` : 'teams', + }) + ); + } + }; + + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = null; + } + }; + }, [connectionState, teamId, handleTeamEvent]); + + return { + isConnected: connectionState === 'connected', + }; +} + +/** + * Hook for subscribing to a specific team's events + */ +export function useTeamEventStream(teamId: string) { + return useTeamEvents({ teamId }); +} + +/** + * Hook for subscribing to all team events + */ +export function useAllTeamEvents(options: Omit = {}) { + return useTeamEvents({ ...options, teamId: null }); +} + +// === Helper Functions === + +function mapEventType(eventType: TeamEventType): CollaborationEvent['type'] { + const mapping: Record = { + 'team.created': 'member_status_change', + 'team.updated': 'member_status_change', + 'team.deleted': 'member_status_change', + 'member.added': 'member_status_change', + 'member.removed': 'member_status_change', + 'member.status_changed': 'member_status_change', + 'task.created': 'task_assigned', + 'task.assigned': 'task_assigned', + 'task.status_changed': 'task_started', + 'task.completed': 'task_completed', + 'loop.started': 'loop_state_change', + 'loop.state_changed': 'loop_state_change', + 'loop.completed': 'loop_state_change', + 'review.submitted': 'review_submitted', + }; + return mapping[eventType] || 'task_started'; +} + +function getGatewayClientSafe() { + try { + // Dynamic import to avoid circular dependency + const { getGatewayClient } = require('../lib/gateway-client'); + return getGatewayClient(); + } catch { + return null; + } +} + +export default useTeamEvents; diff --git a/docs/SYSTEM_ANALYSIS.md b/docs/SYSTEM_ANALYSIS.md index 8382562..c2b0d9c 100644 --- a/docs/SYSTEM_ANALYSIS.md +++ b/docs/SYSTEM_ANALYSIS.md @@ -446,7 +446,9 @@ ZCLAW 是基于 **OpenFang** (Rust Agent OS) 的 AI Agent 桌面客户端,核 * ✅ Sidebar 添加 Team 标签 * ✅ App.tsx 添加 Team 视图渲染 * ✅ 团队选择和状态管理 + * API 客户端: + * ✅ Team API 客户端 (`lib/team-client.ts`) + * ✅ WebSocket 事件订阅 (`lib/useTeamEvents.ts`) * 待完成: - * OpenFang Team API 客户端 - * WebSocket 实时事件同步 -*下一步: OpenFang API 对接与实时同步* + * 与 OpenFang 后端 API 对接测试 +*下一步: 后端 API 对接测试与集成验证*