文件
PRA-only-demo/packages/rpa-core/src/capabilities/ai-service.ts
T

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