370 lines
9.6 KiB
TypeScript
370 lines
9.6 KiB
TypeScript
/**
|
||
* Chat UI API 服务
|
||
* 所有端点都是固定的,后端需要实现这些端点
|
||
*/
|
||
import { getAuthHeaders } from './request';
|
||
|
||
// API 端点定义(固定)
|
||
const API_ENDPOINTS = {
|
||
// 发送消息(流式)
|
||
CHAT_STREAM: "/api/chat-ui/chat",
|
||
// 发送消息(非流式)
|
||
CHAT: "/api/chat-ui/chat",
|
||
// 获取对话历史
|
||
CONVERSATIONS: "/api/chat-ui/conversations",
|
||
// 获取单个对话
|
||
CONVERSATION: "/api/chat-ui/conversations/:id",
|
||
// 删除对话
|
||
DELETE_CONVERSATION: "/api/chat-ui/conversations/:id",
|
||
// 上传文件
|
||
UPLOAD: "/api/chat-ui/upload",
|
||
// 获取模型列表
|
||
MODELS: "/api/chat-ui/models",
|
||
// 停止生成
|
||
STOP: "/api/chat-ui/stop",
|
||
};
|
||
|
||
// 请求类型定义
|
||
export interface ChatMessage {
|
||
role: "user" | "assistant" | "system";
|
||
content: string;
|
||
images?: string[];
|
||
files?: string[];
|
||
}
|
||
|
||
export interface ChatRequest {
|
||
conversationId?: string;
|
||
message: string;
|
||
images?: string[];
|
||
files?: string[]; // 非图片附件 URL 列表
|
||
model?: string;
|
||
temperature?: number;
|
||
maxTokens?: number;
|
||
systemPrompt?: string;
|
||
stream?: boolean;
|
||
// 历史对话消息(用于上下文记忆)
|
||
history?: { role: string; content: string }[];
|
||
// 扩展选项
|
||
deepSearch?: boolean;
|
||
webSearch?: boolean;
|
||
deepThinking?: boolean;
|
||
}
|
||
|
||
export interface ChatResponse {
|
||
id: string;
|
||
conversationId: string;
|
||
content: string;
|
||
model: string;
|
||
createdAt: number;
|
||
usage?: {
|
||
promptTokens: number;
|
||
completionTokens: number;
|
||
totalTokens: number;
|
||
};
|
||
}
|
||
|
||
export interface ModelInfo {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
maxTokens: number;
|
||
provider: string;
|
||
supports_thinking: boolean;
|
||
supports_web_search: boolean;
|
||
supports_vision: boolean;
|
||
supports_files: boolean;
|
||
}
|
||
|
||
export interface UploadResult {
|
||
url: string;
|
||
name: string;
|
||
size?: number;
|
||
mimeType?: string;
|
||
}
|
||
|
||
// 流式响应块类型
|
||
export interface StreamChunk {
|
||
type: "content" | "reasoning";
|
||
text: string;
|
||
}
|
||
|
||
// API 调用类
|
||
class ChatApi {
|
||
private baseUrl: string;
|
||
|
||
constructor(baseUrl = "") {
|
||
this.baseUrl = baseUrl;
|
||
}
|
||
|
||
/**
|
||
* 流式对话
|
||
*/
|
||
async *streamChat(
|
||
request: ChatRequest,
|
||
signal?: AbortSignal,
|
||
): AsyncGenerator<StreamChunk> {
|
||
// 构建消息数组,考虑是否包含图片
|
||
let userContent;
|
||
if (request.images && request.images.length > 0) {
|
||
// 如果有图片,则构建内容数组(针对阿里云DashScope API的格式)
|
||
userContent = [{ type: "text", text: request.message }];
|
||
// 添加图片URL到内容中(阿里云格式)
|
||
request.images.forEach((imageUrl) => {
|
||
userContent.push({
|
||
type: "image_url",
|
||
image_url: imageUrl, // 注意:阿里云格式不需要嵌套对象
|
||
});
|
||
});
|
||
} else {
|
||
// 没有图片时,使用简单的文本
|
||
userContent = request.message;
|
||
}
|
||
|
||
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
|
||
// 构建 messages 数组:system + 历史消息 + 当前用户消息
|
||
const systemMessage = {
|
||
role: "system",
|
||
content:
|
||
request.systemPrompt ||
|
||
"你是一个智能助手,可以分析用户发送的文字,文件或图片内容,并进行回答。",
|
||
};
|
||
const currentUserMessage = {
|
||
role: "user",
|
||
content: userContent,
|
||
};
|
||
const allMessages =
|
||
request.history && request.history.length > 0
|
||
? [systemMessage, ...request.history, currentUserMessage]
|
||
: [systemMessage, currentUserMessage];
|
||
|
||
const openAiRequest = {
|
||
model: request.model || "glm-4-flash",
|
||
messages: allMessages,
|
||
stream: true,
|
||
temperature: request.temperature,
|
||
max_tokens: request.maxTokens,
|
||
files: request.files || [],
|
||
// 扩展参数传递给我们的 Python 后端进行特殊处理
|
||
deepSearch: request.deepSearch,
|
||
webSearch: request.webSearch,
|
||
deepThinking: request.deepThinking,
|
||
};
|
||
|
||
const response = await fetch(
|
||
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
...getAuthHeaders(),
|
||
Accept: "text/event-stream",
|
||
},
|
||
body: JSON.stringify(openAiRequest),
|
||
signal,
|
||
},
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const error = await response.text();
|
||
throw new Error(error || `HTTP ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body?.getReader();
|
||
if (!reader) {
|
||
throw new Error("Response body is not readable");
|
||
}
|
||
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split("\n");
|
||
// 保留最后一行未完整的 JSON
|
||
buffer = lines.pop() || "";
|
||
|
||
for (const line of lines) {
|
||
if (line.trim() === "" || line.includes("[DONE]")) continue;
|
||
const match = line.match(/^data:\s*(.+)$/);
|
||
if (match) {
|
||
try {
|
||
const data = JSON.parse(match[1]);
|
||
// 检查是否有完成原因,如果是完成则跳出
|
||
const finishReason = data.choices?.[0]?.finish_reason;
|
||
if (finishReason && finishReason !== "null") {
|
||
break;
|
||
}
|
||
|
||
const delta = data.choices?.[0]?.delta;
|
||
|
||
// 处理深度思考内容(reasoning_content)
|
||
const reasoningContent = delta?.reasoning_content;
|
||
if (reasoningContent) {
|
||
yield { type: "reasoning", text: reasoningContent };
|
||
}
|
||
|
||
// 处理普通内容
|
||
const content = delta?.content;
|
||
if (content) {
|
||
yield { type: "content", text: content };
|
||
}
|
||
} catch (e) {
|
||
console.warn("JSON解析错误", e, line);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 非流式对话
|
||
*/
|
||
async chat(request: ChatRequest): Promise<ChatResponse> {
|
||
// 构建消息数组,考虑是否包含图片
|
||
let userContent;
|
||
if (request.images && request.images.length > 0) {
|
||
// 如果有图片,则构建内容数组
|
||
userContent = [{ type: "text", text: request.message }];
|
||
// 添加图片URL到内容中
|
||
request.images.forEach((imageUrl) => {
|
||
userContent.push({
|
||
type: "image_url",
|
||
image_url: { url: imageUrl },
|
||
});
|
||
});
|
||
} else {
|
||
// 没有图片时,使用简单的文本
|
||
userContent = request.message;
|
||
}
|
||
|
||
const requestBody = {
|
||
...request,
|
||
message: userContent,
|
||
};
|
||
|
||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.CHAT}`, {
|
||
method: "POST",
|
||
headers: getAuthHeaders(),
|
||
body: JSON.stringify(requestBody),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.text();
|
||
throw new Error(error || `HTTP ${response.status}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
/**
|
||
* 停止对话
|
||
*/
|
||
async stopChat(messageId?: string) {
|
||
await fetch(`${this.baseUrl}${API_ENDPOINTS.STOP}/${messageId}`, {
|
||
method: "POST",
|
||
headers: getAuthHeaders(),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取模型列表
|
||
*/
|
||
async getModels(): Promise<ModelInfo[]> {
|
||
try {
|
||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.MODELS}`, {
|
||
method: "GET",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`获取模型列表失败: HTTP ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
// 后端返回格式: { object: "list", data: [...] }
|
||
return data.data || [];
|
||
} catch (error) {
|
||
console.error("获取模型列表失败:", error);
|
||
// 返回默认模型列表作为降级
|
||
return [
|
||
{
|
||
id: "glm-4.6",
|
||
name: "智普 GLM-4.6",
|
||
description: "最强大的模型",
|
||
maxTokens: 200000,
|
||
provider: "Zhipu",
|
||
supports_thinking: true,
|
||
supports_web_search: true,
|
||
supports_vision: false,
|
||
supports_files: false,
|
||
},
|
||
{
|
||
id: "deepseek-chat",
|
||
name: "DeepSeek Chat",
|
||
description: "DeepSeek 对话模型",
|
||
maxTokens: 64000,
|
||
provider: "DeepSeek",
|
||
supports_thinking: false,
|
||
supports_web_search: false,
|
||
supports_vision: false,
|
||
supports_files: false,
|
||
},
|
||
];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传文件
|
||
*/
|
||
async uploadFile(file: File): Promise<UploadResult> {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
// 获取认证 headers,但不包含 Content-Type(让浏览器为 FormData 自动设置)
|
||
const authHeaders = getAuthHeaders();
|
||
const { 'Content-Type': _, ...headersWithoutContentType } = authHeaders;
|
||
|
||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
|
||
method: "POST",
|
||
headers: headersWithoutContentType,
|
||
body: formData,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`上传失败: HTTP ${response.status}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
/**
|
||
* 删除附件(从 OSS 删除)
|
||
*/
|
||
async deleteAttachment(url: string): Promise<void> {
|
||
const response = await fetch(
|
||
`${this.baseUrl}/api/chat-ui/attachment?url=${encodeURIComponent(url)}`,
|
||
{
|
||
method: "DELETE",
|
||
headers: getAuthHeaders(),
|
||
}
|
||
);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`删除附件失败: HTTP ${response.status}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const chatApi = new ChatApi();
|
||
|
||
// 导出类用于自定义配置
|
||
export { ChatApi, API_ENDPOINTS };
|
||
|
||
// 导出端点常量(供调试使用)
|
||
// export {API_ENDPOINTS}
|