Files
Jenkins CI 0e11f85031 release: v2.2.3 [2026-05-29 21:31:05]
### 新功能
- Function Call 填表开关下方新增公益站风险提示横幅:部分公益站会屏蔽 tools 参数,请确认支持情况避免被意外封禁
### 修复
- **Function Call 填表**:
  - 修复 ST 代理以 HTTP 200 + error body 形式返回错误、导致降级重试机制从未触发的问题
  - 修复思考模式模型(如 DeepSeek v4-flash)因 tool_choice 不兼容返回 Bad Request 后正确降级并重试
  - 重试时自动追加强制调用指令,防止思考模型绕过工具直接输出文本造成无效二次开销
- **超级记忆 / 翰林院**:
  - 修复 `getRagSettings()` 读写顶层路径而非嵌套路径,导致打开超级记忆面板后向量化、归档等开关在重载时被全默认值覆盖的问题
  - 修复自动归档失效问题
  - 修复归档管理器在同一事件中被三次触发的回归问题
  - 修复翰林院设置旧版迁移逻辑异常
2026-05-29 21:31:05 +08:00

430 lines
15 KiB
JavaScript
Raw Permalink 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';
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: profile.apiKey ?? '',
embeddingModel: profile.model,
batchSize: getSettings().retrieval?.batchSize ?? 5,
};
}
return getSettings().retrieval || {};
}
/**
* 获取 Rerank 配置,优先从 ragRerank 槽位 Profile 读取。
*/
export async function getRerankSettings() {
const profile = await getSlotProfile('ragRerank');
if (profile) {
const manualSettings = getSettings().rerank || {};
return {
url: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
top_n: manualSettings.top_n ?? 10,
apiMode: manualSettings.apiMode ?? 'custom',
};
}
return getSettings().rerank || {};
}
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(['测试连接']);
}