feat(frontend): implement agent configuration pages (#41)
- Add agent types list page with search and filter functionality - Add agent type detail/edit page with tabbed interface - Create AgentTypeForm component with React Hook Form + Zod validation - Implement model configuration (temperature, max tokens, top_p) - Add MCP permission management with checkboxes - Include personality prompt editor textarea - Create TanStack Query hooks for agent-types API - Add useDebounce hook for search optimization - Comprehensive unit tests for all components (68 tests) Components: - AgentTypeList: Grid view with status badges, expertise tags - AgentTypeDetail: Full detail view with model config, MCP permissions - AgentTypeForm: Create/edit with 4 tabs (Basic, Model, Permissions, Personality) Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,6 @@
|
||||
|
||||
// Authentication hooks
|
||||
export * from './useAuth';
|
||||
|
||||
// Agent Types hooks
|
||||
export * from './useAgentTypes';
|
||||
|
||||
220
frontend/src/lib/api/hooks/useAgentTypes.ts
Normal file
220
frontend/src/lib/api/hooks/useAgentTypes.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Agent Types Hooks
|
||||
*
|
||||
* TanStack Query hooks for managing agent type operations.
|
||||
* Provides data fetching and mutations for the agent-types API.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import type {
|
||||
AgentTypeCreate,
|
||||
AgentTypeUpdate,
|
||||
AgentTypeResponse,
|
||||
AgentTypeListResponse,
|
||||
AgentTypeListParams,
|
||||
} from '@/lib/api/types/agentTypes';
|
||||
|
||||
/**
|
||||
* Query keys for agent types
|
||||
*/
|
||||
export const agentTypeKeys = {
|
||||
all: ['agent-types'] as const,
|
||||
lists: () => [...agentTypeKeys.all, 'list'] as const,
|
||||
list: (params: AgentTypeListParams) => [...agentTypeKeys.lists(), params] as const,
|
||||
details: () => [...agentTypeKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...agentTypeKeys.details(), id] as const,
|
||||
bySlug: (slug: string) => [...agentTypeKeys.all, 'slug', slug] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default page limit for listing agent types
|
||||
*/
|
||||
const DEFAULT_PAGE_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Hook to fetch paginated list of agent types
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Query result with agent types list
|
||||
*/
|
||||
export function useAgentTypes(params: AgentTypeListParams = {}) {
|
||||
const { user } = useAuth();
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
limit = DEFAULT_PAGE_LIMIT,
|
||||
is_active = true,
|
||||
search,
|
||||
} = params;
|
||||
|
||||
return useQuery({
|
||||
queryKey: agentTypeKeys.list({ page, limit, is_active, search }),
|
||||
queryFn: async (): Promise<AgentTypeListResponse> => {
|
||||
const response = await apiClient.instance.get('/api/v1/agent-types', {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
is_active,
|
||||
...(search ? { search } : {}),
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!user,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch a single agent type by ID
|
||||
*
|
||||
* @param id - Agent type UUID
|
||||
* @returns Query result with agent type details
|
||||
*/
|
||||
export function useAgentType(id: string | null) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: agentTypeKeys.detail(id ?? ''),
|
||||
queryFn: async (): Promise<AgentTypeResponse> => {
|
||||
if (!id) throw new Error('Agent type ID is required');
|
||||
const response = await apiClient.instance.get(`/api/v1/agent-types/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!user && !!id,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch an agent type by slug
|
||||
*
|
||||
* @param slug - Agent type slug
|
||||
* @returns Query result with agent type details
|
||||
*/
|
||||
export function useAgentTypeBySlug(slug: string | null) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: agentTypeKeys.bySlug(slug ?? ''),
|
||||
queryFn: async (): Promise<AgentTypeResponse> => {
|
||||
if (!slug) throw new Error('Agent type slug is required');
|
||||
const response = await apiClient.instance.get(`/api/v1/agent-types/slug/${slug}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!user && !!slug,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a new agent type
|
||||
*
|
||||
* @returns Mutation for creating agent types
|
||||
*/
|
||||
export function useCreateAgentType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: AgentTypeCreate): Promise<AgentTypeResponse> => {
|
||||
const response = await apiClient.instance.post('/api/v1/agent-types', data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate all agent type lists to refetch
|
||||
queryClient.invalidateQueries({ queryKey: agentTypeKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update an existing agent type
|
||||
*
|
||||
* @returns Mutation for updating agent types
|
||||
*/
|
||||
export function useUpdateAgentType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: string;
|
||||
data: AgentTypeUpdate;
|
||||
}): Promise<AgentTypeResponse> => {
|
||||
const response = await apiClient.instance.patch(`/api/v1/agent-types/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (updatedAgentType) => {
|
||||
// Update the cache for this specific agent type
|
||||
queryClient.setQueryData(
|
||||
agentTypeKeys.detail(updatedAgentType.id),
|
||||
updatedAgentType
|
||||
);
|
||||
// Invalidate lists to reflect changes
|
||||
queryClient.invalidateQueries({ queryKey: agentTypeKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to deactivate (soft delete) an agent type
|
||||
*
|
||||
* @returns Mutation for deactivating agent types
|
||||
*/
|
||||
export function useDeactivateAgentType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.instance.delete(`/api/v1/agent-types/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
// Invalidate all agent type queries
|
||||
queryClient.invalidateQueries({ queryKey: agentTypeKeys.all });
|
||||
// Remove specific agent type from cache
|
||||
queryClient.removeQueries({ queryKey: agentTypeKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to duplicate an agent type
|
||||
*
|
||||
* @returns Mutation for duplicating agent types
|
||||
*/
|
||||
export function useDuplicateAgentType() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (agentType: AgentTypeResponse): Promise<AgentTypeResponse> => {
|
||||
// Create a new agent type based on the existing one
|
||||
const newAgentType: AgentTypeCreate = {
|
||||
name: `${agentType.name} (Copy)`,
|
||||
slug: `${agentType.slug}-copy`,
|
||||
description: agentType.description,
|
||||
expertise: [...agentType.expertise],
|
||||
personality_prompt: agentType.personality_prompt,
|
||||
primary_model: agentType.primary_model,
|
||||
fallback_models: [...agentType.fallback_models],
|
||||
model_params: { ...agentType.model_params },
|
||||
mcp_servers: [...agentType.mcp_servers],
|
||||
tool_permissions: { ...agentType.tool_permissions },
|
||||
is_active: false, // Start as inactive/draft
|
||||
};
|
||||
|
||||
const response = await apiClient.instance.post('/api/v1/agent-types', newAgentType);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate lists to show the new duplicate
|
||||
queryClient.invalidateQueries({ queryKey: agentTypeKeys.lists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
137
frontend/src/lib/api/types/agentTypes.ts
Normal file
137
frontend/src/lib/api/types/agentTypes.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* AgentType API Types
|
||||
*
|
||||
* These types mirror the backend Pydantic schemas for AgentType entities.
|
||||
* Used for type-safe API communication with the agent-types endpoints.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base agent type fields shared across create, update, and response schemas
|
||||
*/
|
||||
export interface AgentTypeBase {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
expertise: string[];
|
||||
personality_prompt: string;
|
||||
primary_model: string;
|
||||
fallback_models: string[];
|
||||
model_params: Record<string, unknown>;
|
||||
mcp_servers: string[];
|
||||
tool_permissions: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for creating a new agent type
|
||||
*/
|
||||
export interface AgentTypeCreate {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
expertise?: string[];
|
||||
personality_prompt: string;
|
||||
primary_model: string;
|
||||
fallback_models?: string[];
|
||||
model_params?: Record<string, unknown>;
|
||||
mcp_servers?: string[];
|
||||
tool_permissions?: Record<string, unknown>;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for updating an existing agent type
|
||||
*/
|
||||
export interface AgentTypeUpdate {
|
||||
name?: string | null;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
expertise?: string[] | null;
|
||||
personality_prompt?: string | null;
|
||||
primary_model?: string | null;
|
||||
fallback_models?: string[] | null;
|
||||
model_params?: Record<string, unknown> | null;
|
||||
mcp_servers?: string[] | null;
|
||||
tool_permissions?: Record<string, unknown> | null;
|
||||
is_active?: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for agent type API responses
|
||||
*/
|
||||
export interface AgentTypeResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
expertise: string[];
|
||||
personality_prompt: string;
|
||||
primary_model: string;
|
||||
fallback_models: string[];
|
||||
model_params: Record<string, unknown>;
|
||||
mcp_servers: string[];
|
||||
tool_permissions: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
instance_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination metadata for list responses
|
||||
*/
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema for paginated agent type list responses
|
||||
*/
|
||||
export interface AgentTypeListResponse {
|
||||
data: AgentTypeResponse[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query parameters for listing agent types
|
||||
*/
|
||||
export interface AgentTypeListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model parameter configuration with typed fields
|
||||
*/
|
||||
export interface ModelParams {
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
top_p?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP permission scope configuration
|
||||
*/
|
||||
export interface McpPermission {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool permission entry
|
||||
*/
|
||||
export interface ToolPermission {
|
||||
tool_id: string;
|
||||
enabled: boolean;
|
||||
scopes?: string[];
|
||||
}
|
||||
8
frontend/src/lib/api/types/index.ts
Normal file
8
frontend/src/lib/api/types/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* API Types
|
||||
*
|
||||
* Custom types for API entities that may not be in the generated client.
|
||||
* These are typically used for Syndarix-specific features.
|
||||
*/
|
||||
|
||||
export * from './agentTypes';
|
||||
@@ -4,4 +4,5 @@
|
||||
* @module lib/hooks
|
||||
*/
|
||||
|
||||
export { useDebounce } from './useDebounce';
|
||||
export { useProjectEvents, type UseProjectEventsOptions, type UseProjectEventsResult } from './useProjectEvents';
|
||||
|
||||
46
frontend/src/lib/hooks/useDebounce.ts
Normal file
46
frontend/src/lib/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* useDebounce Hook
|
||||
*
|
||||
* Debounces a value by a specified delay.
|
||||
* Useful for search inputs and other user input that triggers API calls.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that debounces a value
|
||||
*
|
||||
* @param value - The value to debounce
|
||||
* @param delay - Delay in milliseconds
|
||||
* @returns The debounced value
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [searchQuery, setSearchQuery] = useState('');
|
||||
* const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
*
|
||||
* // Use debouncedSearch for API calls
|
||||
* useEffect(() => {
|
||||
* fetchResults(debouncedSearch);
|
||||
* }, [debouncedSearch]);
|
||||
* ```
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Set up the timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Clean up on value change or unmount
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
155
frontend/src/lib/validations/agentType.ts
Normal file
155
frontend/src/lib/validations/agentType.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Agent Type Form Validation Schemas
|
||||
*
|
||||
* Zod schemas for validating agent type form data.
|
||||
* Used with react-hook-form for form validation.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Slug validation regex: lowercase letters, numbers, and hyphens only
|
||||
*/
|
||||
const slugRegex = /^[a-z0-9-]+$/;
|
||||
|
||||
/**
|
||||
* Available AI models for agent types
|
||||
*/
|
||||
export const AVAILABLE_MODELS = [
|
||||
{ value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' },
|
||||
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
|
||||
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Available MCP servers for agent permissions
|
||||
*/
|
||||
export const AVAILABLE_MCP_SERVERS = [
|
||||
{ id: 'gitea', name: 'Gitea', description: 'Git repository management' },
|
||||
{ id: 'knowledge', name: 'Knowledge Base', description: 'Vector database for RAG' },
|
||||
{ id: 'filesystem', name: 'Filesystem', description: 'File read/write operations' },
|
||||
{ id: 'slack', name: 'Slack', description: 'Team communication' },
|
||||
{ id: 'browser', name: 'Browser', description: 'Web browsing and scraping' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Agent type status options
|
||||
*/
|
||||
export const AGENT_TYPE_STATUS = [
|
||||
{ value: true, label: 'Active' },
|
||||
{ value: false, label: 'Inactive' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Model params schema
|
||||
*/
|
||||
const modelParamsSchema = z.object({
|
||||
temperature: z.number().min(0).max(2),
|
||||
max_tokens: z.number().int().min(1024).max(128000),
|
||||
top_p: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for agent type form fields
|
||||
*/
|
||||
export const agentTypeFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(255, 'Name must be less than 255 characters'),
|
||||
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.max(255, 'Slug must be less than 255 characters')
|
||||
.regex(slugRegex, 'Slug must contain only lowercase letters, numbers, and hyphens')
|
||||
.refine((val) => !val.startsWith('-') && !val.endsWith('-'), {
|
||||
message: 'Slug cannot start or end with a hyphen',
|
||||
})
|
||||
.refine((val) => !val.includes('--'), {
|
||||
message: 'Slug cannot contain consecutive hyphens',
|
||||
}),
|
||||
|
||||
description: z
|
||||
.string()
|
||||
.max(2000, 'Description must be less than 2000 characters')
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
expertise: z.array(z.string()),
|
||||
|
||||
personality_prompt: z
|
||||
.string()
|
||||
.min(1, 'Personality prompt is required')
|
||||
.max(10000, 'Personality prompt must be less than 10000 characters'),
|
||||
|
||||
primary_model: z.string().min(1, 'Primary model is required'),
|
||||
|
||||
fallback_models: z.array(z.string()),
|
||||
|
||||
model_params: modelParamsSchema,
|
||||
|
||||
mcp_servers: z.array(z.string()),
|
||||
|
||||
tool_permissions: z.record(z.string(), z.unknown()),
|
||||
|
||||
is_active: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for creating a new agent type (alias for backward compatibility)
|
||||
*/
|
||||
export const agentTypeCreateSchema = agentTypeFormSchema;
|
||||
|
||||
/**
|
||||
* Schema for updating an existing agent type
|
||||
* All fields are optional since we support partial updates
|
||||
*/
|
||||
export const agentTypeUpdateSchema = agentTypeFormSchema.partial();
|
||||
|
||||
/**
|
||||
* Type for agent type create form values
|
||||
*/
|
||||
export type AgentTypeCreateFormValues = z.infer<typeof agentTypeCreateSchema>;
|
||||
|
||||
/**
|
||||
* Type for agent type update form values
|
||||
*/
|
||||
export type AgentTypeUpdateFormValues = z.infer<typeof agentTypeUpdateSchema>;
|
||||
|
||||
/**
|
||||
* Default values for creating a new agent type
|
||||
*/
|
||||
export const defaultAgentTypeValues: AgentTypeCreateFormValues = {
|
||||
name: '',
|
||||
slug: '',
|
||||
description: null,
|
||||
expertise: [],
|
||||
personality_prompt: '',
|
||||
primary_model: 'claude-opus-4-5-20251101',
|
||||
fallback_models: ['claude-sonnet-4-20250514'],
|
||||
model_params: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 8192,
|
||||
top_p: 0.95,
|
||||
},
|
||||
mcp_servers: [],
|
||||
tool_permissions: {},
|
||||
is_active: false, // Start as draft
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate slug from name
|
||||
*
|
||||
* @param name - The name to convert to a slug
|
||||
* @returns A valid slug string
|
||||
*/
|
||||
export function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
}
|
||||
Reference in New Issue
Block a user