mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-13 20:55:50 +00:00
### 新功能 - **翰林院向量化质量升级**: - **边界感知切块**:替换四个来源(聊天记录/小说/世界书/手动)的纯字符硬切——优先在段落边界断开,其次句末标点(含中文引号闭合),极端长串才硬切;句子/对话不再被拦腰截断,embedding 质量同步受益。仅影响新录入,已有向量无需重建 - **注入时序重排**:检索结果注入提示词前按时序重排(聊天记录按楼层、小说按卷/章/节——中文数字章节号可解析),rerank 只决定"选哪些块",不再决定呈现顺序;修复"不打不相识的剧情之后紧跟关系亲密"这类因按相关度排序导致的认知时间错乱 - **断层提示**:聊天记录相邻块楼层跳跃时自动插入"与上文相隔约 N 楼,并非连续发生"提示行,消除中间剧情缺失造成的割裂感 - **时间标识**:新录入的聊天记录块在来源标识中带上消息发送时间(ST 向量存储不持久化元数据,时间必须写入块文本才能在检索后取回;旧格式块兼容解析) - **记忆块工作流(memory-blocks)**:剧情优化新增"自定义记忆块"体系——占位符驱动的并发工作流框架 - 在剧情优化面板「匹配替换 (sulv)」下方可增删自定义块:每个块定义一个占位符,执行剧情优化时主/拦截提示词中的占位符会被块的产出替换 - **静态块**:直接输出固定内容;**AI 调用块**:用所选 API 功能槽独立请求一次,把回复(或其中指定 `<标签>` 的内容)作为替换值 - 原有 sulv1-4 速率占位符迁入同一框架,行为与旧版逐字节一致 - 块定义为纯 JSON、随设置持久化,为后续导入导出与战斗系统接入预留扩展点 - 框架层新增**顺序拼接式 Chain**(`composeChain`):与占位符替换并列的第二种组合范式——同链的块并发执行后按 `order` 排序、以 `separator` 拼接并可选 `header/footer` 包裹,产出一个完整注入块;为记忆注入合成块与战斗系统"底部战报块"预留的承载结构,本版本暂无 UI 入口 - **API 连接配置**: - 角色世界书(cwb)与一键生卡(autoCharCard)纳入旧配置自动迁移:老用户首次加载会把旧 URL / Key / 模型自动迁移为连接配置并分配槽位(一键生卡仅在规划者与执行者配置一致或规划者为空时迁移,避免悄悄改变行为) - **profile 已分配时参数控件 informational 化**:主面板 / 并发剧情优化 / 角色世界书 / 术语表的温度、maxTokens 控件在槽位分配 profile 后自动禁用并显示"由连接配置控制"提示,消除"改了没效果"的用户陷阱 - **profile 状态卡新增"本设备无 Key"警示**:API Key 仅保存在最初填写它的设备/浏览器上(安全设计,不随云端设置同步),换设备后状态卡会直接亮出警示徽标,不必等到调用报错才发现 ### 修复 - **独立聊天记忆从摆设变真功能(原作遗留坑)**:此前向量数据"随卡不随聊天"——开启"独立聊天记忆"后录入仍存进角色库、查询却去查一个从未被写入过的聊天集合、计数恒为 0,整体静默失效。现已重构为聊天级分桶: - 独立模式下,聊天记录类向量按当前聊天隔离存储与检索,同一张卡开多个聊天(不同剧情线)的记忆互不污染 - 小说 / 世界书 / 手动录入属于"知识",仍随角色卡跨聊天共享;全局库不受影响 - 知识管理列表为聊天专属库显示"聊天级"徽标;聊天级库禁止移动到全局 - 统一模式(默认关闭独立记忆)的存量数据与行为完全不变 - 已知限制:聊天专属记忆跟随聊天文件,重命名聊天文件会使其失联(与 ST 官方向量扩展同等限制) - **超级排序截断顺序修正**:开启"超级排序"时,时序重排发生在 top_n 截断之前,导致保留的是"时序最早"而非"最相关"的块,检索结果长期偏向最旧的聊天记录。现改为先按相关度截取 top_n、再做时序排序 - **翰林院向量化失败("向量化块数量不识别"反馈)**: - 一次性清洗 profile-sync 历史污染:`retrieval/rerank.apiKey` 中的掩码占位符在持久层根治(此前仅读取侧防御);`apiEndpoint` / `rerank.apiMode` 的非法值(如被旧版写入的空字符串)归一化为 `custom` - 修复 `apiEndpoint` 为空/非法时请求被硬定向到 `api.openai.com`、无视用户自定义 URL 的问题(CSP 拦截 / 401 的元凶) - 修复**本地代理(LM Studio/Ollama)模式**自始就缺少 URL 分支、同样被错误定向到 openai.com 的问题 - API 模式下拉补全 `OpenAI 官方` / `Azure` 选项;默认 API 模式改为 `custom`(与默认 URL 配套),新用户不再因选项缺失导致首次保存写入空值 - profile-sync 给下拉框赋不存在选项值的污染源头修复(影响所有模块面板,不止翰林院) - **Rerank "API Key 未提供"报错升级**:当原因是"连接配置在本设备没有可用 Key"时,报错会直接说明 Key 的设备本地性并指引到 API 连接配置重新填写(向量化 Google 直连、获取模型列表同步处理) - **旧配置迁移**:一键生卡迁移时排除掩码占位符,避免把历史污染的假 Key 迁入新连接配置 - **超级记忆稳定性专项**(针对"工作不大稳定"反馈,4 处根因一次修复): - **切聊天竞态污染**:CHAT_CHANGED 时超级记忆立即全量同步,而表格系统延迟 100ms 才加载新聊天的表格,导致【旧聊天】的表格内容被写进【新角色】的记忆世界书;两边表名不同时旧表条目无 GC 兜底会**永久残留**("记忆串台"元凶)。现 CHAT_CHANGED 只确保世界书存在,新状态同步交由 `loadTables()` 完成后的自动推送,单次且时序正确 - **死代码双轨存储拆除**:`saveStateToMetadata` / `tryRestoreStateFromMetadata` 把表格状态写到 `msg.metadata`——该字段非 ST 持久化位(同 v2.2.5 二次填表修过的坑),写入即蒸发、恢复永远为空,且每次同步还白调一次 `saveChat()`。整条链路删除,表格状态唯一信源为表格系统的 `msg.extra.amily2_tables_data` - **`awaitSync()` 穿透**:同步队列正忙时 `pushUpdate` 会用一个立即 resolve 的空 Promise 覆盖 `_syncPromise`,Pipeline Stage 4 等待形同虚设、后续阶段在同步未完成时被放行。现忙时不覆盖,正在运行的 drain 循环自然吃掉新入队项 - **开关打开不生效**:启动时若总开关为关,初始化早退且不注册监听器;此后在 UI 勾选开关只写设置,超级记忆直到刷新页面前都是死的。现勾选即触发初始化(幂等) - 附带:`forceSyncAll` 的表格角色推断改为复用 `events-schema.inferTableRole`,消除两处重复逻辑漂移风险;每次切聊天的双倍全量同步(restore 路径一次 + 显式一次)随死代码移除归一 ### 重构 - 表格核心 `manager.js` 瘦身(约 1050 → 600 行):19 个 UI 突变操作拆分至 `actions/ui-mutations.js`,SuperMemory 事件分发拆分至 `events-dispatch.js`;全部经 re-export 保持兼容,外部调用路径零改动 - 角色世界书最后 2 处散乱的厂商 URL 判断迁移至 `detectVendor` 统一入口,业务路径上不再有硬编码的 URL substring 判断
460 lines
17 KiB
JavaScript
460 lines
17 KiB
JavaScript
'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) {
|
||
const apiKey = sanitizeMaskedKey(profile.apiKey ?? '');
|
||
return {
|
||
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
|
||
customApiUrl: profile.apiUrl,
|
||
apiKey,
|
||
embeddingModel: profile.model,
|
||
batchSize: getSettings().retrieval?.batchSize ?? 5,
|
||
// Key 存储是设备本地的(ApiKeyStore local/cloud 模式均不跨设备),
|
||
// 换设备/浏览器后 profile 同步而 Key 缺失——标记出来供报错说明
|
||
_keyMissingFromProfile: !apiKey,
|
||
_profileName: profile.name || profile.id,
|
||
};
|
||
}
|
||
const fallback = getSettings().retrieval || {};
|
||
return { ...fallback, apiKey: sanitizeMaskedKey(fallback.apiKey ?? '') };
|
||
}
|
||
|
||
/** Key 缺失时的统一文案:区分"profile 在本设备无 Key"与"未配置" */
|
||
export function describeMissingKey(resolved, plainMessage) {
|
||
if (resolved?._keyMissingFromProfile) {
|
||
return `连接配置「${resolved._profileName}」在本设备上没有可用的 API Key。` +
|
||
`Key 仅保存在最初填写它的设备/浏览器上,不随云端设置同步。` +
|
||
`请在「API 连接配置」面板编辑该配置并重新填写 Key。`;
|
||
}
|
||
return plainMessage;
|
||
}
|
||
|
||
/**
|
||
* 获取 Rerank 配置,优先从 ragRerank 槽位 Profile 读取。
|
||
*/
|
||
export async function getRerankSettings() {
|
||
const profile = await getSlotProfile('ragRerank');
|
||
if (profile) {
|
||
const manualSettings = getSettings().rerank || {};
|
||
const apiKey = sanitizeMaskedKey(profile.apiKey ?? '');
|
||
return {
|
||
url: profile.apiUrl,
|
||
apiKey,
|
||
model: profile.model,
|
||
top_n: manualSettings.top_n ?? 10,
|
||
apiMode: manualSettings.apiMode ?? 'custom',
|
||
_keyMissingFromProfile: !apiKey,
|
||
_profileName: profile.name || profile.id,
|
||
};
|
||
}
|
||
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(describeMissingKey(settings, "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(describeMissingKey(settings, "自定义模式下,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(describeMissingKey(resolved, "自定义模式下,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':
|
||
case 'local_proxy': // 本地代理(LM Studio/Ollama)同样使用用户填写的地址,此前漏掉落入 default 被错误定向到 openai.com
|
||
url = customApiUrl;
|
||
break;
|
||
default:
|
||
// apiEndpoint 为空/非法(历史 profile-sync 污染)时,customApiUrl 比硬编码 openai.com 更可能是用户真实意图
|
||
url = customApiUrl || '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(describeMissingKey(settings, '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(['测试连接']);
|
||
}
|