diff --git a/frontend/web/src/api.ts b/frontend/web/src/api.ts deleted file mode 100644 index 7040515..0000000 --- a/frontend/web/src/api.ts +++ /dev/null @@ -1,909 +0,0 @@ -import axios from 'axios' -import { message } from 'antd' - -const AUTH_TOKEN_KEY = 'agenteval_auth_token' - -export const authToken = { - get: () => sessionStorage.getItem(AUTH_TOKEN_KEY), - set: (token: string) => sessionStorage.setItem(AUTH_TOKEN_KEY, token), - clear: () => sessionStorage.removeItem(AUTH_TOKEN_KEY), -} - -/** Registered by App to switch back to the login screen on 401. */ -let onUnauthorized: (() => void) | null = null -export const setOnUnauthorized = (handler: (() => void) | null) => { onUnauthorized = handler } - -const api = axios.create({ - baseURL: '/api', - timeout: 30000, -}) - -api.interceptors.request.use((config) => { - const token = authToken.get() - if (token) config.headers['X-Auth-Token'] = token - return config -}) - -api.interceptors.response.use( - (response) => response, - (error) => { - const isLoginCall = error.config?.url?.includes('/auth/login') - if (error.response?.status === 401 && !isLoginCall && onUnauthorized) { - authToken.clear() - onUnauthorized() - return Promise.reject(error) - } - const msg = error.response?.data?.detail || error.message || '请求失败' - message.error(msg) - return Promise.reject(error) - }, -) - -export default api - -export interface AuthStatus { - auth_required: boolean - authenticated: boolean -} - -export const authApi = { - status: () => api.get('/auth/status'), - login: (password: string) => api.post<{ token: string }>('/auth/login', { password }), -} - -export interface Target { - id: string - name: string - description: string - platform: string - channel_type: string - channel_config: Record - status: string - created_at: string - updated_at: string -} - -export interface Scenario { - id: string - name: string - description: string - tags: string[] - cases: any[] - model_bindings: Record - version: number - created_at: string - updated_at: string -} - -export type ModelCapability = 'chat' | 'embedding' | 'moderation' -export type ModelProtocol = 'openai_compatible' | 'anthropic' | 'google_gemini' | 'dashscope' -export type ModelModality = 'text' | 'image' | 'audio' | 'video' - -export interface ModelConfig { - id: string - name: string - provider: ModelProtocol - capability: ModelCapability - endpoint_url: string - model_name: string | null - vendor_name: string - input_modalities: ModelModality[] - output_modalities: ModelModality[] - context_window: number | null - max_output_tokens: number | null - supports_streaming: boolean - supports_tool_calling: boolean - supports_structured_output: boolean - supports_reasoning: boolean - region: string - documentation_url: string | null - has_api_key: boolean - enabled: boolean - is_default: boolean - is_analysis_default: boolean - description: string - created_at: string | null - updated_at: string | null -} - -export interface ModelConfigPayload { - name: string - provider: ModelProtocol - capability: ModelCapability - endpoint_url: string - model_name?: string | null - vendor_name: string - input_modalities: ModelModality[] - output_modalities: ModelModality[] - context_window?: number | null - max_output_tokens?: number | null - supports_streaming: boolean - supports_tool_calling: boolean - supports_structured_output: boolean - supports_reasoning: boolean - region: string - documentation_url?: string | null - api_key?: string | null - clear_api_key?: boolean - enabled: boolean - is_default: boolean - is_analysis_default?: boolean - description: string -} - -export interface ModelConfigReference { - scenario_id: string - scenario_name: string - purpose: string -} - -export type RunTrigger = 'manual' | 'ai_assistant' | 'cli' | 'campaign' - -export interface RunError { - code: string - message: string -} - -export interface RunSummary { - total_cases: number - passed_cases: number - failed_cases: number - total_rules: number - passed_rules: number - pass_rate: number | null - avg_latency_ms: number | null - case_outcomes: Record - case_errors: Array> - error: RunError | null -} - -export interface Run { - id: string - target_id: string - scenario_id: string - scenario_version?: number - campaign_id?: string | null - status: string - triggered_by?: RunTrigger - scenario_name?: string | null - target_name?: string | null - started_at: string - completed_at: string | null - summary: RunSummary | null -} - -export interface ScenarioStat { - scenario_id: string - scenario_name: string - run_count: number - avg_pass_rate: number | null - last_run_at: string | null -} - -export interface DashboardStats { - targets_count: number - scenarios_count: number - runs_count: number - model_configs_count: number - today_runs: number - running_count: number - overall_pass_rate: number | null - trigger_breakdown: Partial> - scenario_stats: ScenarioStat[] - recent_runs: Run[] -} - -export interface TrendPoint { - date: string - pass_rate: number - run_count: number -} - -export const targetsApi = { - list: () => api.get('/targets'), - get: (id: string) => api.get(`/targets/${id}`), - create: (data: Partial) => api.post('/targets', data), - update: (id: string, data: Partial) => api.put(`/targets/${id}`, data), - delete: (id: string) => api.delete(`/targets/${id}`), - test: (id: string) => api.post<{ ok: boolean; message: string }>(`/targets/${id}/test`), - runs: (id: string) => api.get(`/targets/${id}/runs`), -} - -export const scenariosApi = { - list: () => api.get('/scenarios'), - get: (id: string) => api.get(`/scenarios/${id}`), - create: (data: Partial) => api.post('/scenarios', data), - update: (id: string, data: Partial) => api.put(`/scenarios/${id}`, data), - delete: (id: string) => api.delete(`/scenarios/${id}`), - validate: (data: any) => api.post<{ valid: boolean; errors: string[] }>('/scenarios/validate', data), - listTemplates: () => api.get('/scenarios/templates'), - getTemplate: (id: string) => api.get(`/scenarios/templates/${id}`), -} - -export const modelConfigsApi = { - list: (params?: { capability?: ModelCapability; enabled?: boolean }) => - api.get('/model-configs', { params }), - get: (id: string) => api.get(`/model-configs/${id}`), - create: (data: ModelConfigPayload) => api.post('/model-configs', data), - update: (id: string, data: ModelConfigPayload) => api.put(`/model-configs/${id}`, data), - delete: (id: string) => api.delete(`/model-configs/${id}`), - test: (id: string) => api.post<{ ok: boolean; message: string; tested_at: string }>(`/model-configs/${id}/test`), - references: (id: string) => api.get(`/model-configs/${id}/references`), -} - -export const runsApi = { - list: () => api.get('/runs'), - start: (target_id: string, scenario_id: string) => - api.post('/runs', { target_id, scenario_id }), - get: (id: string) => api.get(`/runs/${id}`), - logs: (id: string) => api.get(`/runs/${id}/logs`), - cancel: (id: string) => api.post(`/runs/${id}/cancel`), -} - -export interface RunLogsTurn { - id: string - case_id: string - round_index: number - latency_ms: number | null - sent_text: string - reply_text: string - sent_at: string | null - received_at: string | null -} - -export interface RunLogsResult { - case_id: string - rule_type: string - passed: boolean - score: number | null - reason: string | null -} - -export interface CaseSnapshotExpectations { - intent?: string | null - keywords_include: string[] - keywords_exclude: string[] - response_time_max_ms?: number | null - coherence_min_score?: number | null -} - -export interface CaseSnapshot { - id: string - type: string - messages: string[] - prompt?: string | null - turns?: number | null - expectations: CaseSnapshotExpectations - eval_rules: { type: string; params: Record }[] -} - -export interface RunLogsResponse { - turns: RunLogsTurn[] - results: RunLogsResult[] - case_verdicts: Record - scenario_snapshot: Record -} - -export const reportsApi = { - get: (runId: string) => api.get(`/reports/${runId}`), - download: async (runId: string, format: 'html' | 'json' | 'markdown') => { - const ext = format === 'markdown' ? 'md' : format - const res = await api.get(`/reports/${runId}/${format}`, { responseType: 'blob' }) - const url = URL.createObjectURL(res.data as Blob) - const a = document.createElement('a') - a.href = url - a.download = `report-${runId.slice(0, 8)}.${ext}` - a.click() - URL.revokeObjectURL(url) - }, - compare: (run1: string, run2: string) => - api.get(`/reports/compare`, { params: { run1, run2 } }), -} - -export const statsApi = { - dashboard: () => api.get('/stats/dashboard'), - trend: (days?: number) => api.get('/stats/trend', { params: { days } }), -} - -// ── Campaigns(评估活动) ────────────────────────────────────────── - -export interface CampaignPlanEntry { - scenario_id: string - offset_seconds: number - count: number -} - -export interface ExplorationSeeds { - personas: string[] - goals: string[] -} - -export interface ExplorationBudgetConfig { - max_sessions?: number | null - max_turns?: number | null - min_interval_seconds?: number | null -} - -export interface ExplorationIssueCount { - issue: string - count: number -} - -export interface ExplorationJudgeReview { - reviewed_sessions: number - findings: { dimension: string; rating: string; comment: string }[] - summaries: string[] -} - -export interface ExplorationSummary { - session_count: number - sessions_with_experience: number - goal_achieved_count: number - goal_achievement_rate: number | null - issues: ExplorationIssueCount[] - misled: ExplorationIssueCount[] - judge_review: ExplorationJudgeReview | null -} - -export interface ExplorationExperience { - goal_achieved: boolean - blockers: string[] - misled: string[] - emotion: string - notes: string -} - -export interface ExplorationSession { - id: string - campaign_id: string - target_id: string - persona: Record - goal: string - seed_ref: Record | null - status: string - triggered_by: string - experience: ExplorationExperience | null - judge_review: Record | null - turn_count: number - error: string | null - created_at: string | null - closed_at: string | null -} - -export interface ExplorationMessage { - id: string - session_id: string - round_index: number - role: string - content: string - latency_ms: number | null - created_at: string | null -} - -export const explorationApi = { - listSessions: (campaignId: string) => - api.get<{ sessions: ExplorationSession[] }>(`/exploration/campaigns/${campaignId}/sessions`), - listMessages: (sessionId: string) => - api.get<{ messages: ExplorationMessage[] }>(`/exploration/sessions/${sessionId}/messages`), -} - -export interface CampaignProgress { - current_offset_seconds: number - spawned_runs: number - completed_runs: number -} - -export interface CampaignListProgress { - completed_runs: number - planned_total: number - overall_pass_rate: number | null -} - -export interface Campaign { - id: string - name: string - target_id: string - window_seconds: number - time_scale: number - plan: CampaignPlanEntry[] - status: string - started_at: string | null - completed_at: string | null - summary: Record | null - analysis_model_config_id: string | null - exploration_seeds: ExplorationSeeds | null - exploration_budget: ExplorationBudgetConfig | null - progress?: CampaignProgress -} - -export interface CampaignListItem extends Omit { - progress: CampaignListProgress -} - -export interface CampaignTrendBucket { - bucket_index: number - start_seconds: number - end_seconds: number - run_count: number - pass_rate: number | null - availability: number | null - avg_latency_ms: number | null -} - -export interface CampaignCapability { - scenario_id: string - scenario_name: string - run_count: number - pass_rate: number | null - availability: number | null - avg_latency_ms: number | null -} - -export interface CampaignReport { - campaign_id: string - name: string - target_id: string - status: string - window_seconds: number - time_scale: number - started_at: string | null - completed_at: string | null - summary: { - total_runs: number - completed_runs: number - overall_pass_rate: number | null - overall_availability: number | null - avg_latency_ms: number | null - } - time_trend: CampaignTrendBucket[] - capability_summary: CampaignCapability[] - exploration?: ExplorationSummary | null -} - -export interface CampaignTimelineEntry { - run_id: string - scenario_id: string - scenario_name: string - offset_seconds: number - status: string - pass_rate: number | null - avg_latency_ms: number | null - started_at: string | null -} - -export type CampaignAnalysisStatus = 'none' | 'queued' | 'generating' | 'completed' | 'failed' - -export interface CampaignAnalysisProblem { - severity: string - title: string - description: string - scenario_ids: string[] - evidence_run_ids: string[] -} - -export interface CampaignAnalysisResult { - overall: string - problems: CampaignAnalysisProblem[] - scenario_narratives: { scenario_id: string; narrative: string }[] - suggestions: { priority: number; text: string }[] -} - -export interface CampaignAnalysis { - status: CampaignAnalysisStatus - result?: CampaignAnalysisResult | null - error?: string | null - model_config_id?: string | null - triggered_by?: string - updated_at?: string | null -} - -export interface CreateCampaignPayload { - name: string - target_id: string - window_seconds: number - time_scale: number - plan: CampaignPlanEntry[] - analysis_model_config_id?: string | null - exploration_seeds?: ExplorationSeeds | null - exploration_budget?: ExplorationBudgetConfig | null -} - -// ── Period comparison (v0.8) ──────────────────────────────────── - -export type ComparisonTrend = 'improving' | 'stable' | 'regressing' - -export interface MetricDeltaPair { - baseline: number | null - current: number | null - delta: number | null -} - -export interface MetricDiffScenario { - scenario_id: string - scenario_name: string - pass_rate: MetricDeltaPair - availability: MetricDeltaPair - avg_latency_ms: MetricDeltaPair -} - -export interface MetricDiff { - overall: { - pass_rate: MetricDeltaPair - availability: MetricDeltaPair - avg_latency_ms: MetricDeltaPair - } - scenarios: MetricDiffScenario[] -} - -export interface ComparisonBaselineInfo { - id: string - name: string - completed_at: string | null -} - -export interface ComparisonProblemEvolution { - status: string - title: string - detail: string - scenario_ids: string[] -} - -export interface ComparisonSuggestionTracking { - text: string - status: string - note: string -} - -export interface ComparisonResult { - trend: ComparisonTrend - summary: string - problem_evolution: ComparisonProblemEvolution[] - suggestion_tracking: ComparisonSuggestionTracking[] -} - -export interface CampaignComparison { - status: CampaignAnalysisStatus - auto_baseline: ComparisonBaselineInfo | null - metric_diff: MetricDiff | null - comparison: { - baseline_campaign_id: string - baseline: ComparisonBaselineInfo | null - result: ComparisonResult | null - error: string | null - model_config_id: string | null - triggered_by: string - updated_at: string | null - } | null -} - -export const campaignsApi = { - list: () => api.get('/campaigns'), - get: (id: string) => api.get(`/campaigns/${id}`), - create: (data: CreateCampaignPayload) => api.post('/campaigns', data), - cancel: (id: string) => api.post(`/campaigns/${id}/cancel`), - report: (id: string) => api.get(`/campaigns/${id}/report`), - timeline: (id: string) => - api.get<{ entries: CampaignTimelineEntry[] }>(`/campaigns/${id}/timeline`), - getAnalysis: (id: string) => api.get(`/campaigns/${id}/analysis`), - generateAnalysis: (id: string) => api.post<{ status: string }>(`/campaigns/${id}/analysis`), - getComparison: (id: string) => api.get(`/campaigns/${id}/comparison`), - generateComparison: (id: string, baselineCampaignId?: string) => - api.post<{ status: string }>( - `/campaigns/${id}/comparison`, - baselineCampaignId ? { baseline_campaign_id: baselineCampaignId } : {}, - ), - downloadReport: async (id: string) => { - const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' }) - const url = URL.createObjectURL(res.data as Blob) - const a = document.createElement('a') - a.href = url - a.download = `campaign-report-${id.slice(0, 8)}.md` - a.click() - URL.revokeObjectURL(url) - }, -} - -// ── Intelligent Evaluation(智能评估 v1.0) ──────────────────────── - -export type IntelligentEvalStatus = - | 'draft' | 'planning' | 'pending_approval' - | 'executing' | 'completed' | 'cancelled' | 'failed' | 'deleted' - -export type IntelligentEvalSessionStatus = 'running' | 'completed' | 'failed' | 'expired' - -export interface IntelligentEvalMessage { - id: string - session_id: string - role: string - content: string - latency_ms: number | null - created_at: string | null -} - -export interface IntelligentEvalSession { - id: string - eval_id: string - target_id: string - persona: Record - goal: string - dimension: string | null - status: IntelligentEvalSessionStatus - verdict: Record | null - turn_count: number - created_at: string | null - closed_at: string | null -} - -export interface IntelligentEvalVirtualUser { - persona: Record - goal: string -} - -export interface IntelligentEvalTimeSlot { - time_slot: string - sessions: number - scenario: string -} - -export interface IntelligentEvalPlan { - dimensions: string[] - virtual_users: IntelligentEvalVirtualUser[] - time_distribution: IntelligentEvalTimeSlot[] - estimated_sessions: number - budget: { max_turns_per_session: number; total_max_turns: number } - completion_criteria: string -} - -export interface ReportEvidence { - session_id?: string - turn_index?: number | null - user_said?: string - assistant_replied?: string -} - -export interface ReportFinding { - issue: string - severity: string - dimension: string - evidence: ReportEvidence[] - suggestion?: string | null - related_sop?: string | null -} - -export interface ReportHighlight { - description: string - dimension?: string | null -} - -export interface IntelligentEvalReport { - summary: string - // 归一后的规范结构(后端 submit 边界归一,见 ADR-0011) - scores: { overall: number | null; dimensions: Record } | null - findings: ReportFinding[] - highlights: ReportHighlight[] - priority_recommendations: string[] -} - -export interface IntelligentEval { - id: string - name: string - target_id: string - status: IntelligentEvalStatus - goal: string - seeds: Record - intent: string - role_description: string - plan: IntelligentEvalPlan | null - plan_feedback: string | null - time_window_hours: number - report: IntelligentEvalReport | null - created_at: string | null - updated_at: string | null - started_at: string | null - completed_at: string | null - session_count: number - completed_sessions: number - sessions?: IntelligentEvalSession[] -} - -export interface CreateIntelligentEvalPayload { - name: string - target_id: string - goal: string - seeds: Record - intent: string - role_description: string - time_window_hours: number -} - -export interface ConfigSnapshot { - id: string - eval_id: string - snapshot_type: 'created' | 'plan_submitted' | 'config_updated' - goal: string - seeds: Record - intent: string - role_description: string - time_window_hours: number - plan: IntelligentEvalPlan | null - created_at: string | null - created_by: string -} - -export interface ConfigSnapshotComparison { - snapshot_1: { - id: string - snapshot_type: string - created_at: string | null - } - snapshot_2: { - id: string - snapshot_type: string - created_at: string | null - } - differences: Record -} - -export interface DecisionLog { - id: string - eval_id: string - decision_type: 'execute_session' | 'wait' | 'start_analysis' - reason: string - context: Record - cron_id: string - created_at: string | null -} - -export type TaskQueueStatus = 'pending' | 'assigned' | 'completed' | 'failed' - -export interface TaskQueueStats { - pending: number - assigned: number - completed: number - failed: number - unresolved: number -} - -export interface TaskQueueItem { - id: string - eval_id: string - eval_name: string | null - eval_status: string | null - status: TaskQueueStatus - priority: number - reason: string - assigned_cron_id: string | null - assigned_at: string | null - completed_at: string | null - created_at: string | null - updated_at: string | null - error: string | null -} - -export interface TaskQueueList { - tasks: TaskQueueItem[] - stats: TaskQueueStats -} - -export interface ExecutionSlotProgress { - time_slot: string - planned: number - created: number - completed: number - is_current: boolean - is_past: boolean -} - -export interface ExecutionProgress { - current_stage: 'planning' | 'approval' | 'executing' | 'done' - abnormal_outcome: 'cancelled' | 'failed' | null - blocker: string | null - next_action: string | null - slots: ExecutionSlotProgress[] -} - -export const intelligentEvalsApi = { - list: (params?: { page?: number; page_size?: number; status?: string }) => - api.get<{ - intelligent_evals: IntelligentEval[] - total?: number - stats?: Record - page?: number - page_size?: number - }>( - '/intelligent-evals', - { params }, - ), - listTasks: (params?: { status?: TaskQueueStatus; limit?: number; eval_id?: string }) => - api.get('/intelligent-evals/tasks', { params }), - get: (id: string) => api.get(`/intelligent-evals/${id}`), - getExecutionProgress: (id: string) => - api.get(`/intelligent-evals/${id}/execution-progress`), - create: (data: CreateIntelligentEvalPayload) => api.post('/intelligent-evals', data), - submitPlan: (id: string, plan: Record) => - api.put(`/intelligent-evals/${id}/plan`, { plan }), - approve: (id: string) => api.post(`/intelligent-evals/${id}/approve`), - reject: (id: string, feedback: string) => - api.post(`/intelligent-evals/${id}/reject`, { feedback }), - cancel: (id: string) => api.post(`/intelligent-evals/${id}/cancel`), - remove: (id: string) => api.delete<{ ok: boolean }>(`/intelligent-evals/${id}`), - listSessions: (id: string) => - api.get<{ sessions: IntelligentEvalSession[] }>(`/intelligent-evals/${id}/sessions`), - listMessages: (id: string, sessionId: string) => - api.get<{ messages: IntelligentEvalMessage[] }>(`/intelligent-evals/${id}/sessions/${sessionId}/messages`), - getReport: (id: string) => api.get(`/intelligent-evals/${id}/report`), - downloadReportMarkdown: async (id: string) => { - const res = await api.get(`/intelligent-evals/${id}/report/markdown`, { responseType: 'blob' }) - const url = URL.createObjectURL(res.data as Blob) - const a = document.createElement('a') - a.href = url - a.download = `intelligent-eval-report-${id.slice(0, 8)}.md` - a.click() - URL.revokeObjectURL(url) - }, - // Config Snapshots - listConfigSnapshots: (id: string) => - api.get<{ snapshots: ConfigSnapshot[] }>(`/intelligent-evals/${id}/config-snapshots`), - getConfigSnapshot: (id: string, snapshotId: string) => - api.get(`/intelligent-evals/${id}/config-snapshots/${snapshotId}`), - compareConfigSnapshots: (id: string, snapshotId1: string, snapshotId2: string) => - api.post(`/intelligent-evals/${id}/config-snapshots/compare`, { - snapshot_id_1: snapshotId1, - snapshot_id_2: snapshotId2, - }), - // Decision Logs - listDecisionLogs: (id: string) => - api.get<{ logs: DecisionLog[] }>(`/intelligent-evals/${id}/decision-logs`), -} - -// ── File Management ────────────────────────────────────────────── - -export interface FileCategory { - id: string - name: string - parent_id: string | null - children: FileCategory[] -} - -export interface FileRecord { - id: string - original_name: string - file_size: number - mime_type: string - file_ext: string - category_id: string | null - storage_directory: string - created_at: string | null -} - -export interface FileUploadConfig { - allowed_extensions: string[] - max_upload_size_mb: number -} - -export const filesApi = { - // Categories - getConfig: () => api.get('/files/config'), - listCategories: () => api.get('/files/categories'), - createCategory: (data: { name: string; parent_id?: string | null }) => - api.post('/files/categories', data), - updateCategory: (id: string, data: { name: string }) => - api.put(`/files/categories/${id}`, data), - deleteCategory: (id: string) => api.delete(`/files/categories/${id}`), - - // Files - list: (categoryId?: string | null) => - api.get('/files', { params: categoryId ? { category_id: categoryId } : {} }), - upload: (file: File, categoryId?: string | null) => { - const form = new FormData() - form.append('file', file) - if (categoryId) { - form.append('category_id', categoryId) - } - return api.post('/files/upload', form, { - timeout: 120000, - }) - }, - download: (id: string) => api.get(`/files/${id}/download`, { responseType: 'blob' }), - delete: (id: string) => api.delete(`/files/${id}`), -} diff --git a/frontend/web/src/api/auth.ts b/frontend/web/src/api/auth.ts new file mode 100644 index 0000000..73d7b4d --- /dev/null +++ b/frontend/web/src/api/auth.ts @@ -0,0 +1,11 @@ +import api from './client' + +export interface AuthStatus { + auth_required: boolean + authenticated: boolean +} + +export const authApi = { + status: () => api.get('/auth/status'), + login: (password: string) => api.post<{ token: string }>('/auth/login', { password }), +} diff --git a/frontend/web/src/api/campaigns.ts b/frontend/web/src/api/campaigns.ts new file mode 100644 index 0000000..99c9da8 --- /dev/null +++ b/frontend/web/src/api/campaigns.ts @@ -0,0 +1,291 @@ +import api from './client' +import { downloadBlob } from '../utils/download' + +export interface CampaignPlanEntry { + scenario_id: string + offset_seconds: number + count: number +} + +export interface ExplorationSeeds { + personas: string[] + goals: string[] +} + +export interface ExplorationBudgetConfig { + max_sessions?: number | null + max_turns?: number | null + min_interval_seconds?: number | null +} + +export interface ExplorationIssueCount { + issue: string + count: number +} + +export interface ExplorationJudgeReview { + reviewed_sessions: number + findings: { dimension: string; rating: string; comment: string }[] + summaries: string[] +} + +export interface ExplorationSummary { + session_count: number + sessions_with_experience: number + goal_achieved_count: number + goal_achievement_rate: number | null + issues: ExplorationIssueCount[] + misled: ExplorationIssueCount[] + judge_review: ExplorationJudgeReview | null +} + +export interface ExplorationExperience { + goal_achieved: boolean + blockers: string[] + misled: string[] + emotion: string + notes: string +} + +export interface ExplorationSession { + id: string + campaign_id: string + target_id: string + persona: Record + goal: string + seed_ref: Record | null + status: string + triggered_by: string + experience: ExplorationExperience | null + judge_review: Record | null + turn_count: number + error: string | null + created_at: string | null + closed_at: string | null +} + +export interface ExplorationMessage { + id: string + session_id: string + round_index: number + role: string + content: string + latency_ms: number | null + created_at: string | null +} + +export const explorationApi = { + listSessions: (campaignId: string) => + api.get<{ sessions: ExplorationSession[] }>(`/exploration/campaigns/${campaignId}/sessions`), + listMessages: (sessionId: string) => + api.get<{ messages: ExplorationMessage[] }>(`/exploration/sessions/${sessionId}/messages`), +} + +export interface CampaignProgress { + current_offset_seconds: number + spawned_runs: number + completed_runs: number +} + +export interface CampaignListProgress { + completed_runs: number + planned_total: number + overall_pass_rate: number | null +} + +export interface Campaign { + id: string + name: string + target_id: string + window_seconds: number + time_scale: number + plan: CampaignPlanEntry[] + status: string + started_at: string | null + completed_at: string | null + summary: Record | null + analysis_model_config_id: string | null + exploration_seeds: ExplorationSeeds | null + exploration_budget: ExplorationBudgetConfig | null + progress?: CampaignProgress +} + +export interface CampaignListItem extends Omit { + progress: CampaignListProgress +} + +export interface CampaignTrendBucket { + bucket_index: number + start_seconds: number + end_seconds: number + run_count: number + pass_rate: number | null + availability: number | null + avg_latency_ms: number | null +} + +export interface CampaignCapability { + scenario_id: string + scenario_name: string + run_count: number + pass_rate: number | null + availability: number | null + avg_latency_ms: number | null +} + +export interface CampaignReport { + campaign_id: string + name: string + target_id: string + status: string + window_seconds: number + time_scale: number + started_at: string | null + completed_at: string | null + summary: { + total_runs: number + completed_runs: number + overall_pass_rate: number | null + overall_availability: number | null + avg_latency_ms: number | null + } + time_trend: CampaignTrendBucket[] + capability_summary: CampaignCapability[] + exploration?: ExplorationSummary | null +} + +export interface CampaignTimelineEntry { + run_id: string + scenario_id: string + scenario_name: string + offset_seconds: number + status: string + pass_rate: number | null + avg_latency_ms: number | null + started_at: string | null +} + +export type CampaignAnalysisStatus = 'none' | 'queued' | 'generating' | 'completed' | 'failed' + +export interface CampaignAnalysisProblem { + severity: string + title: string + description: string + scenario_ids: string[] + evidence_run_ids: string[] +} + +export interface CampaignAnalysisResult { + overall: string + problems: CampaignAnalysisProblem[] + scenario_narratives: { scenario_id: string; narrative: string }[] + suggestions: { priority: number; text: string }[] +} + +export interface CampaignAnalysis { + status: CampaignAnalysisStatus + result?: CampaignAnalysisResult | null + error?: string | null + model_config_id?: string | null + triggered_by?: string + updated_at?: string | null +} + +export interface CreateCampaignPayload { + name: string + target_id: string + window_seconds: number + time_scale: number + plan: CampaignPlanEntry[] + analysis_model_config_id?: string | null + exploration_seeds?: ExplorationSeeds | null + exploration_budget?: ExplorationBudgetConfig | null +} + +export type ComparisonTrend = 'improving' | 'stable' | 'regressing' + +export interface MetricDeltaPair { + baseline: number | null + current: number | null + delta: number | null +} + +export interface MetricDiffScenario { + scenario_id: string + scenario_name: string + pass_rate: MetricDeltaPair + availability: MetricDeltaPair + avg_latency_ms: MetricDeltaPair +} + +export interface MetricDiff { + overall: { + pass_rate: MetricDeltaPair + availability: MetricDeltaPair + avg_latency_ms: MetricDeltaPair + } + scenarios: MetricDiffScenario[] +} + +export interface ComparisonBaselineInfo { + id: string + name: string + completed_at: string | null +} + +export interface ComparisonProblemEvolution { + status: string + title: string + detail: string + scenario_ids: string[] +} + +export interface ComparisonSuggestionTracking { + text: string + status: string + note: string +} + +export interface ComparisonResult { + trend: ComparisonTrend + summary: string + problem_evolution: ComparisonProblemEvolution[] + suggestion_tracking: ComparisonSuggestionTracking[] +} + +export interface CampaignComparison { + status: CampaignAnalysisStatus + auto_baseline: ComparisonBaselineInfo | null + metric_diff: MetricDiff | null + comparison: { + baseline_campaign_id: string + baseline: ComparisonBaselineInfo | null + result: ComparisonResult | null + error: string | null + model_config_id: string | null + triggered_by: string + updated_at: string | null + } | null +} + +export const campaignsApi = { + list: () => api.get('/campaigns'), + get: (id: string) => api.get(`/campaigns/${id}`), + create: (data: CreateCampaignPayload) => api.post('/campaigns', data), + cancel: (id: string) => api.post(`/campaigns/${id}/cancel`), + report: (id: string) => api.get(`/campaigns/${id}/report`), + timeline: (id: string) => + api.get<{ entries: CampaignTimelineEntry[] }>(`/campaigns/${id}/timeline`), + getAnalysis: (id: string) => api.get(`/campaigns/${id}/analysis`), + generateAnalysis: (id: string) => api.post<{ status: string }>(`/campaigns/${id}/analysis`), + getComparison: (id: string) => api.get(`/campaigns/${id}/comparison`), + generateComparison: (id: string, baselineCampaignId?: string) => + api.post<{ status: string }>( + `/campaigns/${id}/comparison`, + baselineCampaignId ? { baseline_campaign_id: baselineCampaignId } : {}, + ), + downloadReport: async (id: string) => { + const res = await api.get(`/campaigns/${id}/report/markdown`, { responseType: 'blob' }) + downloadBlob(res.data as Blob, `campaign-report-${id.slice(0, 8)}.md`) + }, +} diff --git a/frontend/web/src/api/client.ts b/frontend/web/src/api/client.ts new file mode 100644 index 0000000..eb7526d --- /dev/null +++ b/frontend/web/src/api/client.ts @@ -0,0 +1,42 @@ +import axios from 'axios' +import { message } from 'antd' + +const AUTH_TOKEN_KEY = 'agenteval_auth_token' + +export const authToken = { + get: () => sessionStorage.getItem(AUTH_TOKEN_KEY), + set: (token: string) => sessionStorage.setItem(AUTH_TOKEN_KEY, token), + clear: () => sessionStorage.removeItem(AUTH_TOKEN_KEY), +} + +/** Registered by App to switch back to the login screen on 401. */ +let onUnauthorized: (() => void) | null = null +export const setOnUnauthorized = (handler: (() => void) | null) => { onUnauthorized = handler } + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, +}) + +api.interceptors.request.use((config) => { + const token = authToken.get() + if (token) config.headers['X-Auth-Token'] = token + return config +}) + +api.interceptors.response.use( + (response) => response, + (error) => { + const isLoginCall = error.config?.url?.includes('/auth/login') + if (error.response?.status === 401 && !isLoginCall && onUnauthorized) { + authToken.clear() + onUnauthorized() + return Promise.reject(error) + } + const msg = error.response?.data?.detail || error.message || '请求失败' + message.error(msg) + return Promise.reject(error) + }, +) + +export default api diff --git a/frontend/web/src/api/files.ts b/frontend/web/src/api/files.ts new file mode 100644 index 0000000..54afd00 --- /dev/null +++ b/frontend/web/src/api/files.ts @@ -0,0 +1,51 @@ +import api from './client' + +export interface FileCategory { + id: string + name: string + parent_id: string | null + children: FileCategory[] +} + +export interface FileRecord { + id: string + original_name: string + file_size: number + mime_type: string + file_ext: string + category_id: string | null + storage_directory: string + created_at: string | null +} + +export interface FileUploadConfig { + allowed_extensions: string[] + max_upload_size_mb: number +} + +export const filesApi = { + // Categories + getConfig: () => api.get('/files/config'), + listCategories: () => api.get('/files/categories'), + createCategory: (data: { name: string; parent_id?: string | null }) => + api.post('/files/categories', data), + updateCategory: (id: string, data: { name: string }) => + api.put(`/files/categories/${id}`, data), + deleteCategory: (id: string) => api.delete(`/files/categories/${id}`), + + // Files + list: (categoryId?: string | null) => + api.get('/files', { params: categoryId ? { category_id: categoryId } : {} }), + upload: (file: File, categoryId?: string | null) => { + const form = new FormData() + form.append('file', file) + if (categoryId) { + form.append('category_id', categoryId) + } + return api.post('/files/upload', form, { + timeout: 120000, + }) + }, + download: (id: string) => api.get(`/files/${id}/download`, { responseType: 'blob' }), + delete: (id: string) => api.delete(`/files/${id}`), +} diff --git a/frontend/web/src/api/index.ts b/frontend/web/src/api/index.ts new file mode 100644 index 0000000..191fac5 --- /dev/null +++ b/frontend/web/src/api/index.ts @@ -0,0 +1,15 @@ +/** + * API 单一出口:axios 实例住在 client.ts,各域类型与端点在 + * api/.ts;消费方统一从本 barrel 导入('../api' 路径不变)。 + */ +export { default, authToken, setOnUnauthorized } from './client' +export * from './auth' +export * from './targets' +export * from './scenarios' +export * from './modelConfigs' +export * from './runs' +export * from './reports' +export * from './stats' +export * from './campaigns' +export * from './intelligentEvals' +export * from './files' diff --git a/frontend/web/src/api/intelligentEvals.ts b/frontend/web/src/api/intelligentEvals.ts new file mode 100644 index 0000000..6c7c8a5 --- /dev/null +++ b/frontend/web/src/api/intelligentEvals.ts @@ -0,0 +1,248 @@ +import api from './client' +import { downloadBlob } from '../utils/download' + +export type IntelligentEvalStatus = + | 'draft' | 'planning' | 'pending_approval' + | 'executing' | 'completed' | 'cancelled' | 'failed' | 'deleted' + +export type IntelligentEvalSessionStatus = 'running' | 'completed' | 'failed' | 'expired' + +export interface IntelligentEvalMessage { + id: string + session_id: string + role: string + content: string + latency_ms: number | null + created_at: string | null +} + +export interface IntelligentEvalSession { + id: string + eval_id: string + target_id: string + persona: Record + goal: string + dimension: string | null + status: IntelligentEvalSessionStatus + verdict: Record | null + turn_count: number + created_at: string | null + closed_at: string | null +} + +export interface IntelligentEvalVirtualUser { + persona: Record + goal: string +} + +export interface IntelligentEvalTimeSlot { + time_slot: string + sessions: number + scenario: string +} + +export interface IntelligentEvalPlan { + dimensions: string[] + virtual_users: IntelligentEvalVirtualUser[] + time_distribution: IntelligentEvalTimeSlot[] + estimated_sessions: number + budget: { max_turns_per_session: number; total_max_turns: number } + completion_criteria: string +} + +export interface ReportEvidence { + session_id?: string + turn_index?: number | null + user_said?: string + assistant_replied?: string +} + +export interface ReportFinding { + issue: string + severity: string + dimension: string + evidence: ReportEvidence[] + suggestion?: string | null + related_sop?: string | null +} + +export interface ReportHighlight { + description: string + dimension?: string | null +} + +export interface IntelligentEvalReport { + summary: string + // 归一后的规范结构(后端 submit 边界归一,见 ADR-0011) + scores: { overall: number | null; dimensions: Record } | null + findings: ReportFinding[] + highlights: ReportHighlight[] + priority_recommendations: string[] +} + +export interface IntelligentEval { + id: string + name: string + target_id: string + status: IntelligentEvalStatus + goal: string + seeds: Record + intent: string + role_description: string + plan: IntelligentEvalPlan | null + plan_feedback: string | null + time_window_hours: number + report: IntelligentEvalReport | null + created_at: string | null + updated_at: string | null + started_at: string | null + completed_at: string | null + session_count: number + completed_sessions: number + sessions?: IntelligentEvalSession[] +} + +export interface CreateIntelligentEvalPayload { + name: string + target_id: string + goal: string + seeds: Record + intent: string + role_description: string + time_window_hours: number +} + +export interface ConfigSnapshot { + id: string + eval_id: string + snapshot_type: 'created' | 'plan_submitted' | 'config_updated' + goal: string + seeds: Record + intent: string + role_description: string + time_window_hours: number + plan: IntelligentEvalPlan | null + created_at: string | null + created_by: string +} + +export interface ConfigSnapshotComparison { + snapshot_1: { + id: string + snapshot_type: string + created_at: string | null + } + snapshot_2: { + id: string + snapshot_type: string + created_at: string | null + } + differences: Record +} + +export interface DecisionLog { + id: string + eval_id: string + decision_type: 'execute_session' | 'wait' | 'start_analysis' + reason: string + context: Record + cron_id: string + created_at: string | null +} + +export type TaskQueueStatus = 'pending' | 'assigned' | 'completed' | 'failed' + +export interface TaskQueueStats { + pending: number + assigned: number + completed: number + failed: number + unresolved: number +} + +export interface TaskQueueItem { + id: string + eval_id: string + eval_name: string | null + eval_status: string | null + status: TaskQueueStatus + priority: number + reason: string + assigned_cron_id: string | null + assigned_at: string | null + completed_at: string | null + created_at: string | null + updated_at: string | null + error: string | null +} + +export interface TaskQueueList { + tasks: TaskQueueItem[] + stats: TaskQueueStats +} + +export interface ExecutionSlotProgress { + time_slot: string + planned: number + created: number + completed: number + is_current: boolean + is_past: boolean +} + +export interface ExecutionProgress { + current_stage: 'planning' | 'approval' | 'executing' | 'done' + abnormal_outcome: 'cancelled' | 'failed' | null + blocker: string | null + next_action: string | null + slots: ExecutionSlotProgress[] +} + +export const intelligentEvalsApi = { + list: (params?: { page?: number; page_size?: number; status?: string }) => + api.get<{ + intelligent_evals: IntelligentEval[] + total?: number + stats?: Record + page?: number + page_size?: number + }>( + '/intelligent-evals', + { params }, + ), + listTasks: (params?: { status?: TaskQueueStatus; limit?: number; eval_id?: string }) => + api.get('/intelligent-evals/tasks', { params }), + get: (id: string) => api.get(`/intelligent-evals/${id}`), + getExecutionProgress: (id: string) => + api.get(`/intelligent-evals/${id}/execution-progress`), + create: (data: CreateIntelligentEvalPayload) => api.post('/intelligent-evals', data), + submitPlan: (id: string, plan: Record) => + api.put(`/intelligent-evals/${id}/plan`, { plan }), + approve: (id: string) => api.post(`/intelligent-evals/${id}/approve`), + reject: (id: string, feedback: string) => + api.post(`/intelligent-evals/${id}/reject`, { feedback }), + cancel: (id: string) => api.post(`/intelligent-evals/${id}/cancel`), + remove: (id: string) => api.delete<{ ok: boolean }>(`/intelligent-evals/${id}`), + listSessions: (id: string) => + api.get<{ sessions: IntelligentEvalSession[] }>(`/intelligent-evals/${id}/sessions`), + listMessages: (id: string, sessionId: string) => + api.get<{ messages: IntelligentEvalMessage[] }>(`/intelligent-evals/${id}/sessions/${sessionId}/messages`), + getReport: (id: string) => api.get(`/intelligent-evals/${id}/report`), + downloadReportMarkdown: async (id: string) => { + const res = await api.get(`/intelligent-evals/${id}/report/markdown`, { responseType: 'blob' }) + downloadBlob(res.data as Blob, `intelligent-eval-report-${id.slice(0, 8)}.md`) + }, + // Config Snapshots + listConfigSnapshots: (id: string) => + api.get<{ snapshots: ConfigSnapshot[] }>(`/intelligent-evals/${id}/config-snapshots`), + getConfigSnapshot: (id: string, snapshotId: string) => + api.get(`/intelligent-evals/${id}/config-snapshots/${snapshotId}`), + compareConfigSnapshots: (id: string, snapshotId1: string, snapshotId2: string) => + api.post(`/intelligent-evals/${id}/config-snapshots/compare`, { + snapshot_id_1: snapshotId1, + snapshot_id_2: snapshotId2, + }), + // Decision Logs + listDecisionLogs: (id: string) => + api.get<{ logs: DecisionLog[] }>(`/intelligent-evals/${id}/decision-logs`), +} diff --git a/frontend/web/src/api/modelConfigs.ts b/frontend/web/src/api/modelConfigs.ts new file mode 100644 index 0000000..de771bb --- /dev/null +++ b/frontend/web/src/api/modelConfigs.ts @@ -0,0 +1,74 @@ +import api from './client' + +export type ModelCapability = 'chat' | 'embedding' | 'moderation' +export type ModelProtocol = 'openai_compatible' | 'anthropic' | 'google_gemini' | 'dashscope' +export type ModelModality = 'text' | 'image' | 'audio' | 'video' + +export interface ModelConfig { + id: string + name: string + provider: ModelProtocol + capability: ModelCapability + endpoint_url: string + model_name: string | null + vendor_name: string + input_modalities: ModelModality[] + output_modalities: ModelModality[] + context_window: number | null + max_output_tokens: number | null + supports_streaming: boolean + supports_tool_calling: boolean + supports_structured_output: boolean + supports_reasoning: boolean + region: string + documentation_url: string | null + has_api_key: boolean + enabled: boolean + is_default: boolean + is_analysis_default: boolean + description: string + created_at: string | null + updated_at: string | null +} + +export interface ModelConfigPayload { + name: string + provider: ModelProtocol + capability: ModelCapability + endpoint_url: string + model_name?: string | null + vendor_name: string + input_modalities: ModelModality[] + output_modalities: ModelModality[] + context_window?: number | null + max_output_tokens?: number | null + supports_streaming: boolean + supports_tool_calling: boolean + supports_structured_output: boolean + supports_reasoning: boolean + region: string + documentation_url?: string | null + api_key?: string | null + clear_api_key?: boolean + enabled: boolean + is_default: boolean + is_analysis_default?: boolean + description: string +} + +export interface ModelConfigReference { + scenario_id: string + scenario_name: string + purpose: string +} + +export const modelConfigsApi = { + list: (params?: { capability?: ModelCapability; enabled?: boolean }) => + api.get('/model-configs', { params }), + get: (id: string) => api.get(`/model-configs/${id}`), + create: (data: ModelConfigPayload) => api.post('/model-configs', data), + update: (id: string, data: ModelConfigPayload) => api.put(`/model-configs/${id}`, data), + delete: (id: string) => api.delete(`/model-configs/${id}`), + test: (id: string) => api.post<{ ok: boolean; message: string; tested_at: string }>(`/model-configs/${id}/test`), + references: (id: string) => api.get(`/model-configs/${id}/references`), +} diff --git a/frontend/web/src/api/reports.ts b/frontend/web/src/api/reports.ts new file mode 100644 index 0000000..c779a34 --- /dev/null +++ b/frontend/web/src/api/reports.ts @@ -0,0 +1,13 @@ +import api from './client' +import { downloadBlob } from '../utils/download' + +export const reportsApi = { + get: (runId: string) => api.get(`/reports/${runId}`), + download: async (runId: string, format: 'html' | 'json' | 'markdown') => { + const ext = format === 'markdown' ? 'md' : format + const res = await api.get(`/reports/${runId}/${format}`, { responseType: 'blob' }) + downloadBlob(res.data as Blob, `report-${runId.slice(0, 8)}.${ext}`) + }, + compare: (run1: string, run2: string) => + api.get(`/reports/compare`, { params: { run1, run2 } }), +} diff --git a/frontend/web/src/api/runs.ts b/frontend/web/src/api/runs.ts new file mode 100644 index 0000000..f65689d --- /dev/null +++ b/frontend/web/src/api/runs.ts @@ -0,0 +1,89 @@ +import api from './client' + +export type RunTrigger = 'manual' | 'ai_assistant' | 'cli' | 'campaign' + +export interface RunError { + code: string + message: string +} + +export interface RunSummary { + total_cases: number + passed_cases: number + failed_cases: number + total_rules: number + passed_rules: number + pass_rate: number | null + avg_latency_ms: number | null + case_outcomes: Record + case_errors: Array> + error: RunError | null +} + +export interface Run { + id: string + target_id: string + scenario_id: string + scenario_version?: number + campaign_id?: string | null + status: string + triggered_by?: RunTrigger + scenario_name?: string | null + target_name?: string | null + started_at: string + completed_at: string | null + summary: RunSummary | null +} + +export interface RunLogsTurn { + id: string + case_id: string + round_index: number + latency_ms: number | null + sent_text: string + reply_text: string + sent_at: string | null + received_at: string | null +} + +export interface RunLogsResult { + case_id: string + rule_type: string + passed: boolean + score: number | null + reason: string | null +} + +export interface CaseSnapshotExpectations { + intent?: string | null + keywords_include: string[] + keywords_exclude: string[] + response_time_max_ms?: number | null + coherence_min_score?: number | null +} + +export interface CaseSnapshot { + id: string + type: string + messages: string[] + prompt?: string | null + turns?: number | null + expectations: CaseSnapshotExpectations + eval_rules: { type: string; params: Record }[] +} + +export interface RunLogsResponse { + turns: RunLogsTurn[] + results: RunLogsResult[] + case_verdicts: Record + scenario_snapshot: Record +} + +export const runsApi = { + list: () => api.get('/runs'), + start: (target_id: string, scenario_id: string) => + api.post('/runs', { target_id, scenario_id }), + get: (id: string) => api.get(`/runs/${id}`), + logs: (id: string) => api.get(`/runs/${id}/logs`), + cancel: (id: string) => api.post(`/runs/${id}/cancel`), +} diff --git a/frontend/web/src/api/scenarios.ts b/frontend/web/src/api/scenarios.ts new file mode 100644 index 0000000..31c57f7 --- /dev/null +++ b/frontend/web/src/api/scenarios.ts @@ -0,0 +1,24 @@ +import api from './client' + +export interface Scenario { + id: string + name: string + description: string + tags: string[] + cases: any[] + model_bindings: Record + version: number + created_at: string + updated_at: string +} + +export const scenariosApi = { + list: () => api.get('/scenarios'), + get: (id: string) => api.get(`/scenarios/${id}`), + create: (data: Partial) => api.post('/scenarios', data), + update: (id: string, data: Partial) => api.put(`/scenarios/${id}`, data), + delete: (id: string) => api.delete(`/scenarios/${id}`), + validate: (data: any) => api.post<{ valid: boolean; errors: string[] }>('/scenarios/validate', data), + listTemplates: () => api.get('/scenarios/templates'), + getTemplate: (id: string) => api.get(`/scenarios/templates/${id}`), +} diff --git a/frontend/web/src/api/stats.ts b/frontend/web/src/api/stats.ts new file mode 100644 index 0000000..accd688 --- /dev/null +++ b/frontend/web/src/api/stats.ts @@ -0,0 +1,34 @@ +import api from './client' +import type { Run, RunTrigger } from './runs' + +export interface ScenarioStat { + scenario_id: string + scenario_name: string + run_count: number + avg_pass_rate: number | null + last_run_at: string | null +} + +export interface DashboardStats { + targets_count: number + scenarios_count: number + runs_count: number + model_configs_count: number + today_runs: number + running_count: number + overall_pass_rate: number | null + trigger_breakdown: Partial> + scenario_stats: ScenarioStat[] + recent_runs: Run[] +} + +export interface TrendPoint { + date: string + pass_rate: number + run_count: number +} + +export const statsApi = { + dashboard: () => api.get('/stats/dashboard'), + trend: (days?: number) => api.get('/stats/trend', { params: { days } }), +} diff --git a/frontend/web/src/api/targets.ts b/frontend/web/src/api/targets.ts new file mode 100644 index 0000000..f18bd8e --- /dev/null +++ b/frontend/web/src/api/targets.ts @@ -0,0 +1,24 @@ +import api from './client' +import type { Run } from './runs' + +export interface Target { + id: string + name: string + description: string + platform: string + channel_type: string + channel_config: Record + status: string + created_at: string + updated_at: string +} + +export const targetsApi = { + list: () => api.get('/targets'), + get: (id: string) => api.get(`/targets/${id}`), + create: (data: Partial) => api.post('/targets', data), + update: (id: string, data: Partial) => api.put(`/targets/${id}`, data), + delete: (id: string) => api.delete(`/targets/${id}`), + test: (id: string) => api.post<{ ok: boolean; message: string }>(`/targets/${id}/test`), + runs: (id: string) => api.get(`/targets/${id}/runs`), +}