Files
ST-Amily2-Chat-Optimisation/core/table-system/secondary-filler.js
Jenkins CI 2c3072a3d8 release: v2.2.2 [2026-05-27 11:10:55]
### 新功能
- **Function Call 填表模式**:在填表设置中新增独立开关,启用后支持通过 OpenAI 兼容接口(DeepSeek / OpenRouter / 各类中转等)直接返回结构化操作列表,绕过 `<Amily2Edit>` 文本解析路径,填表更稳定
  - 遇到不支持 `tool_choice` 的接口时自动降级重试
  - 对思考模型注入强制调用指令,防止绕过工具直接输出文本
  - 全部走 ST 后端代理,修复 CSP 拦截直连外部 URL 的问题
- **主界面新增提示词链编辑器入口**,同时调换了记忆管理与角色世界书的按钮位置
- **规则中心**新增"自动排除用户楼层"选项
### 修复
- 提示词链按钮点击无响应(改为事件委托方式绑定)
- 拖拽组件微抖误触发(加 5px 移动阈值过滤)
- 填表检查窗若干问题修复;翰林院(批量回填)修复;防抖逻辑落地
- 角色世界书入口添加使用警告弹窗(强制 10 秒倒计时),提示该功能长期未维护
- ApiProfile `fakeStream` 字段保存丢失问题
- 正文优化默认改为关闭状态
- NGMS / NCCS API 配置槽位标签修正(NGMS→总结,NCCS→填表)
- API Profile 面板选择逻辑统一重构,修复多处旧字段覆盖新配置的问题
- 世界书控制参数兼容性修复(排除递归、插入位置、扫描深度等,适配 ST 1.17.0+)
2026-05-27 11:10:55 +08:00

503 lines
22 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.
import { getContext, extension_settings } from "/scripts/extensions.js";
import { loadWorldInfo } from "/scripts/world-info.js";
import { saveChat } from "/script.js";
import { renderTables } from '../../ui/table-bindings.js';
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
import { extensionName } from "../../utils/settings.js";
import { updateTableFromText, updateTableFromOps, getBatchFillerRuleTemplate, getBatchFillerFlowTemplate, convertTablesToCsvString, saveStateToMessage, getMemoryState, clearHighlights } from './manager.js';
import { getPresetPrompts, getMixedOrder } from '../../PresetSettings/index.js';
import { callAI, callAIForTools, generateRandomSeed } from '../api.js';
import { TABLE_FILL_TOOL, parseToolCallArgs } from './formatters/tool-call.js';
import { callNccsAI } from '../api/NccsApi.js';
import { extractBlocksByTags, applyExclusionRules } from '../utils/rag-tag-extractor.js';
import { resolveTableRuleConfig } from '../../utils/config/RuleProfileManager.js';
import { safeLorebookEntries } from '../tavernhelper-compatibility.js';
import { log } from './logger.js';
import { showTableFillReviewModal } from '../../ui/page-window.js';
const CONTINUE_PROMPT_SECONDARY = '上一条回复不完整或缺少 <Amily2Edit> 指令块。请直接从中断处继续生成剩余内容,不要重复已输出的文本,也不要添加任何解释或寒暄,确保最终输出中包含完整的 <Amily2Edit>...</Amily2Edit> 指令块。';
let secondaryFillerDebounceTimer = null;
async function callSecondaryModel(messages) {
const settings = extension_settings[extensionName] || {};
if (settings.nccsEnabled) {
return await callNccsAI(messages);
}
return await callAI(messages);
}
async function requestSecondaryContinuation(baseMessages, partialResponse) {
const continueMessages = [
...baseMessages,
{ role: 'assistant', content: partialResponse || '' },
{ role: 'user', content: CONTINUE_PROMPT_SECONDARY },
];
const continued = await callSecondaryModel(continueMessages);
if (!continued) return null;
return `${partialResponse || ''}${continued}`;
}
function commitSecondaryFillResult(rawContent, targetMessages) {
updateTableFromText(rawContent);
const memoryState = getMemoryState();
const lastProcessedMsg = targetMessages[targetMessages.length - 1].msg;
for (const target of targetMessages) {
if (!target.msg.metadata) target.msg.metadata = {};
target.msg.metadata.Amily2_Process_Hash = target.hash;
}
if (saveStateToMessage(memoryState, lastProcessedMsg)) {
renderTables();
updateOrInsertTableInChat();
}
saveChat();
}
async function getWorldBookContext() {
const settings = extension_settings[extensionName] || {};
if (!settings.table_worldbook_enabled) {
return '';
}
const selectedEntriesByBook = settings.table_selected_entries || {};
const booksToInclude = Object.keys(selectedEntriesByBook);
const selectedEntryUids = new Set(Object.values(selectedEntriesByBook).flat());
if (booksToInclude.length === 0 || selectedEntryUids.size === 0) {
return '';
}
let allEntries = [];
for (const bookName of booksToInclude) {
try {
const entries = await safeLorebookEntries(bookName);
if (entries?.length) {
entries.forEach(entry => allEntries.push({ ...entry, bookName }));
}
} catch (error) {
console.error(`[Amily2-副API] Error loading entries for world book: ${bookName}`, error);
}
}
const userEnabledEntries = allEntries.filter(entry => {
return entry && selectedEntryUids.has(String(entry.uid));
});
if (userEnabledEntries.length === 0) {
return '';
}
let content = userEnabledEntries.map(entry =>
`[来源:世界书,条目名字:${entry.comment || '无标题条目'}]\n${entry.content}`
).join('\n\n');
const maxChars = settings.table_worldbook_char_limit || 30000;
if (content.length > maxChars) {
content = content.substring(0, maxChars);
const lastNewline = content.lastIndexOf('\n');
if (lastNewline !== -1) {
content = content.substring(0, lastNewline);
}
content += '\n[...内容已截断]';
}
return content.trim() ? `<世界书>\n${content.trim()}\n</世界书>` : '';
}
export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
const settings = extension_settings[extensionName] || {};
// 【V2.1.1】分步填表触发延迟 / 防抖:自动触发时若配置了延迟,则延后执行,
// 延迟期内再次到来的事件会重置计时器,避免消息连续到达时重复拉起填表。
const delay = Math.max(0, parseInt(settings.secondary_filler_delay || 0, 10));
if (!forceRun && delay > 0) {
if (secondaryFillerDebounceTimer) {
clearTimeout(secondaryFillerDebounceTimer);
}
secondaryFillerDebounceTimer = setTimeout(() => {
secondaryFillerDebounceTimer = null;
fillWithSecondaryApi(latestMessage, forceRun);
}, delay);
console.log(`[Amily2-副API] 分步填表已按防抖延迟 ${delay}ms 调度。`);
return;
}
if (secondaryFillerDebounceTimer) {
clearTimeout(secondaryFillerDebounceTimer);
secondaryFillerDebounceTimer = null;
}
clearHighlights();
// 总开关关闭时,分步填表同样禁用
if (settings.table_system_enabled === false) {
log('【分步填表】表格系统总开关已关闭,跳过。', 'info');
return;
}
const context = getContext();
if (context.chat.length <= 1) {
console.log("[Amily2-副API] 聊天刚开始,跳过本次自动填表。");
return;
}
const fillingMode = settings.filling_mode || 'main-api';
if (fillingMode !== 'secondary-api' && !forceRun) {
log('当前非分步填表模式,且未强制执行,跳过。', 'info');
return;
}
if (window.AMILY2_SYSTEM_PARALYZED === true) {
console.error("[Amily2-制裁] 系统完整性已受损,所有外交活动被无限期中止。");
return;
}
try {
const bufferSize = parseInt(settings.secondary_filler_buffer || 0, 10);
const batchSize = parseInt(settings.secondary_filler_batch || 0, 10);
const contextLimit = parseInt(settings.secondary_filler_context || 2, 10);
// 【V1.7.7 修复】限制最大回溯深度,防止更新后无限填补旧历史
// 响应用户反馈:扫描深度 = 上下文 + 填表批次 + 保留楼层 + 冗余量(10)
// redundancy (冗余量): 额外扫描 10 层作为安全缓冲,防止因消息索引计算偏差导致漏掉边缘消息
const redundancy = 10;
const maxScanDepth = contextLimit + batchSize + bufferSize + redundancy;
const chat = context.chat;
const totalMessages = chat.length;
const validEndIndex = totalMessages - 1 - bufferSize;
// 计算扫描的起始索引不小于0
const scanStartIndex = Math.max(0, validEndIndex - maxScanDepth);
if (validEndIndex < 0) {
console.log(`[Amily2-副API] 消息数量不足以超出保留区(${bufferSize}),跳过。`);
return;
}
let targetMessages = [];
let needsProcessing = false;
const getContentHash = (content) => {
let hash = 0, i, chr;
if (content.length === 0) return hash;
for (i = 0; i < content.length; i++) {
chr = content.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return hash;
};
// 【修复】改为正向扫描,优先处理最老的未处理消息,防止遗留消息被挤出扫描区
for (let i = scanStartIndex; i <= validEndIndex; i++) {
const msg = chat[i];
if (msg.is_user) continue;
const currentHash = getContentHash(msg.mes);
const savedHash = msg.metadata?.Amily2_Process_Hash;
const isUnprocessed = !savedHash;
const isChanged = savedHash && savedHash !== currentHash;
if (isUnprocessed || isChanged) {
targetMessages.push({ index: i, msg: msg, hash: currentHash });
if (batchSize > 0 && targetMessages.length >= batchSize) {
needsProcessing = true;
break;
}
}
}
if (targetMessages.length === 0) {
console.log("[Amily2-副API] 没有发现需要处理的消息。");
return;
}
if (batchSize > 0) {
if (targetMessages.length < batchSize) {
console.log(`[Amily2-副API] 批量模式: 当前累积 ${targetMessages.length}/${batchSize} 条未处理消息,暂不触发。`);
return;
}
} else {
targetMessages = [targetMessages[targetMessages.length - 1]];
}
console.log(`[Amily2-副API] 触发填表: 处理 ${targetMessages.length} 条消息。索引范围: ${targetMessages[0].index} - ${targetMessages[targetMessages.length-1].index}`);
toastr.info(`分步填表正在执行,正在填写 ${targetMessages[0].index + 1} 楼至 ${targetMessages[targetMessages.length-1].index + 1} 楼的内容`, "Amily2-分步填表");
let tagsToExtract = [];
let exclusionRules = [];
const tableRuleConfig = resolveTableRuleConfig(settings);
if (tableRuleConfig.tags || (tableRuleConfig.exclusionRules && tableRuleConfig.exclusionRules.length)) {
tagsToExtract = (tableRuleConfig.tags || '').split(',').map(t => t.trim()).filter(Boolean);
exclusionRules = tableRuleConfig.exclusionRules || [];
}
let coreContentText = "";
const userName = context.name1 || '用户';
const characterName = context.name2 || '角色';
for (const target of targetMessages) {
let textToProcess = target.msg.mes;
if (tagsToExtract.length > 0) {
const blocks = extractBlocksByTags(textToProcess, tagsToExtract);
textToProcess = blocks.join('\n\n');
}
textToProcess = applyExclusionRules(textToProcess, exclusionRules);
if (!textToProcess.trim()) continue;
coreContentText += `\n【第 ${target.index + 1} 楼】${characterName}AI消息\n${textToProcess}\n`;
}
if (!coreContentText.trim()) {
console.log("[Amily2-副API] 目标内容处理后为空,跳过。");
return;
}
const historyEndIndex = targetMessages[0].index - 1;
let historyContextStr = "";
if (contextLimit > 0 && historyEndIndex >= 0) {
historyContextStr = await getHistoryContext(contextLimit, historyEndIndex, tagsToExtract, exclusionRules) || "";
}
const currentInteractionContent = (historyContextStr ? `${historyContextStr}\n\n` : '') +
`<核心填表内容>\n${coreContentText}\n</核心填表内容>`;
let mixedOrder;
try {
const savedOrder = localStorage.getItem('amily2_prompt_presets_v2_mixed_order');
if (savedOrder) {
mixedOrder = JSON.parse(savedOrder);
}
} catch (e) {
console.error("[副API填表] 加载混合顺序失败:", e);
}
const order = getMixedOrder('secondary_filler') || [];
const presetPrompts = await getPresetPrompts('secondary_filler');
const messages = [
{ role: 'system', content: generateRandomSeed() }
];
const worldBookContext = await getWorldBookContext();
const ruleTemplate = getBatchFillerRuleTemplate();
const flowTemplate = getBatchFillerFlowTemplate();
const currentTableDataString = convertTablesToCsvString();
const finalFlowPrompt = flowTemplate.replace('{{{Amily2TableData}}}', currentTableDataString);
let promptCounter = 0;
for (const item of order) {
if (item.type === 'prompt') {
if (presetPrompts && presetPrompts[promptCounter]) {
messages.push(presetPrompts[promptCounter]);
promptCounter++;
}
} else if (item.type === 'conditional') {
switch (item.id) {
case 'worldbook':
if (worldBookContext) {
messages.push({ role: "system", content: worldBookContext });
}
break;
case 'contextHistory':
if (historyContextStr) {
messages.push({ role: "system", content: historyContextStr });
}
break;
case 'ruleTemplate':
messages.push({ role: "system", content: ruleTemplate });
break;
case 'flowTemplate':
messages.push({ role: "system", content: finalFlowPrompt });
break;
case 'coreContent':
messages.push({ role: 'user', content: `请严格根据以下"核心填表内容"进行填写表格,并按照指定的格式输出,不要添加任何额外信息。\n\n<核心填表内容>\n${coreContentText}\n</核心填表内容>` });
break;
}
}
}
console.groupCollapsed(`[Amily2 分步填表] 即将发送至 API 的内容`);
console.log("发送给AI的提示词: ", JSON.stringify(messages, null, 2));
console.dir(messages);
console.groupEnd();
if (settings.tableFillFunctionCall) {
// Function Call 路径
const argsString = await callAIForTools(messages, TABLE_FILL_TOOL, { slot: 'tableFilling' });
if (!argsString) {
console.error('[Amily2-副API] Function Call 返回为空。');
return;
}
const ops = parseToolCallArgs(argsString);
if (ops.length === 0) {
console.warn('[Amily2-副API] Function Call 返回操作列表为空,无需变更。');
toastr.info('AI 判断此范围无需修改。', 'Amily2-分步填表');
} else {
await updateTableFromOps(ops);
toastr.success('分步填表Function Call执行完毕。', 'Amily2-分步填表');
}
} else {
// Legacy 文本路径
let rawContent;
if (settings.nccsEnabled) {
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
rawContent = await callNccsAI(messages);
} else {
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
rawContent = await callAI(messages, { slot: 'tableFilling' });
}
if (!rawContent) {
console.error('[Amily2-副API] 未能获取AI响应内容。');
return;
}
console.log('[Amily2号-副API-原始回复]:', rawContent);
if (!rawContent.includes('<Amily2Edit>')) {
const rangeLabel = `${targetMessages[0].index + 1} - ${targetMessages[targetMessages.length - 1].index + 1}`;
console.warn(`[Amily2-副API] 响应未包含 <Amily2Edit> 指令块(楼层 ${rangeLabel}),弹出检查窗口等待用户处理。`);
toastr.warning(`分步填表(楼层 ${rangeLabel})的响应缺少 <Amily2Edit> 指令块,请在弹窗中处理。`, 'Amily2-分步填表');
if (latestMessage && latestMessage.metadata) {
delete latestMessage.metadata.Amily2_Retry_Count;
}
showTableFillReviewModal(rawContent, {
title: `分步填表响应检查 - 楼层 ${rangeLabel}`,
subtitle: `分步填表(楼层 ${rangeLabel})的 AI 响应未包含有效的 <Amily2Edit> 指令块。请检查原始响应并选择处理方式。`,
onContinue: async (currentText) => {
const merged = await requestSecondaryContinuation(messages, currentText);
if (!merged) { toastr.error('补全请求失败或返回为空。', '继续补全'); return null; }
if (!merged.includes('<Amily2Edit>')) {
toastr.warning('补全后仍未包含 <Amily2Edit> 指令块,可继续补全、手动应用或重新填表。', '继续补全');
} else {
toastr.success('已获得包含指令块的补全内容,可点击”手动应用”写入。', '继续补全');
}
return merged;
},
onApply: (editedText) => {
if (!editedText || !editedText.includes('<Amily2Edit>')) {
toastr.warning('应用的文本中未检测到 <Amily2Edit> 指令块,已按原文尝试写入。', '手动应用');
}
try {
commitSecondaryFillResult(editedText, targetMessages);
toastr.success('分步填表已由用户手动处理完成。', 'Amily2-分步填表');
} catch (err) {
console.error('[Amily2-副API] 手动应用失败:', err);
toastr.error(`手动应用失败: ${err.message}`, '写入异常');
}
},
onRetry: () => {
if (latestMessage && latestMessage.metadata) {
delete latestMessage.metadata.Amily2_Retry_Count;
}
toastr.info('将重新执行分步填表...', 'Amily2-分步填表');
setTimeout(() => fillWithSecondaryApi(latestMessage, forceRun), 300);
},
onCancel: () => {
toastr.info('已取消本次分步填表。', 'Amily2-分步填表');
},
});
return;
}
commitSecondaryFillResult(rawContent, targetMessages);
}
toastr.success("分步填表执行完毕。", "Amily2-分步填表");
} catch (error) {
console.error(`[Amily2-副API] 发生严重错误:`, error);
// 【新增】自定义重试逻辑
const maxRetries = parseInt(settings.secondary_filler_max_retries || 0, 10);
const currentRetryCount = latestMessage?.metadata?.Amily2_Retry_Count || 0;
if (currentRetryCount < maxRetries) {
const nextRetryCount = currentRetryCount + 1;
console.log(`[Amily2-副API] 准备进行第 ${nextRetryCount}/${maxRetries} 次重试...`);
toastr.warning(`副API填表失败: ${error.message}。将在3秒后进行第 ${nextRetryCount} 次重试...`, "自动重试");
// 记录重试次数到最新消息的 metadata 中,以便跨调用传递状态
if (latestMessage) {
if (!latestMessage.metadata) latestMessage.metadata = {};
latestMessage.metadata.Amily2_Retry_Count = nextRetryCount;
}
setTimeout(() => {
fillWithSecondaryApi(latestMessage, forceRun);
}, 3000);
} else {
console.log(`[Amily2-副API] 已达到最大重试次数 (${maxRetries}),放弃本次填表。`);
toastr.error(`副API填表失败: ${error.message}。已达到最大重试次数,任务终止。`, "严重错误");
// 清除重试计数器
if (latestMessage && latestMessage.metadata) {
delete latestMessage.metadata.Amily2_Retry_Count;
}
}
}
}
async function getHistoryContext(messagesToFetch, historyEndIndex, tagsToExtract, exclusionRules) {
const context = getContext();
const chat = context.chat;
if (!chat || chat.length === 0 || messagesToFetch <= 0) {
return null;
}
const historyUntil = Math.max(0, historyEndIndex);
// 【修复】slice 的 end 索引是不包含的,为了包含 historyUntilend 必须 +1
const sliceEnd = historyUntil + 1;
const messagesToExtract = Math.min(messagesToFetch, sliceEnd);
const sliceStart = Math.max(0, sliceEnd - messagesToExtract);
const historySlice = chat.slice(sliceStart, sliceEnd);
const userName = context.name1 || '用户';
const characterName = context.name2 || '角色';
const messages = historySlice.map((msg, index) => {
let content = msg.mes;
if (!msg.is_user && tagsToExtract && tagsToExtract.length > 0) {
const blocks = extractBlocksByTags(content, tagsToExtract);
content = blocks.join('\n\n');
}
if (content && exclusionRules) {
content = applyExclusionRules(content, exclusionRules);
}
if (!content.trim()) return null;
return {
floor: sliceStart + index + 1,
author: msg.is_user ? userName : characterName,
authorType: msg.is_user ? 'user' : 'char',
content: content.trim()
};
}).filter(Boolean);
if (messages.length === 0) {
return null;
}
const formattedHistory = messages.map(m => `【第 ${m.floor} 楼】 ${m.author}: ${m.content}`).join('\n');
return `<对话记录>\n${formattedHistory}\n</对话记录>`;
}