import { http } from "@/api/http"; import type { Conversation, ConversationSummary } from "@/types/conversation"; import type { ApiResponse } from "@/types/api"; export interface ConversationStreamEvent { type: "start" | "semantic" | "delta" | "done" | "error"; content?: string; message?: string; data?: Conversation | Record; } export function fetchConversations(limit = 50, search?: string) { return http.get>("/v1/conversations", { params: { limit, search }, timeout: 30_000, }); } export function createConversation(title?: string) { return http.post>("/v1/conversations", { title, }); } export function directConversationQuery(kind: "supplier" | "material" | "cases" | "solution", query: string) { return http.post>>("/v1/conversations/direct-query", { kind, query }); } export function fetchConversation(id: number, offset = 0) { return http.get>(`/v1/conversations/${id}`, { params: { limit: 20, offset }, timeout: 60_000, }); } export function deleteConversation(id: number) { return http.delete>(`/v1/conversations/${id}`); } export function sendConversationMessage( id: number, content: string, messageType: "text" | "image" | "file" | "audio" = "text", ) { return http.post>( `/v1/conversations/${id}/messages`, { content, messageType }, ); } export function withdrawConversationMessage( conversationId: number, messageId: number, ) { return http.delete>( `/v1/conversations/${conversationId}/messages/${messageId}`, ); } export async function streamConversationMessage( id: number, content: string, onEvent: (event: ConversationStreamEvent) => void, messageType: "text" | "image" | "file" | "audio" = "text", signal?: AbortSignal, ) { const token = localStorage.getItem("risk_prediction_token"); const controller = new AbortController(); const abortFromCaller = () => controller.abort(); if (signal?.aborted) controller.abort(); signal?.addEventListener("abort", abortFromCaller, { once: true }); const timeout = window.setTimeout(() => controller.abort(), 180_000); let response: Response; try { response = await fetch( `${import.meta.env.VITE_API_BASE_URL || "/api"}/v1/conversations/${id}/stream`, { method: "POST", headers: { Accept: "text/event-stream", "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ content, messageType }), signal: controller.signal, }, ); } catch (reason) { if (signal?.aborted) throw new Error("已停止生成", { cause: reason }); throw reason; } if (response.status === 401) { localStorage.removeItem("risk_prediction_token"); window.dispatchEvent(new CustomEvent("risk-auth-expired")); } if (!response.ok || !response.body) throw new Error(`流式请求失败(HTTP ${response.status})`); try { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let completed = false; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line.startsWith("data:")) continue; const raw = line.slice(5).trim(); if (!raw) continue; let event: ConversationStreamEvent; try { event = JSON.parse(raw) as ConversationStreamEvent; } catch { throw new Error("AI 流式响应格式错误,请重试"); } onEvent(event); if (event.type === "done") completed = true; } } if (buffer.startsWith("data:")) { const raw = buffer.slice(5).trim(); if (raw) { let event: ConversationStreamEvent; try { event = JSON.parse(raw) as ConversationStreamEvent; } catch { throw new Error("AI 流式响应格式错误,请重试"); } onEvent(event); if (event.type === "done") completed = true; } } if (!completed) throw new Error("AI 流式连接中断,请重试"); } catch (reason) { if (signal?.aborted) { throw new Error("已停止生成", { cause: reason }); } if (controller.signal.aborted) { throw new Error("AI 响应超时,请稍后重试", { cause: reason }); } throw reason; } finally { window.clearTimeout(timeout); signal?.removeEventListener("abort", abortFromCaller); } }