mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-06 04:35:51 +00:00
release: v2.2.4 [2026-05-31 13:32:25]
### 新功能
- **Function Call 填表**:
- FC 首次请求时对 DeepSeek 系模型自动附加 `thinking: { type: "disabled" }`,避免思考模式与 tool_choice 冲突
- 操作列表为空时在日志面板输出原始响应 JSON,便于区分"AI 判断无需变更"、"格式校验全部不通过"和"JSON 解析失败"三种情况
### 修复
- **剧情优化**:移除剧情优化页面遗留的 Jqyh 直连配置字段(URL / Key / Model),统一走 API 连接配置功能分配槽位
- **表格**:
- 补全 `batch-filling-threshold` 批处理阈值的持久化绑定(页面刷新后不再还原为默认值 30)
- 修复分步填表并发锁与 async/await 时序问题
- 修复外层多余 `try...finally` 导致的插件加载报错
- **Rerank**:
- 修复选择连接配置后报"API Key 未配置"的问题(`apiMode` 现从设置读取而非硬编码 `custom`)
- 补全 `hly-rerank-api-mode` 加载绑定及默认值
- **翰林院 RAG**:补全 `priorityRetrieval.sources` 各来源条目的缺失键,修复设置面板回填 TypeError
- **二次填表**:
- 修复 `secondary-filler.js` 把哈希/重试次数写入非持久化的 `msg.metadata` 字段(ST 标准位是 `msg.extra`),导致刷新后去重与重试计数失效
- 修复扫描深度重复计入 `bufferSize`(`contextLimit + buffer + batch + redundancy` → `contextLimit + batch + redundancy`),避免越过预期窗口
- SWIPED 事件改走扫描路径,不再用 `targetMessage` bypass 强填最末条,`保留缓冲区(bufferSize)` 设置在滑动场景下正确生效(手动"回退重填"按钮仍保留 bypass,意图明确)
- 修复 FC(Function Call)路径下成功填表与"AI 判断无需修改"两种结果均未写回 `amily2_process_hash` 与 `saveChat()` 的问题——之前导致 FC 模式去重完全失效,最旧的未处理楼层会被每次扫描重复发给 AI;现统一回写路径为 `markTargetsProcessed`
- FC 空操作时同步输出原始响应 JSON 到控制台(与批量回填日志面板保持一致),便于区分"无需变更"/"格式校验失败"/"JSON 解析失败"
- 修复 `fillWithSecondaryApi` 入口处过早设置 `secondaryFillerRunning = true`,导致防抖/总开关关闭/聊天过短/非分步模式/系统瘫痪五条早返路径均不解锁的死锁问题(特别是防抖路径——锁住后 setTimeout 回调撞上自己的锁,永久跳过后续触发)。锁的获取已挪到所有早返检查之后、`try` 块之前
- **填表设置面板**:新增"手动解除填表锁"按钮(位于触发延迟下方),用于兜底应急——若仍遇到"分步填表正在进行中,跳过本次触发"反复刷屏,可手动点击释放
- **API 调用层全面支持 AbortController**(`callAI` / `callAIForTools` / `callNccsAI` 及其全部下游 provider):
- 新增 `options.signal` 透传,OpenAI 兼容 / OpenAI(测试) / Google 直连 / ST 后端 / FC 等所有 `fetch` 调用均接受 `AbortSignal`
- `callSillyTavernBackend` 由 `$.ajax` 改写为 `fetch`,以原生支持 signal
- `callSillyTavernPreset` / `callNccsSillyTavernPreset` 通过 `raceAgainstSignal` 兜底,外部不可终止的 `ConnectionManagerRequestService.sendRequest` 也能在 signal 触发时即时返回 AbortError
- 全部 catch 块识别 `AbortError`,rethrow 而不弹错误 toast;FC 重试逻辑识别中断后跳过重试
- **填表设置面板**:在"手动解除填表锁"旁新增"强制中断当前填表"按钮——通过 AbortController 真正掐断 fetch 连接(fetch 立即抛错),结果会被丢弃,不会污染表格 / hash / `saveChat`
This commit is contained in:
71
core/api.js
71
core/api.js
@@ -588,6 +588,7 @@ export async function callAI(messages, options = {}) {
|
||||
apiKey: apiSettings.apiKey,
|
||||
apiProvider: apiSettings.apiProvider,
|
||||
customParams: apiSettings.customParams ?? {},
|
||||
signal: options.signal,
|
||||
...options,
|
||||
// options 可显式覆盖 customParams,体现"代码内显式 > profile 配置"
|
||||
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
|
||||
@@ -648,6 +649,10 @@ export async function callAI(messages, options = {}) {
|
||||
return responseContent;
|
||||
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
console.warn('[Amily2-外交部] API 调用被用户中断。');
|
||||
throw error; // 让上层(如 secondary-filler)识别并跳过结果处理
|
||||
}
|
||||
console.error(`[Amily2-外交部] API调用发生错误:`, error);
|
||||
|
||||
if (error.message.includes('400')) {
|
||||
@@ -663,7 +668,7 @@ export async function callAI(messages, options = {}) {
|
||||
} else {
|
||||
toastr.error(`API调用失败: ${error.message}`, "API调用失败");
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -690,7 +695,8 @@ async function callOpenAICompatible(messages, options) {
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
stream: false,
|
||||
})
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -732,7 +738,8 @@ async function callOpenAITest(messages, options) {
|
||||
const response = await fetch('/api/backends/chat-completions/generate', {
|
||||
method: 'POST',
|
||||
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -774,10 +781,11 @@ async function callGoogleDirect(messages, options) {
|
||||
temperature: options.temperature
|
||||
}));
|
||||
|
||||
const response = await fetch(finalApiUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: requestBody
|
||||
const response = await fetch(finalApiUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: requestBody,
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -822,11 +830,10 @@ async function callGoogleDirect(messages, options) {
|
||||
async function callSillyTavernBackend(messages, options) {
|
||||
console.log('[Amily2号-ST后端] 通过SillyTavern后端调用API');
|
||||
|
||||
const rawResponse = await $.ajax({
|
||||
url: '/api/backends/chat-completions/generate',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({
|
||||
const response = await fetch('/api/backends/chat-completions/generate', {
|
||||
method: 'POST',
|
||||
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
// 用户 customParams(可被核心字段覆盖)
|
||||
...(options.customParams || {}),
|
||||
// 表单托管字段总是 win
|
||||
@@ -838,9 +845,16 @@ async function callSillyTavernBackend(messages, options) {
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
stream: false,
|
||||
})
|
||||
}),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`SillyTavern后端API请求失败: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const rawResponse = await response.json();
|
||||
const result = normalizeApiResponse(rawResponse);
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'SillyTavern后端API调用失败');
|
||||
@@ -850,6 +864,28 @@ async function callSillyTavernBackend(messages, options) {
|
||||
}
|
||||
|
||||
|
||||
function raceAgainstSignal(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) {
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
|
||||
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function callSillyTavernPreset(messages, options) {
|
||||
console.log('[Amily2号-ST预设] 使用SillyTavern预设调用');
|
||||
|
||||
@@ -909,7 +945,7 @@ async function callSillyTavernPreset(messages, options) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await responsePromise;
|
||||
const result = await raceAgainstSignal(responsePromise, options.signal);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('未收到API响应');
|
||||
@@ -969,6 +1005,7 @@ export async function callAIForTools(messages, tool, options = {}) {
|
||||
apiKey: apiSettings.apiKey,
|
||||
apiProvider: apiSettings.apiProvider,
|
||||
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
|
||||
signal: options.signal,
|
||||
...options,
|
||||
};
|
||||
|
||||
@@ -1009,6 +1046,7 @@ export async function callAIForTools(messages, tool, options = {}) {
|
||||
method: 'POST',
|
||||
headers: { ...getRequestHeaders(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildFCBody(withToolChoice, overrideMessages, extraParams)),
|
||||
signal: finalOptions.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
@@ -1036,6 +1074,7 @@ export async function callAIForTools(messages, tool, options = {}) {
|
||||
if (isDeepSeek) console.log('[Amily2-外交部] 检测到 DeepSeek 端点,首次 FC 请求附加 thinking:disabled');
|
||||
data = await doFCRequest(true, undefined, firstAttemptExtra);
|
||||
} catch (firstError) {
|
||||
if (firstError?.name === 'AbortError') throw firstError; // 用户中断,不要重试
|
||||
// 首次失败(含 ST 代理吞掉错误码场景)无条件去掉 tool_choice 重试一次
|
||||
// 思考模式模型支持 tools 但不支持强制 tool_choice,追加强制指令防止模型直接输出文本
|
||||
console.warn('[Amily2-外交部] 首次 FC 请求失败,去掉 tool_choice 重试…', firstError.message);
|
||||
@@ -1059,6 +1098,10 @@ export async function callAIForTools(messages, tool, options = {}) {
|
||||
return argsString ?? null;
|
||||
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
console.warn('[Amily2-外交部] Function Call 调用被用户中断。');
|
||||
throw error;
|
||||
}
|
||||
console.error('[Amily2-外交部] Function Call 调用失败:', error);
|
||||
toastr.error(`Function Call 调用失败: ${error.message}`, 'Amily2-外交部');
|
||||
return null;
|
||||
|
||||
@@ -87,6 +87,7 @@ export async function callNccsAI(messages, options = {}) {
|
||||
const settings = await getNccsApiSettings();
|
||||
const finalOptions = {
|
||||
...settings,
|
||||
signal: options.signal,
|
||||
...options
|
||||
};
|
||||
|
||||
@@ -123,14 +124,40 @@ export async function callNccsAI(messages, options = {}) {
|
||||
}
|
||||
return responseContent;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
console.warn('[Amily2-Nccs] API 调用被用户中断。');
|
||||
throw error;
|
||||
}
|
||||
console.error(`[Amily2-Nccs] API 调用失败:`, error);
|
||||
toastr.error(`调用失败: ${error.message}`, "Nccs API Error");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFakeStream(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
function raceAgainstSignal(promise, signal) {
|
||||
if (!signal) return promise;
|
||||
if (signal.aborted) {
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
return Promise.reject(err);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
const err = new Error('Aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
|
||||
(e) => { signal.removeEventListener('abort', onAbort); reject(e); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchFakeStream(url, opts, signal) {
|
||||
const res = await fetch(url, { ...opts, signal });
|
||||
if (!res.ok) throw new Error(`Stream HTTP ${res.status}: ${await res.text()}`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
@@ -217,10 +244,10 @@ async function callNccsOpenAITest(messages, options) {
|
||||
};
|
||||
|
||||
if (options.stream) {
|
||||
return await fetchFakeStream('/api/backends/chat-completions/generate', fetchOpts);
|
||||
return await fetchFakeStream('/api/backends/chat-completions/generate', fetchOpts, options.signal);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/backends/chat-completions/generate', fetchOpts);
|
||||
const response = await fetch('/api/backends/chat-completions/generate', { ...fetchOpts, signal: options.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
return normalizeApiResponse(await response.json());
|
||||
}
|
||||
@@ -244,13 +271,14 @@ async function callNccsSillyTavernPreset(messages, options) {
|
||||
|
||||
if (!context.ConnectionManagerRequestService) throw new Error('ConnectionManagerRequestService unavailable');
|
||||
|
||||
const result = await context.ConnectionManagerRequestService.sendRequest(
|
||||
const sendPromise = context.ConnectionManagerRequestService.sendRequest(
|
||||
targetProfile.id,
|
||||
messages,
|
||||
8192,
|
||||
options.customParams || {}
|
||||
);
|
||||
|
||||
const result = await raceAgainstSignal(sendPromise, options.signal);
|
||||
return normalizeApiResponse(result);
|
||||
|
||||
} finally {
|
||||
|
||||
@@ -19,13 +19,14 @@ const CONTINUE_PROMPT_SECONDARY = '上一条回复不完整或缺少 <Amily2Edit
|
||||
|
||||
let secondaryFillerDebounceTimer = null;
|
||||
let secondaryFillerRunning = false;
|
||||
let currentAbortController = null;
|
||||
|
||||
async function callSecondaryModel(messages) {
|
||||
async function callSecondaryModel(messages, signal) {
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
if (settings.nccsEnabled) {
|
||||
return await callNccsAI(messages);
|
||||
return await callNccsAI(messages, { signal });
|
||||
}
|
||||
return await callAI(messages);
|
||||
return await callAI(messages, { signal });
|
||||
}
|
||||
|
||||
async function requestSecondaryContinuation(baseMessages, partialResponse) {
|
||||
@@ -123,11 +124,11 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts
|
||||
log('分步填表正在进行中,跳过本次触发。', 'warn');
|
||||
return;
|
||||
}
|
||||
secondaryFillerRunning = true;
|
||||
const settings = extension_settings[extensionName] || {};
|
||||
|
||||
// 【V2.1.1】分步填表触发延迟 / 防抖:自动触发时若配置了延迟,则延后执行,
|
||||
// 延迟期内再次到来的事件会重置计时器,避免消息连续到达时重复拉起填表。
|
||||
// 注意:防抖与早返路径都不持锁,避免 setTimeout 回调撞上自己的锁导致死锁。
|
||||
const delay = Math.max(0, parseInt(settings.secondary_filler_delay || 0, 10));
|
||||
if (!forceRun && delay > 0) {
|
||||
if (secondaryFillerDebounceTimer) {
|
||||
@@ -170,7 +171,10 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 所有早返检查通过后再获取锁,确保 finally 一定能解锁
|
||||
secondaryFillerRunning = true;
|
||||
currentAbortController = new AbortController();
|
||||
const signal = currentAbortController.signal;
|
||||
try {
|
||||
const bufferSize = parseInt(settings.secondary_filler_buffer || 0, 10);
|
||||
const batchSize = parseInt(settings.secondary_filler_batch || 0, 10);
|
||||
@@ -369,7 +373,7 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts
|
||||
|
||||
if (settings.tableFillFunctionCall) {
|
||||
// Function Call 路径
|
||||
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
|
||||
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling', signal });
|
||||
if (!argsString) {
|
||||
console.error('[Amily2-副API] Function Call 返回为空。');
|
||||
return;
|
||||
@@ -397,10 +401,10 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts
|
||||
let rawContent;
|
||||
if (settings.nccsEnabled) {
|
||||
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
|
||||
rawContent = await callNccsAI(messages);
|
||||
rawContent = await callNccsAI(messages, { signal });
|
||||
} else {
|
||||
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
|
||||
rawContent = await callAI(messages, { slot: 'tableFilling' });
|
||||
rawContent = await callAI(messages, { slot: 'tableFilling', signal });
|
||||
}
|
||||
|
||||
if (!rawContent) {
|
||||
@@ -461,8 +465,16 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts
|
||||
toastr.success("分步填表执行完毕。", "Amily2-分步填表");
|
||||
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || signal.aborted) {
|
||||
console.warn('[Amily2-副API] 分步填表已被用户中断,跳过结果处理与重试。');
|
||||
toastr.info('分步填表已中断。', 'Amily2-分步填表');
|
||||
if (latestMessage && latestMessage.extra) {
|
||||
delete latestMessage.extra.amily2_retry_count;
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(`[Amily2-副API] 发生严重错误:`, error);
|
||||
|
||||
|
||||
// 【新增】自定义重试逻辑
|
||||
const maxRetries = parseInt(settings.secondary_filler_max_retries || 0, 10);
|
||||
const currentRetryCount = latestMessage?.extra?.amily2_retry_count || 0;
|
||||
@@ -492,8 +504,37 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false, opts
|
||||
}
|
||||
} finally {
|
||||
secondaryFillerRunning = false;
|
||||
currentAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resetSecondaryFillerLock() {
|
||||
const wasLocked = secondaryFillerRunning;
|
||||
if (secondaryFillerDebounceTimer) {
|
||||
clearTimeout(secondaryFillerDebounceTimer);
|
||||
secondaryFillerDebounceTimer = null;
|
||||
}
|
||||
if (currentAbortController) {
|
||||
try { currentAbortController.abort(); } catch {}
|
||||
currentAbortController = null;
|
||||
}
|
||||
secondaryFillerRunning = false;
|
||||
return wasLocked;
|
||||
}
|
||||
|
||||
export function isSecondaryFillerRunning() {
|
||||
return secondaryFillerRunning;
|
||||
}
|
||||
|
||||
export function abortCurrentSecondaryFiller() {
|
||||
if (!secondaryFillerRunning && !currentAbortController) {
|
||||
return false;
|
||||
}
|
||||
if (currentAbortController) {
|
||||
try { currentAbortController.abort(); } catch {}
|
||||
}
|
||||
// 锁的释放由 finally 完成;这里只发出中断信号
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getHistoryContext(messagesToFetch, historyEndIndex, tagsToExtract, exclusionRules) {
|
||||
|
||||
Reference in New Issue
Block a user