447 lines
21 KiB
JavaScript
447 lines
21 KiB
JavaScript
// js/api.js
|
||
|
||
// =====================
|
||
// API 相关工具函数与管理器
|
||
// =====================
|
||
|
||
// 从 ui.js 或其他模块导入所需的函数 (如果使用模块化)
|
||
// import { addProgressLog, showNotification } from './ui.js';
|
||
|
||
// ---------------------
|
||
// API 错误信息提取工具
|
||
// ---------------------
|
||
/**
|
||
* 统一从 API 响应中提取错误信息,便于调试和用户提示。
|
||
*
|
||
* 主要逻辑:
|
||
* 1. 尝试读取响应体文本。
|
||
* 2. 尝试将响应体文本解析为 JSON 对象。
|
||
* 3. 从 JSON 对象中提取常见的错误信息字段 (如 `error.message`, `message`, `detail`)。
|
||
* 4. 如果解析 JSON 失败或不是 JSON 格式,则直接使用响应体文本或 HTTP 状态信息。
|
||
* 5. 对最终的错误信息进行截断,以避免过长的信息导致 UI 问题。
|
||
*
|
||
* @param {Response} response - Fetch API 的 Response 对象。
|
||
* @param {string} defaultMessage - 当无法从响应中提取具体错误信息时使用的默认消息。
|
||
* @returns {Promise<string>} 提取并格式化后的错误信息字符串。
|
||
*/
|
||
async function getApiError(response, defaultMessage) {
|
||
let errorInfo = defaultMessage;
|
||
try {
|
||
const responseText = await response.text();
|
||
console.error('API Error Response Text:', responseText);
|
||
try {
|
||
// 尝试解析为 JSON 并提取常见错误字段
|
||
const jsonError = JSON.parse(responseText);
|
||
errorInfo = jsonError.error?.message || jsonError.message || jsonError.detail || JSON.stringify(jsonError);
|
||
} catch (e) {
|
||
// 不是 JSON,直接返回文本
|
||
errorInfo = responseText || `HTTP ${response.status} ${response.statusText}`;
|
||
}
|
||
} catch (e) {
|
||
errorInfo = `${defaultMessage} (HTTP ${response.status} ${response.statusText})`;
|
||
}
|
||
// 限制错误信息长度,避免 UI 崩溃
|
||
return errorInfo.substring(0, 300) + (errorInfo.length > 300 ? '...' : '');
|
||
}
|
||
|
||
// =====================
|
||
// Mistral API 相关函数
|
||
// =====================
|
||
|
||
/**
|
||
* 上传文件到 Mistral API。
|
||
* 该函数用于将本地文件发送到 Mistral 的文件服务,通常是进行 OCR 等操作的前置步骤。
|
||
*
|
||
* @param {File} fileToProcess - 需要上传的 File 对象。
|
||
* @param {string} mistralKey - Mistral API 密钥。
|
||
* @returns {Promise<string>} 上传成功后返回 Mistral 文件 ID。
|
||
* @throws {Error} 如果上传失败(例如网络错误、认证失败、API 返回错误),则抛出错误。
|
||
* 特别地,如果状态码为 401,会提示 API Key 无效。
|
||
*/
|
||
async function uploadToMistral(fileToProcess, mistralKey) {
|
||
const formData = new FormData();
|
||
formData.append('file', fileToProcess);
|
||
formData.append('purpose', 'ocr');
|
||
|
||
// 使用统一配置获取代理地址
|
||
const proxyUrl = (typeof window !== 'undefined' && window.ProxyConfig)
|
||
? window.ProxyConfig.getMistralUrl('/v1/files')
|
||
: 'http://localhost:3456/api/mistral/v1/files';
|
||
|
||
const response = await fetch(proxyUrl, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${mistralKey || 'sk-local-proxy'}` },
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorInfo = await getApiError(response, '文件上传失败');
|
||
if (response.status === 401) throw new Error(`Mistral API Key (...${mistralKey.slice(-4)}) 无效或未授权`);
|
||
throw new Error(`文件上传失败 (${response.status}): ${errorInfo}`);
|
||
}
|
||
|
||
const fileData = await response.json();
|
||
if (!fileData || !fileData.id) throw new Error('上传成功但未返回有效的文件ID');
|
||
return fileData.id;
|
||
}
|
||
|
||
/**
|
||
* 将文件上传到本地代理的 OSS 上传接口 (直接提供给大模型用)。
|
||
*
|
||
* @param {File|Blob} fileToProcess - 需要上传的 File 或 Blob 对象。
|
||
* @param {string} fileName - 文件名。
|
||
* @returns {Promise<string>} 上传成功后返回 OSS 的公网访问 URL。
|
||
*/
|
||
async function uploadFileToOssViaProxy(fileToProcess, fileName) {
|
||
const formData = new FormData();
|
||
formData.append('file', fileToProcess, fileName);
|
||
|
||
// 使用统一配置获取代理地址
|
||
const proxyUrl = (typeof window !== 'undefined' && window.ProxyConfig)
|
||
? window.ProxyConfig.getOssUploadUrl()
|
||
: (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||
? 'http://localhost:3456/api/upload/oss'
|
||
: '/api/upload/oss');
|
||
|
||
const response = await fetch(proxyUrl, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorInfo = await getApiError(response, 'OSS文件上传失败');
|
||
throw new Error(`OSS文件上传失败 (${response.status}): ${errorInfo}`);
|
||
}
|
||
|
||
const resultData = await response.json();
|
||
if (!resultData || !resultData.url) {
|
||
throw new Error('上传成功但未返回有效的URL');
|
||
}
|
||
return resultData.url;
|
||
}
|
||
|
||
/**
|
||
* 获取 Mistral 文件的签名 URL。
|
||
* 此 URL 用于授权后续的操作,例如在该文件上执行 OCR。
|
||
*
|
||
* @param {string} fileId - 已上传到 Mistral 的文件 ID。
|
||
* @param {string} mistralKey - Mistral API 密钥。
|
||
* @returns {Promise<string>} 获取到的签名 URL。
|
||
* @throws {Error} 如果获取签名 URL 失败(例如文件 ID 无效、认证失败),则抛出错误。
|
||
*/
|
||
async function getMistralSignedUrl(fileId, mistralKey) {
|
||
// 使用统一配置获取代理地址
|
||
const proxyUrl = (typeof window !== 'undefined' && window.ProxyConfig)
|
||
? window.ProxyConfig.getMistralUrl(`/v1/files/${fileId}/url?expiry=24`)
|
||
: `http://localhost:3456/api/mistral/v1/files/${fileId}/url?expiry=24`;
|
||
|
||
const response = await fetch(proxyUrl, {
|
||
method: 'GET',
|
||
headers: { 'Authorization': `Bearer ${mistralKey || 'sk-local-proxy'}`, 'Accept': 'application/json' }
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorInfo = await getApiError(response, '获取签名URL失败');
|
||
throw new Error(`获取签名URL失败 (${response.status}): ${errorInfo}`);
|
||
}
|
||
|
||
const urlData = await response.json();
|
||
if (!urlData || !urlData.url) throw new Error('获取的签名URL格式不正确');
|
||
return urlData.url;
|
||
}
|
||
|
||
/**
|
||
* 调用 Mistral OCR API 对指定文档进行文字识别。
|
||
*
|
||
* @param {string} signedUrl - 通过 `getMistralSignedUrl` 获取到的已签名文档 URL。
|
||
* @param {string} mistralKey - Mistral API 密钥。
|
||
* @returns {Promise<Object>} OCR 处理成功后返回的 JSON 对象,包含识别出的页面文本和结构信息。
|
||
* @throws {Error} 如果 OCR 处理失败(例如 URL 无效、API Key 错误、处理超时),则抛出错误。
|
||
*/
|
||
async function callMistralOcr(signedUrl, mistralKey) {
|
||
// 使用统一配置获取代理地址
|
||
const proxyUrl = (typeof window !== 'undefined' && window.ProxyConfig)
|
||
? window.ProxyConfig.getMistralUrl('/v1/ocr')
|
||
: 'http://localhost:3456/api/mistral/v1/ocr';
|
||
|
||
const response = await fetch(proxyUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${mistralKey || 'sk-local-proxy'}`,
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
model: 'mistral-ocr-latest',
|
||
document: { type: "document_url", document_url: signedUrl },
|
||
include_image_base64: true
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorInfo = await getApiError(response, 'OCR处理失败');
|
||
throw new Error(`OCR处理失败 (${response.status}): ${errorInfo}`);
|
||
}
|
||
|
||
const ocrData = await response.json();
|
||
if (!ocrData || !ocrData.pages) throw new Error('OCR处理成功但返回的数据格式不正确');
|
||
return ocrData;
|
||
}
|
||
|
||
/**
|
||
* 删除已上传到 Mistral 的文件,以释放云端存储空间。
|
||
* 此函数在执行删除操作时,如果遇到失败,仅会在控制台打印警告,不会向上抛出错误中断主流程。
|
||
*
|
||
* @param {string} fileId - 需要删除的 Mistral 文件 ID。
|
||
* @param {string} apiKey - Mistral API 密钥。
|
||
* @returns {Promise<void>} 无明确返回值。
|
||
*/
|
||
async function deleteMistralFile(fileId, apiKey) {
|
||
if (!fileId) return; // 参数校验
|
||
|
||
// 使用统一配置获取代理地址
|
||
const deleteUrl = (typeof window !== 'undefined' && window.ProxyConfig)
|
||
? window.ProxyConfig.getMistralUrl(`/v1/files/${fileId}`)
|
||
: `http://localhost:3456/api/mistral/v1/files/${fileId}`;
|
||
|
||
try {
|
||
const response = await fetch(deleteUrl, {
|
||
method: 'DELETE',
|
||
headers: { 'Authorization': `Bearer ${apiKey || 'sk-local-proxy'}` }
|
||
});
|
||
if (!response.ok) {
|
||
const errorInfo = await getApiError(response, '文件删除失败');
|
||
console.warn(`Failed to delete Mistral file ${fileId}: ${response.status} - ${errorInfo}`);
|
||
// 只记录警告,不中断主流程
|
||
}
|
||
// 可选: 检查响应确认删除成功
|
||
// const data = await response.json();
|
||
// console.log('Delete response:', data);
|
||
} catch (error) {
|
||
console.warn(`Error during Mistral file deletion ${fileId}:`, error);
|
||
// 同样不向上抛出
|
||
}
|
||
}
|
||
|
||
// =====================
|
||
// 翻译 API 相关函数
|
||
// =====================
|
||
|
||
/**
|
||
* 封装实际的翻译 API 调用逻辑。
|
||
* 此函数根据传入的配置对象 (`effectiveConfig`) 和请求体 (`requestBody`),
|
||
* 向指定的翻译 API 端点发送 POST 请求,并处理响应。
|
||
*
|
||
* 主要逻辑:
|
||
* 1. 参数校验:确保 `effectiveConfig` 包含 `endpoint`。
|
||
* 2. 头部设置:确保 `headers` 存在,并设置 `Accept` 头部优先接受 JSON。
|
||
* 3. 发起请求:使用 `fetch` API 发送 POST 请求。
|
||
* 4. 错误处理:
|
||
* - 如果响应不成功 (`!response.ok`),调用 `getApiError` 提取错误信息并抛出。
|
||
* - 检查响应的 `Content-Type` 是否为 JSON。如果不是,则抛出错误,提示检查 API Endpoint 配置。
|
||
* 5. 结果提取:
|
||
* - 将响应体解析为 JSON。
|
||
* - 使用 `effectiveConfig.responseExtractor` (如果提供) 从 JSON 数据中提取翻译后的文本内容。
|
||
* 如果未提供提取器,则使用默认提取逻辑 (通常适用于 OpenAI 格式的响应)。
|
||
* - 如果无法提取到内容,则抛出错误。
|
||
* 6. 返回结果:返回提取并去除首尾空格的翻译文本。
|
||
*
|
||
* @param {Object} effectiveConfig - 生效的 API 配置对象。
|
||
* 必须包含 `endpoint` (string): API 请求的完整 URL。
|
||
* 可选包含 `headers` (Object): HTTP 请求头部。
|
||
* 可选包含 `responseExtractor` (function): 从 API 响应 JSON 中提取翻译结果的函数。
|
||
* @param {Object} requestBody - 发送给翻译 API 的请求体 JSON 对象。
|
||
* @returns {Promise<string>} 翻译后的文本内容。
|
||
* @throws {Error} 如果 API 调用失败、响应格式不正确、或无法提取翻译内容,则抛出错误。
|
||
*/
|
||
async function callTranslationApi(effectiveConfig, requestBody) {
|
||
// 添加防御性检查
|
||
if (!effectiveConfig || !effectiveConfig.endpoint) {
|
||
throw new Error('无效的 API 配置: 缺少必要的端点信息');
|
||
}
|
||
|
||
if (!effectiveConfig.headers) {
|
||
effectiveConfig.headers = { 'Content-Type': 'application/json' };
|
||
}
|
||
// 确保 Accept header 存在并优先 application/json
|
||
effectiveConfig.headers['Accept'] = 'application/json, text/plain, */*';
|
||
|
||
const response = await fetch(effectiveConfig.endpoint, {
|
||
method: 'POST',
|
||
headers: effectiveConfig.headers,
|
||
body: JSON.stringify(requestBody)
|
||
});
|
||
|
||
const contentType = response.headers.get('content-type');
|
||
if (!response.ok) {
|
||
const errorText = await getApiError(response, '翻译API返回错误');
|
||
// 包含状态码和部分错误文本,更易调试
|
||
throw new Error(`翻译 API 错误 (${response.status}): ${errorText}`);
|
||
}
|
||
|
||
// 检查 Content-Type 是否为 JSON
|
||
if (!contentType || !contentType.includes('application/json')) {
|
||
const responseText = await response.text();
|
||
console.error('Translation API did not return JSON. Response:', responseText.substring(0, 500)); // Log first 500 chars
|
||
throw new Error(`翻译 API 未返回有效的 JSON 响应。收到的 Content-Type: ${contentType}. 响应内容可能为 HTML 或其他格式。请检查 API Endpoint 配置。`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
// 通过配置的 responseExtractor 提取翻译内容
|
||
const extractor = effectiveConfig.responseExtractor || (d => d?.choices?.[0]?.message?.content);
|
||
const translatedContent = extractor(data);
|
||
|
||
if (translatedContent === null || translatedContent === undefined) {
|
||
console.error(`Failed to extract translation from response:`, data);
|
||
throw new Error('无法从 API 响应中提取翻译内容');
|
||
}
|
||
|
||
return translatedContent.trim();
|
||
}
|
||
|
||
// 辅助函数:构建自定义 API 配置 (添加 bodyBuilder 参数)
|
||
// 注意:此函数与 js/process/translation.js 中的 buildCustomApiConfig 功能类似,
|
||
// 未来可以考虑合并或共享,但目前保持独立,以明确 api.js 的职责是纯粹的API交互。
|
||
// (此函数在当前版本中可能未被直接调用或功能已简化,因为 testModelKey 现在依赖 translateMarkdown)
|
||
/**
|
||
* [测试用/可能已部分废弃] 构建用于测试的自定义 API 配置。
|
||
* 此函数旨在为 `testModelKey` 或类似测试场景创建一个简化的 API 配置对象。
|
||
* 在当前实现中,由于 `testModelKey` 直接使用 `translateMarkdown`,此函数的完整构建逻辑可能已被省略或不再活跃。
|
||
* 如果需要让 `testModelKey` 独立进行 API 调用,则需要在此处完整实现配置构建逻辑。
|
||
*
|
||
* @param {string} key - API 密钥。
|
||
* @param {string} customApiEndpoint - 自定义 API 的端点 URL。
|
||
* @param {string} customModelId - 自定义模型的 ID。
|
||
* @param {string} customRequestFormat - 自定义请求的格式 (如 'openai', 'anthropic', 'gemini')。
|
||
* @param {number} [temperature] - (可选) 模型温度参数。
|
||
* @param {number} [max_tokens] - (可选) 最大 token 数。
|
||
* @returns {Object} 一个包含 `endpoint`, `modelName`, `headers` 的基础配置对象。
|
||
* 如果由此函数直接支持 `callTranslationApi`,则还应包含 `bodyBuilder` 和 `responseExtractor`。
|
||
*/
|
||
function buildCustomApiConfigForTest(key, customApiEndpoint, customModelId, customRequestFormat, temperature, max_tokens) {
|
||
let apiEndpoint = customApiEndpoint;
|
||
if (typeof window.modelDetector !== 'undefined') {
|
||
const fullEndpoint = window.modelDetector.getFullApiEndpoint();
|
||
if (fullEndpoint) apiEndpoint = fullEndpoint;
|
||
}
|
||
// ... (此处省略与 translation.js 中类似的具体格式构建逻辑,
|
||
// 因为 testModelKey 直接调用 translateMarkdown, 而 translateMarkdown 内部会构建这些)
|
||
// 这个函数如果仅由 testModelKey 的旧版间接使用,可能不再需要细节实现。
|
||
// 如果 testModelKey 要独立实现API调用,则这里需要完整实现。
|
||
// 当前 testModelKey 直接使用 translateMarkdown,所以此函数可能不再被直接调用。
|
||
return {
|
||
endpoint: apiEndpoint,
|
||
modelName: customModelId,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
// bodyBuilder and responseExtractor would be set here if callTranslationApi was used directly by testModelKey
|
||
};
|
||
}
|
||
|
||
|
||
// =====================
|
||
// Key 测试函数
|
||
// =====================
|
||
|
||
/**
|
||
* 测试指定模型及其 API Key 的可用性("测活")。
|
||
* 此函数通过尝试使用给定的模型和 Key 进行一次小规模的翻译请求来验证其有效性。
|
||
* 它依赖于全局或 `processModule` 下可用的 `translateMarkdown` 函数来执行实际的 API 调用。
|
||
*
|
||
* 主要逻辑:
|
||
* 1. 检查 `translateMarkdown` 函数是否可用,如果不可用则抛出错误。
|
||
* 2. 构造一个简短的测试文本和目标语言。
|
||
* 3. 根据 `modelName` 判断是预设模型还是自定义模型:
|
||
* - 如果 `modelName` 以 `custom_source_` 开头,则视为自定义模型,并将 `modelConfig` 作为配置传递给 `translateMarkdown`。
|
||
* - 否则,视为预设模型,不传递 `modelConfig` 的详细内容。
|
||
* 4. 调用 `translateMarkdown` 发起测试翻译。
|
||
* - 为 `translateMarkdown` 传递必要的参数,包括测试文本、目标语言、模型类型、API Key、以及针对性的日志上下文和提示(或禁用它们)。
|
||
* - 特别注意,对于自定义模型,会将 `modelConfig` 参数 (即 `testModelKey` 的第三个参数) 传递给 `translateMarkdown`。
|
||
* 5. 结果判断:如果 `translateMarkdown` 返回一个非空字符串,则认为 Key 有效。
|
||
* 6. 错误处理:捕获 `translateMarkdown` 可能抛出的任何错误,并将其视为 Key 无效或配置错误。
|
||
*
|
||
* @param {string} modelName - 要测试的模型名称。
|
||
* - 对于预设模型,例如 'mistral', 'deepseek'。
|
||
* - 对于自定义源站点模型,格式为 'custom_source_xxxx',其中 xxxx 是源站点 ID。
|
||
* @param {string} keyValue - 要测试的 API Key。
|
||
* @param {Object} modelConfig - 模型的配置对象。
|
||
* - 对于 `modelName` 为 'custom' 或 'custom_source_...' 的情况,此对象包含自定义 API 的详细信息
|
||
* (如 `apiEndpoint`/`apiBaseUrl`, `modelId`, `requestFormat`, `temperature`, `max_tokens` 等)。
|
||
* 这些信息会传递给 `translateMarkdown` 内部的 `buildCustomApiConfig`。
|
||
* - 对于预设模型,此参数可能为空或不被 `translateMarkdown` 的预设模型路径直接使用(因为它会自行查找预设配置)。
|
||
* @returns {Promise<boolean>} 如果 Key 测试成功(API 调用成功并返回了内容),则返回 `true`;否则返回 `false`。
|
||
*/
|
||
async function testModelKey(modelName, keyValue, modelConfig) {
|
||
try {
|
||
if (typeof window.translateMarkdown !== 'function') {
|
||
// 尝试从 processModule 加载 (如果项目结构如此)
|
||
if (typeof processModule !== 'undefined' && typeof processModule.translateMarkdown === 'function') {
|
||
window.translateMarkdown = processModule.translateMarkdown;
|
||
} else {
|
||
console.error('translateMarkdown function is not available globally or on processModule.');
|
||
throw new Error('translateMarkdown 未加载,无法测试Key');
|
||
}
|
||
}
|
||
|
||
// 构造最小请求内容
|
||
const testText = 'Hello'; // 使用更短的文本进行测试
|
||
const targetLang = 'zh'; // 使用语言代码,假设 translateMarkdown 内部能处理
|
||
|
||
let effectiveModelTypeForTranslateMarkdown = modelName;
|
||
// modelConfig is already the specific source site config when modelName starts with 'custom_source_'
|
||
// or it's the general config for preset models.
|
||
|
||
if (modelName.startsWith('custom_source_')) {
|
||
effectiveModelTypeForTranslateMarkdown = 'custom';
|
||
// modelConfig (the 3rd argument to testModelKey) is already the correct source site config object
|
||
// passed from ui.js -> handleTestKey, so no change needed for it here for this case.
|
||
}
|
||
|
||
// 调用 translateMarkdown 进行测试。
|
||
// 它内部会根据 modelName 和 modelConfig (特别是对custom模型) 来构建实际的API请求。
|
||
let result;
|
||
if (effectiveModelTypeForTranslateMarkdown === 'custom') {
|
||
// custom 模型,传 modelConfig
|
||
result = await window.translateMarkdown(
|
||
testText,
|
||
targetLang,
|
||
effectiveModelTypeForTranslateMarkdown,
|
||
keyValue,
|
||
modelConfig,
|
||
'[KeyTest]',
|
||
null,
|
||
null,
|
||
false,
|
||
false
|
||
);
|
||
} else {
|
||
// 预设模型,不传 modelConfig
|
||
result = await window.translateMarkdown(
|
||
testText,
|
||
targetLang,
|
||
effectiveModelTypeForTranslateMarkdown,
|
||
keyValue,
|
||
'[KeyTest]',
|
||
null,
|
||
null,
|
||
false,
|
||
false
|
||
);
|
||
}
|
||
|
||
// 简单检查是否有字符串结果返回
|
||
if (typeof result === 'string' && result.length > 0) {
|
||
return true; // Key is considered valid
|
||
}
|
||
console.warn(`Key test for ${modelName} returned non-string or empty result:`, result);
|
||
return false; // Key might be valid but API call didn't behave as expected for translation
|
||
|
||
} catch (e) {
|
||
console.error(`Key test failed for model ${modelName} (key: ...${keyValue.slice(-4)}):`, e.message);
|
||
return false; // Error occurred, key is invalid or configuration is wrong
|
||
}
|
||
}
|
||
|
||
|
||
// --- 导出 API 相关函数 ---
|
||
// (如果使用模块化)
|
||
// export { uploadToMistral, getMistralSignedUrl, callMistralOcr, deleteMistralFile, callTranslationApi, getApiError, testModelKey, uploadFileToOssViaProxy };
|