feat(team): add OpenFang Team API client and WebSocket events
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 <noreply@anthropic.com>
This commit is contained in:
437
desktop/src/lib/team-client.ts
Normal file
437
desktop/src/lib/team-client.ts
Normal file
@@ -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<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
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<TeamResponse> {
|
||||
return request<TeamResponse>(`/teams/${teamId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new team
|
||||
*/
|
||||
export async function createTeam(data: CreateTeamRequest): Promise<TeamResponse> {
|
||||
return request<TeamResponse>('/teams', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a team
|
||||
*/
|
||||
export async function updateTeam(
|
||||
teamId: string,
|
||||
data: Partial<Pick<Team, 'name' | 'description' | 'pattern' | 'status'>>
|
||||
): Promise<TeamResponse> {
|
||||
return request<TeamResponse>(`/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<ReviewFeedback, 'reviewedAt' | 'reviewerId'>
|
||||
): 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<TeamMetrics> {
|
||||
return request<TeamMetrics>(`/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<string, unknown>;
|
||||
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;
|
||||
202
desktop/src/lib/useTeamEvents.ts
Normal file
202
desktop/src/lib/useTeamEvents.ts
Normal file
@@ -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<UseTeamEventsOptions, 'teamId'> = {}) {
|
||||
return useTeamEvents({ ...options, teamId: null });
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
function mapEventType(eventType: TeamEventType): CollaborationEvent['type'] {
|
||||
const mapping: Record<TeamEventType, CollaborationEvent['type']> = {
|
||||
'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;
|
||||
@@ -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 对接测试与集成验证*
|
||||
|
||||
Reference in New Issue
Block a user