ai-chatbot/lib/ai/providers/local-qwen.ts
Your Name 53ddb7e585
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Initial commit - clean repository
2025-12-17 20:15:18 +08:00

234 lines
5.7 KiB
TypeScript

import "server-only";
/**
* 本地千问模型 Provider
* 支持千问2.5-VL-3B-Instruct 本地部署模型
*/
interface LocalQwenConfig {
baseURL: string;
apiKey: string;
timeout?: number;
}
interface QwenMessage {
role: string;
content: Array<{ type: string; text?: string; image?: string }>;
}
interface QwenChatRequest {
messages: QwenMessage[];
max_new_tokens?: number;
temperature?: number;
top_p?: number;
do_sample?: boolean;
}
interface QwenChatResponse {
response: string;
usage: {
tokens: number;
};
processing_time: number;
}
/**
* 创建本地千问模型客户端
*/
export function createLocalQwen(config: LocalQwenConfig) {
const { baseURL, apiKey, timeout = 30000 } = config;
// 使用 fetch API 的辅助函数
async function fetchAPI(endpoint: string, options: RequestInit = {}) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(`${baseURL}${endpoint}`, {
...options,
signal: controller.signal,
headers: {
"X-API-Key": apiKey,
"Content-Type": "application/json",
...options.headers,
},
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}
/**
* 转换 AI SDK 消息格式到千问格式
*/
function convertMessages(messages: any[]): QwenMessage[] {
return messages.map((msg) => {
// 如果消息是字符串,转换为标准格式
if (typeof msg.content === "string") {
return {
role: msg.role,
content: [
{
type: "text",
text: msg.content,
},
],
};
}
// 如果消息已经是数组格式,直接返回
if (Array.isArray(msg.content)) {
return {
role: msg.role,
content: msg.content,
};
}
// 默认转换
return {
role: msg.role,
content: [
{
type: "text",
text: String(msg.content),
},
],
};
});
}
/**
* 生成聊天响应
*/
async function chat(
messages: any[],
options: {
maxTokens?: number;
temperature?: number;
topP?: number;
} = {}
): Promise<string> {
try {
console.log("[LocalQwen] Sending chat request to:", baseURL);
console.log("[LocalQwen] Messages count:", messages.length);
const requestBody: QwenChatRequest = {
messages: convertMessages(messages),
max_new_tokens: options.maxTokens || 512,
temperature: options.temperature || 0.7,
top_p: options.topP || 0.9,
do_sample: true,
};
const response = await fetchAPI("/chat/text", {
method: "POST",
body: JSON.stringify(requestBody),
});
console.log("[LocalQwen] Response received:", {
tokens: response.usage.tokens,
processingTime: response.processing_time,
});
return response.response;
} catch (error: unknown) {
console.error("[LocalQwen] Chat error:", error);
const errorMsg = error instanceof Error ? error.message : String(error);
throw new Error(`本地千问模型请求失败: ${errorMsg}`);
}
}
/**
* 创建流式响应(本地模型暂不支持流式,模拟流式效果)
*/
async function* chatStream(
messages: any[],
options: {
maxTokens?: number;
temperature?: number;
topP?: number;
} = {}
): AsyncGenerator<string> {
const response = await chat(messages, options);
// 模拟流式输出,每次输出几个字符
const chunkSize = 5;
for (let i = 0; i < response.length; i += chunkSize) {
yield response.slice(i, i + chunkSize);
// 添加小延迟使流式效果更自然
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
/**
* 健康检查
*/
async function healthCheck(): Promise<boolean> {
try {
const response = await fetchAPI("/health");
return response.status === "healthy";
} catch (error: unknown) {
console.error("[LocalQwen] Health check failed:", error);
return false;
}
}
// 返回类似 AI SDK LanguageModel 的接口
return {
chat: (modelId: string) => ({
doGenerate: async (options: any) => {
const response = await chat(options.prompt, {
maxTokens: options.maxTokens,
temperature: options.temperature,
topP: options.topP,
});
return {
text: response,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: 0,
},
};
},
doStream: async function* (options: any) {
for await (const chunk of chatStream(options.prompt, {
maxTokens: options.maxTokens,
temperature: options.temperature,
topP: options.topP,
})) {
yield {
type: "text-delta" as const,
textDelta: chunk,
};
}
yield {
type: "finish" as const,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: 0,
},
};
},
modelId,
provider: "local-qwen",
specificationVersion: "v1" as const,
defaultObjectGenerationMode: "json" as const,
}),
healthCheck,
};
}