107 lines
4.4 KiB
TypeScript
107 lines
4.4 KiB
TypeScript
import { getHostBridge } from "../engine/host-bridge";
|
||
import { RuntimeError } from "../engine/types";
|
||
|
||
export type ChatContentPart =
|
||
| { type: "text"; text: string }
|
||
| { type: "image_url"; image_url: { url: string } };
|
||
|
||
export interface ChatMessage {
|
||
role: "system" | "user" | "assistant";
|
||
content: string | ChatContentPart[];
|
||
}
|
||
|
||
export interface ResolvedAiConfig {
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
model: string;
|
||
visionModel: string;
|
||
}
|
||
|
||
export function getAiConfig(): ResolvedAiConfig {
|
||
const bridge = getHostBridge();
|
||
// 宿主桥未注入(vitest / CLI 未配置)时返回空配置,requireAiConfig 统一抛 ai_not_configured
|
||
const config = bridge ? bridge.loadAiConfig() : { baseUrl: "", apiKey: "", model: "", visionModel: "" };
|
||
return { ...config, visionModel: config.visionModel || config.model };
|
||
}
|
||
|
||
export function requireAiConfig(options: { vision?: boolean } = {}): ResolvedAiConfig {
|
||
const config = getAiConfig();
|
||
if (!config.baseUrl || !config.model || (options.vision && !config.visionModel)) {
|
||
throw new RuntimeError("ai_not_configured", "AI 服务未配置", {
|
||
recommendation: options.vision
|
||
? "到 设置 → AI 服务 填写 Base URL 与多模态视觉模型(兼容 OpenAI 协议)"
|
||
: "到 设置 → AI 服务 填写 Base URL 与模型名(兼容 OpenAI 协议)"
|
||
});
|
||
}
|
||
return config;
|
||
}
|
||
|
||
export interface ChatCompletionOptions {
|
||
temperature?: number;
|
||
vision?: boolean;
|
||
/** 交互式请求(用户正在等待,如 AI 拾取):默认短超时 45s、最多 2 次,超时不重试 */
|
||
interactive?: boolean;
|
||
/** 单次请求超时毫秒数;显式指定后覆盖交互式/后台默认值 */
|
||
timeoutMs?: number;
|
||
/** 最大尝试次数;显式指定后覆盖交互式/后台默认值 */
|
||
maxAttempts?: number;
|
||
}
|
||
|
||
export async function chatCompletion(messages: ChatMessage[], options: ChatCompletionOptions = {}): Promise<string> {
|
||
const config = requireAiConfig({ vision: options.vision });
|
||
const base = config.baseUrl.replace(/\/+$/, "");
|
||
const interactive = options.interactive === true;
|
||
const timeoutMs = Math.max(1000, Math.round(options.timeoutMs ?? (interactive ? 45000 : 170000)));
|
||
const maxAttempts = Math.max(1, Math.round(options.maxAttempts ?? (interactive ? 2 : 3)));
|
||
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||
let response: Response;
|
||
try {
|
||
response = await fetch(base + "/chat/completions", {
|
||
signal: AbortSignal.timeout(timeoutMs),
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
|
||
body: JSON.stringify({
|
||
model: options.vision ? config.visionModel : config.model,
|
||
messages,
|
||
temperature: options.temperature ?? 0.3
|
||
})
|
||
});
|
||
} catch (error) {
|
||
// 超时(AbortSignal.timeout)不重试:重发只会成倍拉长等待
|
||
if ((error as { name?: string }).name === "TimeoutError") {
|
||
throw new RuntimeError("ai_request_timeout", `AI 请求超时(${timeoutMs}ms),请检查网络或稍后重试`);
|
||
}
|
||
if (attempt === maxAttempts) throw error;
|
||
await new Promise(resolve => setTimeout(resolve, 1200 * attempt));
|
||
continue;
|
||
}
|
||
|
||
if (response.ok) {
|
||
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
||
return data.choices?.[0]?.message?.content ?? "";
|
||
}
|
||
|
||
const detail = (await response.text()).slice(0, 300);
|
||
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
|
||
if (retryable && attempt < maxAttempts) {
|
||
const retryAfter = Number(response.headers.get("retry-after"));
|
||
const fallbackDelay = 1400 * attempt;
|
||
const delay = Number.isFinite(retryAfter) && retryAfter >= 0 && retryAfter <= 15
|
||
? retryAfter * 1000
|
||
: fallbackDelay;
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
continue;
|
||
}
|
||
|
||
if (response.status === 429) {
|
||
const suffix = detail ? " 详情:" + detail.slice(0, 180) : "";
|
||
throw new RuntimeError("ai_rate_limited", "AI 服务限流或配额不足(HTTP 429),请稍后重试或更换可用视觉模型。" + suffix);
|
||
}
|
||
|
||
throw new RuntimeError("ai_request_failed", "AI 请求失败(HTTP " + response.status + "):" + detail);
|
||
}
|
||
|
||
throw new RuntimeError("ai_request_failed", "AI 请求失败:已重试仍未成功");
|
||
}
|