Files
ST-Amily2-Chat-Optimisation/core/rag-api.js
Jenkins CI 1a4a10d42d release: v2.2.5 [2026-06-10 12:41:11]
### 修复
- **翰林院(RAG)API Key 污染**:
  - 修复 `saveSettingsFromUI` 无差别遍历翰林院面板内全部 `[data-setting-key]` 输入(包含被 `profile-sync` 接管隐藏的字段),导致掩码占位符 `••••••••` 被当作真值写回 `settings.rerank.apiKey` / `settings.retrieval.apiKey`,URL / model 也被 Profile 值覆盖到 legacy 字段。修复后会跳过祖先带 `data-profile-hidden` 的输入
  - `getRerankSettings` / `getEmbedRetrievalSettings` 同时加入防御性还原:识别历史污染留下的 `••••••••` 时归为空字符串,避免取消 Profile 分配后实际请求带占位符 token 被 401
---
2026-06-10 12:41:11 +08:00

440 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
import {
extension_settings
} from '/scripts/extensions.js';
import {
buildGoogleEmbeddingRequest,
parseGoogleEmbeddingResponse,
buildGoogleEmbeddingApiUrl
} from './utils/googleAdapter.js';
import { getSlotProfile } from './api/api-resolver.js';
import { extensionName } from '../utils/settings.js';
const MODULE_NAME = 'hanlinyuan-rag-core';
const GOOGLE_API_BASE_URL = 'https://generativelanguage.googleapis.com';
// profile-sync 在 UI 隐藏字段时填入的掩码占位符const MASKED_KEY = '••••••••')。
// 历史上 saveSettingsFromUI 曾把这个占位符写回 settings.{rerank,retrieval}.apiKey
// 导致取消 Profile 分配后实际请求带占位符 token 被 401。这里做防御性还原。
const PROFILE_MASKED_KEY = '••••••••';
function sanitizeMaskedKey(key) {
return key === PROFILE_MASKED_KEY ? '' : key;
}
function getSettings() {
const root = extension_settings[extensionName];
const nested = root && root[MODULE_NAME];
if (nested) return nested;
// 读侧兼容:若迁移尚未触发(极早期调用),回退至旧顶层位置,避免空配置。
const legacy = extension_settings[MODULE_NAME];
if (legacy) return legacy;
console.error('[翰林院-API] 无法获取设置API调用可能失败。');
return { retrieval: {}, rerank: {} };
}
/**
* 获取 Embedding 配置,优先从 ragEmbed 槽位 Profile 读取。
* Profile 存在时映射为 custom endpoint覆盖旧 settings。
*/
export async function getEmbedRetrievalSettings() {
const profile = await getSlotProfile('ragEmbed');
if (profile) {
return {
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
customApiUrl: profile.apiUrl,
apiKey: sanitizeMaskedKey(profile.apiKey ?? ''),
embeddingModel: profile.model,
batchSize: getSettings().retrieval?.batchSize ?? 5,
};
}
const fallback = getSettings().retrieval || {};
return { ...fallback, apiKey: sanitizeMaskedKey(fallback.apiKey ?? '') };
}
/**
* 获取 Rerank 配置,优先从 ragRerank 槽位 Profile 读取。
*/
export async function getRerankSettings() {
const profile = await getSlotProfile('ragRerank');
if (profile) {
const manualSettings = getSettings().rerank || {};
return {
url: profile.apiUrl,
apiKey: sanitizeMaskedKey(profile.apiKey ?? ''),
model: profile.model,
top_n: manualSettings.top_n ?? 10,
apiMode: manualSettings.apiMode ?? 'custom',
};
}
const fallback = getSettings().rerank || {};
return { ...fallback, apiKey: sanitizeMaskedKey(fallback.apiKey ?? '') };
}
function normalizeApiResponse(responseData) {
let data = responseData;
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch (e) {
console.error(`[翰林院-API] API响应JSON解析失败:`, e);
return { error: { message: 'Invalid JSON response' } };
}
}
if (data && typeof data.data === 'object' && data.data !== null && !Array.isArray(data.data)) {
if (Object.hasOwn(data.data, 'data')) {
data = data.data;
}
}
if (data && data.data) { // for /v1/models
return { data: data.data };
}
if (data && data.error) {
return { error: data.error };
}
return data;
}
export function getSanitizedBaseUrl(rawApiUrl) {
let baseUrl = rawApiUrl.trim();
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
if (baseUrl.endsWith('/v1')) {
baseUrl = baseUrl.slice(0, -3);
}
// 兼容处理 /embeddings
if (baseUrl.endsWith('/embeddings')) {
baseUrl = baseUrl.slice(0, -11);
}
return baseUrl;
}
export async function fetchEmbeddingModels(overrideSettings = null) {
const settings = overrideSettings || await getEmbedRetrievalSettings();
const { apiEndpoint, apiKey, customApiUrl } = settings;
let modelsUrl;
let headers = {};
let responseParser;
switch (apiEndpoint) {
case 'google_direct':
if (!apiKey) throw new Error("Google直连模式需要API Key。");
const fetchGoogleModels = async (version) => {
const url = `${GOOGLE_API_BASE_URL}/${version}/models`;
console.log(`[翰林院] 正在从 Google API (${version}) 获取模型列表: ${url}`);
const response = await fetch(url, {
headers: { 'x-goog-api-key': apiKey },
});
if (!response.ok) {
console.warn(`获取 Google API (${version}) 模型列表失败: ${response.status}`);
return [];
}
const json = await response.json();
if (!json.models || !Array.isArray(json.models)) {
return [];
}
return json.models
.filter(model => model.supportedGenerationMethods?.includes('embedContent') || model.supportedGenerationMethods?.includes('batchEmbedContents'))
.map(model => model.name.replace('models/', ''));
};
const [v1Models, v1betaModels] = await Promise.all([
fetchGoogleModels('v1'),
fetchGoogleModels('v1beta')
]);
const allModels = [...new Set([...v1Models, ...v1betaModels])].sort();
return allModels;
case 'custom':
if (!customApiUrl) throw new Error("自定义模式需要API URL。");
if (!apiKey) throw new Error("自定义模式需要API Key。");
const customBaseUrl = getSanitizedBaseUrl(customApiUrl);
modelsUrl = `${customBaseUrl}/v1/models`;
headers = getApiHeaders(settings); // 这些模式需要认证头
console.log(`[翰林院] 正在从 ${modelsUrl} 获取模型列表 (需要认证)...`);
responseParser = (json) => {
if (!json.data || !Array.isArray(json.data)) {
throw new Error("模型API的响应格式无效: 未找到 'data' 数组。");
}
return json.data.map(m => m.id).sort();
};
// 对于custom模式需要继续执行下面的fetch
break;
case 'local_proxy':
default:
if (!customApiUrl) throw new Error("本地代理模式需要API URL。");
const proxyBaseUrl = getSanitizedBaseUrl(customApiUrl);
modelsUrl = `${proxyBaseUrl}/v1/models`;
// 本地代理通常不需要认证头
headers = { 'Content-Type': 'application/json' };
console.log(`[翰林院] 正在从 ${modelsUrl} 获取模型列表 (无需认证)...`);
responseParser = (json) => {
if (!json.data || !Array.isArray(json.data)) {
throw new Error("模型API的响应格式无效: 未找到 'data' 数组。");
}
return json.data.map(m => m.id).sort();
};
break;
}
// 注意st_backend case 和 google_direct case 已经提前返回,不会执行到这里
if (!modelsUrl) {
// 这个分支理论上不应该被执行因为所有case都处理了
throw new Error('无法确定获取模型的有效路径。');
}
const response = await fetch(modelsUrl, {
method: 'GET',
headers: headers,
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`获取模型列表失败 (${response.status}): ${errorBody}`);
}
const data = await response.json();
return responseParser(data);
}
export function getRerankBaseUrl(rawApiUrl) {
let baseUrl = rawApiUrl.trim();
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1);
}
if (baseUrl.endsWith('/v1')) {
baseUrl = baseUrl.slice(0, -3);
}
// 兼容处理 /rerank
if (baseUrl.endsWith('/rerank')) {
baseUrl = baseUrl.slice(0, -7);
}
return baseUrl;
}
export async function fetchRerankModels() {
const settings = await getRerankSettings();
const { url, apiKey, apiMode = 'custom' } = settings;
if (!url) {
throw new Error("Rerank API URL 未提供。");
}
if (apiMode === 'custom' && !apiKey) {
throw new Error("自定义模式下Rerank API Key 未提供。");
}
const baseUrl = getRerankBaseUrl(url);
const modelsUrl = `${baseUrl}/v1/models`;
const headers = { 'Content-Type': 'application/json' };
if (apiMode === 'custom') {
headers['Authorization'] = `Bearer ${apiKey}`;
}
console.log(`[翰林院-Rerank] 正在从 ${modelsUrl} 获取模型列表 (模式: ${apiMode})...`);
const response = await fetch(modelsUrl, {
method: 'GET',
headers: headers,
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`获取Rerank模型列表失败 (${response.status}): ${errorBody}`);
}
const data = await response.json();
if (!data.data || !Array.isArray(data.data)) {
throw new Error("Rerank模型API的响应格式无效: 未找到 'data' 数组。");
}
return data.data
.map(m => m.id)
.sort();
}
export async function executeRerank(query, documents, rerankSettings = null) {
const resolved = rerankSettings || await getRerankSettings();
const { url, apiKey, model, top_n, apiMode = 'custom' } = resolved;
if (!url) throw new Error("Rerank API URL 未提供。");
if (apiMode === 'custom' && !apiKey) throw new Error("自定义模式下Rerank API Key 未提供。");
const baseUrl = getRerankBaseUrl(url);
const rerankUrl = `${baseUrl}/v1/rerank`;
const headers = { 'Content-Type': 'application/json' };
if (apiMode === 'custom') {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const body = JSON.stringify({
query: query,
documents: documents,
model: model,
top_n: top_n,
});
console.log(`[翰林院-Rerank] 正在向 ${rerankUrl} 发送请求 (模式: ${apiMode})...`);
const response = await fetch(rerankUrl, {
method: 'POST',
headers: headers,
body: body,
});
if (!response.ok) {
throw new Error(`Rerank API 请求失败 (${response.status}): ${await response.text()}`);
}
return await response.json();
}
export function getApiEndpointUrl(raw = false, overrideRetrieval = null) {
const {
apiEndpoint,
customApiUrl
} = overrideRetrieval || getSettings().retrieval;
let url;
switch (apiEndpoint) {
case 'openai':
url = 'https://api.openai.com';
break;
case 'azure':
case 'custom':
url = customApiUrl;
break;
default:
url = 'https://api.openai.com';
break;
}
if (raw) {
return url;
}
// 默认情况下,返回拼接好 /v1/embeddings 的完整URL
return getSanitizedBaseUrl(url) + '/v1/embeddings';
}
export function getApiHeaders(overrideRetrieval = null) {
const headers = {
'Content-Type': 'application/json'
};
const {
apiKey,
apiEndpoint
} = overrideRetrieval || getSettings().retrieval;
switch (apiEndpoint) {
case 'openai':
case 'custom':
headers['Authorization'] = `Bearer ${apiKey}`;
break;
case 'azure':
headers['api-key'] = apiKey;
break;
}
return headers;
}
export async function getEmbeddings(texts, signal = null) {
const settings = await getEmbedRetrievalSettings();
const { apiEndpoint, customApiUrl, apiKey, embeddingModel, batchSize = 5 } = settings;
const allEmbeddings = [];
for (let i = 0; i < texts.length; i += batchSize) {
if (signal?.aborted) throw new Error('AbortError');
const batch = texts.slice(i, i + batchSize);
let batchEmbeddings = [];
switch (apiEndpoint) {
case 'google_direct':
console.log('[翰林院-API] 使用Google直连模式获取向量。');
if (!apiKey) throw new Error('Google直连模式需要API Key。');
// 使用适配器构建URL和请求体Key 通过 x-goog-api-key 头传递避免 URL 泄露
const googleUrl = buildGoogleEmbeddingApiUrl(GOOGLE_API_BASE_URL, embeddingModel);
const googleBody = buildGoogleEmbeddingRequest(batch, embeddingModel);
console.log(`[翰林院-API] 发送到 Google API 的请求 URL: ${googleUrl}`);
console.log(`[翰林院-API] 发送到 Google API 的请求体:`, JSON.stringify(googleBody, null, 2));
const googleResponse = await fetch(googleUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify(googleBody),
signal: signal,
});
if (!googleResponse.ok) {
const errorText = await googleResponse.text();
console.error(`[翰林院-API] Google API 错误响应: ${errorText}`);
throw new Error(`Google API Error: ${googleResponse.status} ${errorText}`);
}
const googleData = await googleResponse.json();
console.log(`[翰林院-API] 从 Google API 收到的响应:`, JSON.stringify(googleData, null, 2));
// 使用适配器解析响应
batchEmbeddings = parseGoogleEmbeddingResponse(googleData, batch);
break;
case 'custom':
case 'local_proxy':
default:
console.log(`[翰林院-API] 使用前端直连模式 (${apiEndpoint}) 获取向量。`);
if (!apiKey && apiEndpoint === 'custom') {
// 本地代理可以没有key但自定义通常需要
// throw new Error('自定义模式需要API Key。');
}
const url = getApiEndpointUrl(false, settings); // 使用已解析的 settings
const headers = getApiHeaders(settings); // 使用已解析的 settings
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
input: batch,
model: embeddingModel
}),
signal: signal,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`神力获取失败 ${response.status}: ${errorText}`);
}
const result = await response.json();
if (result.data && Array.isArray(result.data)) {
batchEmbeddings = result.data.map(item => item.embedding);
} else {
throw new Error('API返回的向量数据格式不正确。');
}
break;
}
if (batchEmbeddings.length !== batch.length) {
throw new Error('获取到的向量数量与发送的文本数量不匹配。');
}
allEmbeddings.push(...batchEmbeddings);
// 避免速率限制
if (i + batchSize < texts.length) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
return allEmbeddings;
}
export async function testApiConnection() {
await getEmbeddings(['测试连接']);
}