291 lines
7.2 KiB
TypeScript
291 lines
7.2 KiB
TypeScript
/**
|
||
* Chat UI API 服务
|
||
* 所有端点都是固定的,后端需要实现这些端点
|
||
*/
|
||
// 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[];
|
||
model?: string;
|
||
temperature?: number;
|
||
maxTokens?: number;
|
||
systemPrompt?: string;
|
||
stream?: boolean;
|
||
// 扩展选项
|
||
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;
|
||
}
|
||
|
||
export interface UploadResult {
|
||
url: string;
|
||
name: string;
|
||
size?: number;
|
||
mimeType?: string;
|
||
}
|
||
|
||
// API 调用类
|
||
class ChatApi {
|
||
private baseUrl: string;
|
||
|
||
constructor(baseUrl = "") {
|
||
this.baseUrl = baseUrl;
|
||
}
|
||
|
||
/**
|
||
* 流式对话
|
||
*/
|
||
async *streamChat(
|
||
request: ChatRequest,
|
||
signal?: AbortSignal,
|
||
): AsyncGenerator<string> {
|
||
// 构建消息数组,考虑是否包含图片
|
||
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 兼容的规范请求体
|
||
const openAiRequest = {
|
||
model: request.model || "qwen-plus", // 可能需要指定支持视觉的模型
|
||
messages: [
|
||
{ role: "system", content: request.systemPrompt || "你是一个支持视觉理解的助手。" },
|
||
{
|
||
role: "user",
|
||
content: userContent
|
||
}
|
||
],
|
||
stream: true,
|
||
temperature: request.temperature,
|
||
max_tokens: request.maxTokens
|
||
};
|
||
|
||
const response = await fetch(
|
||
`${this.baseUrl}${API_ENDPOINTS.CHAT_STREAM}`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
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 content = data.choices?.[0]?.delta?.content;
|
||
if (content) {
|
||
yield 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: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
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: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取模型列表
|
||
*/
|
||
async getModels(): Promise<ModelInfo[]> {
|
||
return [
|
||
{
|
||
id: "qwen-max",
|
||
name: "通义千问 Max",
|
||
description: "最强大的模型",
|
||
maxTokens: 8192,
|
||
provider: "Aliyun",
|
||
},
|
||
{
|
||
id: "qwen-plus",
|
||
name: "通义千问 Plus",
|
||
description: "能力均衡",
|
||
maxTokens: 8192,
|
||
provider: "Aliyun",
|
||
},
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 上传文件
|
||
*/
|
||
async uploadFile(file: File): Promise<UploadResult> {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
const response = await fetch(`${this.baseUrl}${API_ENDPOINTS.UPLOAD}`, {
|
||
method: "POST",
|
||
body: formData,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`上传失败: HTTP ${response.status}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
}
|
||
|
||
// 导出单例
|
||
export const chatApi = new ChatApi();
|
||
|
||
// 导出类用于自定义配置
|
||
export { ChatApi, API_ENDPOINTS };
|
||
|
||
// 导出端点常量(供调试使用)
|
||
// export {API_ENDPOINTS}
|