/ easyshell-web / src / api / ai.ts
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  }