107 行
4.4 KiB
TypeScript
107 行
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 请求失败:已重试仍未成功");
|
|
}
|