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