151 lines
4.7 KiB
TypeScript
151 lines
4.7 KiB
TypeScript
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<string, unknown>;
|
||
}
|
||
|
||
export function fetchConversations(limit = 50, search?: string) {
|
||
return http.get<ApiResponse<ConversationSummary[]>>("/v1/conversations", {
|
||
params: { limit, search },
|
||
timeout: 30_000,
|
||
});
|
||
}
|
||
|
||
export function createConversation(title?: string) {
|
||
return http.post<ApiResponse<Conversation>>("/v1/conversations", {
|
||
title,
|
||
});
|
||
}
|
||
|
||
export function directConversationQuery(kind: "supplier" | "material" | "cases" | "solution", query: string) {
|
||
return http.post<ApiResponse<Record<string, unknown>>>("/v1/conversations/direct-query", { kind, query });
|
||
}
|
||
|
||
export function fetchConversation(id: number, offset = 0) {
|
||
return http.get<ApiResponse<Conversation>>(`/v1/conversations/${id}`, {
|
||
params: { limit: 20, offset },
|
||
timeout: 60_000,
|
||
});
|
||
}
|
||
|
||
export function deleteConversation(id: number) {
|
||
return http.delete<ApiResponse<null>>(`/v1/conversations/${id}`);
|
||
}
|
||
|
||
export function sendConversationMessage(
|
||
id: number,
|
||
content: string,
|
||
messageType: "text" | "image" | "file" | "audio" = "text",
|
||
) {
|
||
return http.post<ApiResponse<Conversation>>(
|
||
`/v1/conversations/${id}/messages`,
|
||
{ content, messageType },
|
||
);
|
||
}
|
||
|
||
export function withdrawConversationMessage(
|
||
conversationId: number,
|
||
messageId: number,
|
||
) {
|
||
return http.delete<ApiResponse<Conversation>>(
|
||
`/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);
|
||
}
|
||
}
|