ai.ts
1 import request from './request'; 2 import i18n from '../i18n'; 3 import type { 4 ApiResponse, 5 PageResponse, 6 Task, 7 AiConfigVO, 8 AiConfigSaveRequest, 9 AiTestRequest, 10 AiTestResult, 11 AiRiskRulesVO, 12 AiRiskRulesSaveRequest, 13 AiRiskAssessRequest, 14 RiskAssessment, 15 AiChatSession, 16 AiChatMessage, 17 AiChatRequest, 18 AiChatResponse, 19 AiScheduledTask, 20 AiScheduledTaskRequest, 21 AiInspectReport, 22 AiAlertRequest, 23 AiAlertAnalysis, 24 BuiltInTemplate, 25 AgentEvent, 26 } from '../types'; 27 28 export function getAiConfig(): Promise<ApiResponse<AiConfigVO>> { 29 return request.get('/v1/ai/config'); 30 } 31 32 export function saveAiConfig(data: AiConfigSaveRequest): Promise<ApiResponse<null>> { 33 return request.put('/v1/ai/config', data); 34 } 35 36 export function testAiConnection(data: AiTestRequest): Promise<ApiResponse<AiTestResult>> { 37 return request.post('/v1/ai/config/test', data); 38 } 39 40 export function getRiskRules(): Promise<ApiResponse<AiRiskRulesVO>> { 41 return request.get('/v1/ai/risk/rules'); 42 } 43 44 export function saveRiskRules(data: AiRiskRulesSaveRequest): Promise<ApiResponse<null>> { 45 return request.put('/v1/ai/risk/rules', data); 46 } 47 48 export function assessRisk(data: AiRiskAssessRequest): Promise<ApiResponse<RiskAssessment>> { 49 return request.post('/v1/ai/risk/assess', data); 50 } 51 52 export function getChatSessions(): Promise<ApiResponse<AiChatSession[]>> { 53 return request.get('/v1/ai/chat/sessions'); 54 } 55 56 export function sendChat(data: AiChatRequest, timeout?: number): Promise<ApiResponse<AiChatResponse>> { 57 return request.post('/v1/ai/chat', data, { timeout: timeout || 120000 }); 58 } 59 60 export function getChatMessages(sessionId: string): Promise<ApiResponse<AiChatMessage[]>> { 61 return request.get(`/v1/ai/chat/sessions/${sessionId}/messages`); 62 } 63 64 export function deleteChatSession(sessionId: string): Promise<ApiResponse<null>> { 65 return request.delete(`/v1/ai/chat/sessions/${sessionId}`); 66 } 67 68 export function approveExecution(taskId: string): Promise<ApiResponse<null>> { 69 return request.post(`/v1/ai/chat/approve/${taskId}`); 70 } 71 72 export function rejectExecution(taskId: string): Promise<ApiResponse<null>> { 73 return request.post(`/v1/ai/chat/reject/${taskId}`); 74 } 75 76 export function confirmPlan(sessionId: string): Promise<ApiResponse<null>> { 77 return request.post(`/v1/ai/chat/sessions/${sessionId}/plan/confirm`); 78 } 79 80 export function rejectPlan(sessionId: string): Promise<ApiResponse<null>> { 81 return request.post(`/v1/ai/chat/sessions/${sessionId}/plan/reject`); 82 } 83 84 export function approveCheckpoint(sessionId: string, stepIndex: number): Promise<ApiResponse<null>> { 85 return request.post(`/v1/ai/chat/sessions/${sessionId}/checkpoint/${stepIndex}/approve`); 86 } 87 88 export function rejectCheckpoint(sessionId: string, stepIndex: number): Promise<ApiResponse<null>> { 89 return request.post(`/v1/ai/chat/sessions/${sessionId}/checkpoint/${stepIndex}/reject`); 90 } 91 92 export function saveProcessData(sessionId: string, processData: string): Promise<ApiResponse<null>> { 93 return request.post(`/v1/ai/chat/sessions/${sessionId}/process-data`, { processData }); 94 } 95 96 export function getPendingApprovals(): Promise<ApiResponse<Task[]>> { 97 return request.get('/v1/ai/chat/pending-approvals'); 98 } 99 100 const STREAM_TIMEOUT_MS = 180_000; 101 const STREAM_MAX_RETRIES = 2; 102 const NON_RETRYABLE_STATUSES = new Set([400, 401, 403, 404, 422]); 103 104 function isRetryableError(err: unknown): boolean { 105 if (err instanceof DOMException && err.name === 'AbortError') return false; 106 if (err instanceof Error && err.message.startsWith('HTTP ')) { 107 const status = parseInt(err.message.slice(5), 10); 108 if (NON_RETRYABLE_STATUSES.has(status)) return false; 109 } 110 return true; 111 } 112 113 export function sendChatStream( 114 data: AiChatRequest, 115 onEvent: (event: AgentEvent) => void, 116 onError: (error: Error) => void, 117 onComplete: () => void, 118 ): AbortController { 119 const controller = new AbortController(); 120 let attempt = 0; 121 122 const doFetch = () => { 123 const token = localStorage.getItem('token'); 124 let receivedTerminalEvent = false; 125 126 const wrappedOnEvent = (event: AgentEvent) => { 127 if (event.type === 'DONE' || event.type === 'ERROR') { 128 receivedTerminalEvent = true; 129 } 130 onEvent(event); 131 }; 132 133 // Dual AbortController: timeoutId fires fetchAbort (triggers retry), 134 // while controller.abort() is the user-initiated cancel (no retry). 135 const timeoutId = setTimeout(() => { 136 if (!receivedTerminalEvent && !controller.signal.aborted) { 137 fetchAbort.abort(new DOMException('Stream timeout', 'TimeoutError')); 138 } 139 }, STREAM_TIMEOUT_MS); 140 141 const fetchAbort = new AbortController(); 142 const onUserAbort = () => fetchAbort.abort(); 143 controller.signal.addEventListener('abort', onUserAbort, { once: true }); 144 145 fetch('/api/v1/ai/chat/stream', { 146 method: 'POST', 147 headers: { 148 'Content-Type': 'application/json', 149 'Authorization': token ? `Bearer ${token}` : '', 150 'Accept': 'text/event-stream', 151 'Accept-Language': i18n.language || 'zh-CN', 152 }, 153 body: JSON.stringify(data), 154 signal: fetchAbort.signal, 155 }) 156 .then(async (response) => { 157 clearTimeout(timeoutId); 158 if (!response.ok) { 159 if (response.status === 401 || response.status === 403) { 160 localStorage.removeItem('token'); 161 window.location.href = '/login'; 162 return; 163 } 164 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 165 } 166 const reader = response.body?.getReader(); 167 if (!reader) throw new Error('No response body'); 168 169 const decoder = new TextDecoder(); 170 let buffer = ''; 171 172 try { 173 while (true) { 174 const { done, value } = await reader.read(); 175 if (done) break; 176 177 buffer += decoder.decode(value, { stream: true }); 178 const lines = buffer.split('\n'); 179 buffer = lines.pop() || ''; 180 181 for (const line of lines) { 182 if (line.startsWith('data:')) { 183 const jsonStr = line.slice(5).trim(); 184 if (jsonStr) { 185 try { 186 const event: AgentEvent = JSON.parse(jsonStr); 187 wrappedOnEvent(event); 188 } catch { } 189 } 190 } 191 } 192 193 if (receivedTerminalEvent) { 194 // After receiving DONE/ERROR, drain remaining buffer then let stream close naturally 195 // DO NOT call reader.cancel() - it triggers server-side cancellation before doFinally runs 196 if (buffer.startsWith('data:')) { 197 const jsonStr = buffer.slice(5).trim(); 198 if (jsonStr) { 199 try { 200 const event: AgentEvent = JSON.parse(jsonStr); 201 wrappedOnEvent(event); 202 } catch { } 203 } 204 buffer = ''; 205 } 206 // Simply break the loop - the server will close the stream naturally 207 break; 208 } 209 } 210 } catch (readErr) { 211 if (!receivedTerminalEvent) throw readErr; 212 } 213 214 if (buffer.startsWith('data:')) { 215 const jsonStr = buffer.slice(5).trim(); 216 if (jsonStr) { 217 try { 218 const event: AgentEvent = JSON.parse(jsonStr); 219 wrappedOnEvent(event); 220 } catch { } 221 } 222 } 223 224 onComplete(); 225 }) 226 .catch((err) => { 227 clearTimeout(timeoutId); 228 controller.signal.removeEventListener('abort', onUserAbort); 229 230 if (controller.signal.aborted) return; 231 232 if (receivedTerminalEvent) { 233 onComplete(); 234 return; 235 } 236 237 if (isRetryableError(err) && attempt < STREAM_MAX_RETRIES) { 238 attempt++; 239 const backoff = Math.min(1000 * Math.pow(2, attempt - 1), 8000); 240 setTimeout(doFetch, backoff); 241 return; 242 } 243 244 onError(err instanceof Error ? err : new Error(String(err))); 245 }); 246 }; 247 248 doFetch(); 249 return controller; 250 } 251 252 export function getScheduledTasks(): Promise<ApiResponse<AiScheduledTask[]>> { 253 return request.get('/v1/ai/scheduled-tasks'); 254 } 255 256 export function getScheduledTask(id: number): Promise<ApiResponse<AiScheduledTask>> { 257 return request.get(`/v1/ai/scheduled-tasks/${id}`); 258 } 259 260 export function createScheduledTask(data: AiScheduledTaskRequest): Promise<ApiResponse<AiScheduledTask>> { 261 return request.post('/v1/ai/scheduled-tasks', data); 262 } 263 264 export function updateScheduledTask(id: number, data: AiScheduledTaskRequest): Promise<ApiResponse<AiScheduledTask>> { 265 return request.put(`/v1/ai/scheduled-tasks/${id}`, data); 266 } 267 268 export function deleteScheduledTask(id: number): Promise<ApiResponse<null>> { 269 return request.delete(`/v1/ai/scheduled-tasks/${id}`); 270 } 271 272 export function enableScheduledTask(id: number): Promise<ApiResponse<null>> { 273 return request.post(`/v1/ai/scheduled-tasks/${id}/enable`); 274 } 275 276 export function disableScheduledTask(id: number): Promise<ApiResponse<null>> { 277 return request.post(`/v1/ai/scheduled-tasks/${id}/disable`); 278 } 279 280 export function runScheduledTask(id: number): Promise<ApiResponse<null>> { 281 return request.post(`/v1/ai/scheduled-tasks/${id}/run`); 282 } 283 284 export function getTemplates(): Promise<ApiResponse<BuiltInTemplate[]>> { 285 return request.get('/v1/ai/scheduled-tasks/templates'); 286 } 287 288 export function analyzeAlert(data: AiAlertRequest): Promise<ApiResponse<AiAlertAnalysis>> { 289 return request.post('/v1/ai/scheduled-tasks/alert/analyze', data); 290 } 291 292 export function getInspectReports(page: number = 0, size: number = 10): Promise<ApiResponse<PageResponse<AiInspectReport>>> { 293 return request.get(`/v1/ai/inspect/reports?page=${page}&size=${size}`); 294 } 295 296 export function getInspectReport(id: number): Promise<ApiResponse<AiInspectReport>> { 297 return request.get(`/v1/ai/inspect/reports/${id}`); 298 } 299 300 // GitHub Copilot OAuth Device Flow 301 export function copilotRequestDeviceCode(): Promise<ApiResponse<{ deviceCode: string; userCode: string; verificationUri: string; expiresIn: number; interval: number }>> { 302 return request.post('/v1/ai/copilot/device-code'); 303 } 304 305 export function copilotPollToken(deviceCode: string): Promise<ApiResponse<{ status: string; message: string }>> { 306 return request.post('/v1/ai/copilot/poll-token', { deviceCode }); 307 } 308 309 export function copilotGetStatus(): Promise<ApiResponse<{ authenticated: boolean }>> { 310 return request.get('/v1/ai/copilot/status'); 311 } 312 313 export function copilotLogout(): Promise<ApiResponse<null>> { 314 return request.delete('/v1/ai/copilot/logout'); 315 } 316 317 export function getCopilotModels(): Promise<ApiResponse<{ id: string; name: string; version: string }[]>> { 318 return request.get('/v1/ai/copilot/models'); 319 } 320 321 export function getProviderModels(provider: string): Promise<ApiResponse<{ id: string; name: string }[]>> { 322 return request.get('/v1/ai/config/models', { params: { provider } }); 323 }