ci: auto build & obfuscate [2026-05-16 19:16:28] (Jenkins #21)

This commit is contained in:
Jenkins CI
2026-05-16 19:16:28 +08:00
parent 4bc6e0a047
commit d9fa3072a2
46 changed files with 4154 additions and 1584 deletions

View File

@@ -441,7 +441,7 @@ async function fetchSillyTavernPresetModels() {
export async function getApiSettings(slot = 'main') {
const s = extension_settings[extensionName] || {};
// 优先读取槽位分配的 Profile仅接管连接参数
// 优先读取槽位分配的 Profileprofile 一旦分配即为权威,不再被主面板/模块独立设置压制
const profile = await getSlotProfile(slot);
if (profile) {
const resolvedProvider = profile.provider === 'sillytavern_backend'
@@ -453,10 +453,10 @@ export async function getApiSettings(slot = 'main') {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// 温度 / MaxTokens 读面板值profile-sync 保留了这些输入框)
maxTokens: s.maxTokens ?? profile.maxTokens ?? 65500,
temperature: s.temperature ?? profile.temperature ?? 1.0,
maxTokens: profile.maxTokens ?? 65500,
temperature: profile.temperature ?? 1.0,
fakeStream: profile.fakeStream ?? false,
customParams: profile.customParams ?? {},
tavernProfile: '',
};
}
@@ -588,7 +588,10 @@ export async function callAI(messages, options = {}) {
apiUrl: apiSettings.apiUrl,
apiKey: apiSettings.apiKey,
apiProvider: apiSettings.apiProvider,
...options
customParams: apiSettings.customParams ?? {},
...options,
// options 可显式覆盖 customParams体现"代码内显式 > profile 配置"
customParams: { ...(apiSettings.customParams ?? {}), ...(options.customParams ?? {}) },
};
if (finalOptions.apiProvider !== 'sillytavern_preset') {
@@ -680,11 +683,14 @@ async function callOpenAICompatible(messages, options) {
'Content-Type': 'application/json'
},
body: JSON.stringify({
// 用户自定义参数profile.customParams + 显式 options.customParams 已在 callAI 合并)
...(options.customParams || {}),
// 表单托管的核心字段总是覆盖 customParams
model: options.model,
messages: messages,
max_tokens: options.maxTokens,
temperature: options.temperature,
stream: false
stream: false,
})
});
@@ -699,6 +705,21 @@ async function callOpenAICompatible(messages, options) {
async function callOpenAITest(messages, options) {
const body = {
// 1. 可调默认值(用户 customParams 可覆盖)
top_p: options.top_p || 1,
frequency_penalty: 0,
presence_penalty: 0.12,
include_reasoning: false,
reasoning_effort: 'medium',
enable_web_search: false,
request_images: false,
custom_prompt_post_processing: 'strict',
group_names: [],
// 2. 用户 customParams 覆盖上层默认值
...(options.customParams || {}),
// 3. 表单托管的核心字段总是 win
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -707,15 +728,6 @@ async function callOpenAITest(messages, options) {
stream: false,
max_tokens: options.maxTokens || 30000,
temperature: options.temperature || 1,
top_p: options.top_p || 1,
custom_prompt_post_processing: 'strict',
enable_web_search: false,
frequency_penalty: 0,
group_names: [],
include_reasoning: false,
presence_penalty: 0.12,
reasoning_effort: 'medium',
request_images: false,
};
const response = await fetch('/api/backends/chat-completions/generate', {
@@ -816,6 +828,9 @@ async function callSillyTavernBackend(messages, options) {
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
// 用户 customParams可被核心字段覆盖
...(options.customParams || {}),
// 表单托管字段总是 win
chat_completion_source: 'custom',
custom_url: options.apiUrl,
api_key: options.apiKey,
@@ -823,7 +838,7 @@ async function callSillyTavernBackend(messages, options) {
messages: messages,
max_tokens: options.maxTokens,
temperature: options.temperature,
stream: false
stream: false,
})
});

View File

@@ -3,11 +3,12 @@ import { getRequestHeaders } from "/script.js";
import { extensionName } from "../../utils/settings.js";
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
async function getConcurrentApiSettings() {
const s = extension_settings[extensionName] || {};
// 优先读取槽位分配的 Profile仅接管连接参数
// 优先读取槽位分配的 Profileprofile 一旦分配即权威slider 残值不再覆盖
const profile = await getSlotProfile('plotOptConc');
if (profile) {
return {
@@ -15,8 +16,7 @@ async function getConcurrentApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// MaxTokens 读面板值
maxTokens: s.plotOpt_concurrentMaxTokens ?? profile.maxTokens ?? 8100,
maxTokens: profile.maxTokens ?? 8100,
temperature: profile.temperature ?? 1,
};
}
@@ -47,7 +47,7 @@ export async function callConcurrentAI(messages, options = {}) {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Concurrent外交部] API配置不完整无法调用AI");
toastr.error("并发API配置不完整请检查URL、Key和模型配置。", "Concurrent-外交部");
toastr.error("并发剧情优化plotOptConc未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写并发优化独立设置。", "Amily2-并发优化未配置");
return null;
}
@@ -97,7 +97,7 @@ export async function callConcurrentAI(messages, options = {}) {
}
async function callConcurrentOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
chat_completion_source: 'openai',

View File

@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
let ChatCompletionService = undefined;
try {
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
export async function getJqyhApiSettings() {
const s = extension_settings[extensionName] || {};
// JQYH 与剧情优化互斥,共用 'plotOpt' 槽位
// JQYH 与剧情优化互斥,共用 'plotOpt' 槽位profile 一旦分配即权威slider 残值不再覆盖)
const profile = await getSlotProfile('plotOpt');
if (profile) {
return {
@@ -55,9 +56,9 @@ export async function getJqyhApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// 温度 / MaxTokens 读面板值
maxTokens: s.jqyhMaxTokens ?? profile.maxTokens ?? 65500,
temperature: s.jqyhTemperature ?? profile.temperature ?? 1.0,
maxTokens: profile.maxTokens ?? 65500,
temperature: profile.temperature ?? 1.0,
customParams: profile.customParams ?? {},
tavernProfile: '',
};
}
@@ -70,6 +71,7 @@ export async function getJqyhApiSettings() {
model: s.jqyhModel || '',
maxTokens: s.jqyhMaxTokens || 4000,
temperature: s.jqyhTemperature || 0.7,
customParams: {},
tavernProfile: s.jqyhTavernProfile || '',
};
}
@@ -96,7 +98,7 @@ export async function callJqyhAI(messages, options = {}) {
if (finalOptions.apiMode !== 'sillytavern_preset') {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Jqyh外交部] API配置不完整无法调用AI");
toastr.error("API配置不完整请检查URL、Key和模型配置。", "Jqyh-外交部");
toastr.error("剧情优化前置JQYH未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 JQYH 独立设置。", "Amily2-JQYH 未配置");
return null;
}
}
@@ -160,9 +162,11 @@ export async function callJqyhAI(messages, options = {}) {
}
async function callJqyhOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
top_p: options.top_p || 1,
...(options.customParams || {}),
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -171,7 +175,6 @@ async function callJqyhOpenAITest(messages, options) {
stream: false,
max_tokens: options.maxTokens || 30000,
temperature: options.temperature || 1,
top_p: options.top_p || 1,
};
if (!isGoogleApi) {
@@ -245,7 +248,8 @@ async function callJqyhSillyTavernPreset(messages, options) {
responsePromise = context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
options.maxTokens || 4000
options.maxTokens || 4000,
options.customParams || {}
);
} finally {

View File

@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
let ChatCompletionService = undefined;
try {
@@ -41,7 +42,7 @@ if (window.Amily2Bus) {
export async function getNccsApiSettings() {
const s = extension_settings[extensionName] || {};
// 优先读取 'nccs' 槽位分配的 Profile仅接管连接参数
// 优先读取 'nccs' 槽位分配的 Profileprofile 一旦分配即权威,旧 slider 残值不再覆盖
const profile = await getSlotProfile('nccs');
if (profile) {
return {
@@ -50,9 +51,9 @@ export async function getNccsApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// 温度 / MaxTokens / FakeStream 读面板值profile-sync 保留了这些输入框)
maxTokens: s.nccsMaxTokens ?? profile.maxTokens ?? 65500,
temperature: s.nccsTemperature ?? profile.temperature ?? 1.0,
maxTokens: profile.maxTokens ?? 65500,
temperature: profile.temperature ?? 1.0,
customParams: profile.customParams ?? {},
tavernProfile: '',
useFakeStream: s.nccsFakeStreamEnabled ?? false,
};
@@ -67,6 +68,7 @@ export async function getNccsApiSettings() {
model: s.nccsModel || '',
maxTokens: s.nccsMaxTokens ?? 8192,
temperature: s.nccsTemperature ?? 1,
customParams: {},
tavernProfile: s.nccsTavernProfile || '',
useFakeStream: s.nccsFakeStreamEnabled || false,
};
@@ -94,7 +96,7 @@ export async function callNccsAI(messages, options = {}) {
if (finalOptions.apiMode !== 'sillytavern_preset') {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Nccs外交部] API配置不完整无法调用AI");
toastr.error("API配置不完整请检查URL、Key和模型配置。", "Nccs-外交部");
toastr.error("并发模块NCCS未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 NCCS 独立设置。", "Amily2-NCCS 未配置");
return null;
}
} else {
@@ -187,8 +189,10 @@ function normalizeApiResponse(responseData) {
}
async function callNccsOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
top_p: options.top_p || 1,
...(options.customParams || {}),
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -197,7 +201,6 @@ async function callNccsOpenAITest(messages, options) {
stream: !!options.stream,
max_tokens: 8192,
temperature: 1,
top_p: options.top_p || 1,
};
if (!isGoogleApi) {
@@ -244,7 +247,8 @@ async function callNccsSillyTavernPreset(messages, options) {
const result = await context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
8192
8192,
options.customParams || {}
);
return normalizeApiResponse(result);

View File

@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
let ChatCompletionService = undefined;
try {
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
export async function getNgmsApiSettings() {
const s = extension_settings[extensionName] || {};
// 优先读取 'ngms' 槽位分配的 Profile仅接管连接参数
// 优先读取 'ngms' 槽位分配的 Profileprofile 一旦分配即权威,旧 slider 残值不再覆盖
const profile = await getSlotProfile('ngms');
if (profile) {
return {
@@ -55,9 +56,9 @@ export async function getNgmsApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
// 温度 / MaxTokens / FakeStream 读面板值
maxTokens: s.ngmsMaxTokens ?? profile.maxTokens ?? 65500,
temperature: s.ngmsTemperature ?? profile.temperature ?? 1.0,
maxTokens: profile.maxTokens ?? 65500,
temperature: profile.temperature ?? 1.0,
customParams: profile.customParams ?? {},
tavernProfile: '',
useFakeStream: s.ngmsFakeStreamEnabled ?? false,
};
@@ -71,6 +72,7 @@ export async function getNgmsApiSettings() {
model: s.ngmsModel || '',
maxTokens: s.ngmsMaxTokens ?? 30000,
temperature: s.ngmsTemperature ?? 1.0,
customParams: {},
tavernProfile: s.ngmsTavernProfile || '',
useFakeStream: s.ngmsFakeStreamEnabled || false,
};
@@ -101,7 +103,7 @@ export async function callNgmsAI(messages, options = {}) {
if (finalOptions.apiMode !== 'sillytavern_preset') {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Ngms外交部] API配置不完整无法调用AI");
toastr.error("API配置不完整请检查URL、Key和模型配置。", "Ngms-外交部");
toastr.error("总结模块NGMS未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 NGMS 独立设置。", "Amily2-NGMS 未配置");
return null;
}
} else {
@@ -221,9 +223,11 @@ async function fetchFakeStream(url, opts) {
}
async function callNgmsOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
top_p: options.top_p || 1,
...(options.customParams || {}),
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -232,7 +236,6 @@ async function callNgmsOpenAITest(messages, options) {
stream: !!options.stream,
max_tokens: options.maxTokens || 30000,
temperature: options.temperature || 1,
top_p: options.top_p || 1,
};
if (!isGoogleApi) {
@@ -312,7 +315,8 @@ async function callNgmsSillyTavernPreset(messages, options) {
responsePromise = context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
options.maxTokens || 4000
options.maxTokens || 4000,
options.customParams || {}
);
} finally {

View File

@@ -4,6 +4,7 @@ import { extensionName } from "../../utils/settings.js";
import { amilyHelper } from '../../core/tavern-helper/main.js';
import { getSlotProfile, providerToApiMode } from './api-resolver.js';
import { configManager } from '../../utils/config/ConfigManager.js';
import { detectVendor } from '../../utils/api-vendor.js';
let ChatCompletionService = undefined;
try {
@@ -47,7 +48,7 @@ function normalizeApiResponse(responseData) {
export async function getSybdApiSettings() {
const s = extension_settings[extensionName] || {};
// 优先读取 'sybd' 槽位分配的 Profile
// 优先读取 'sybd' 槽位分配的 Profileprofile 一旦分配即权威slider 残值不再覆盖)
const profile = await getSlotProfile('sybd');
if (profile) {
return {
@@ -55,8 +56,9 @@ export async function getSybdApiSettings() {
apiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
model: profile.model,
maxTokens: s.sybdMaxTokens ?? profile.maxTokens ?? 4000,
temperature: s.sybdTemperature ?? profile.temperature ?? 0.7,
maxTokens: profile.maxTokens ?? 4000,
temperature: profile.temperature ?? 0.7,
customParams: profile.customParams ?? {},
tavernProfile: '',
};
}
@@ -69,6 +71,7 @@ export async function getSybdApiSettings() {
model: s.sybdModel || '',
maxTokens: s.sybdMaxTokens || 4000,
temperature: s.sybdTemperature || 0.7,
customParams: {},
tavernProfile: s.sybdTavernProfile || '',
};
}
@@ -95,7 +98,7 @@ export async function callSybdAI(messages, options = {}) {
if (finalOptions.apiMode !== 'sillytavern_preset') {
if (!finalOptions.apiUrl || !finalOptions.model || !finalOptions.apiKey) {
console.warn("[Amily2-Sybd外交部] API配置不完整无法调用AI");
toastr.error("API配置不完整请检查URL、Key和模型配置。", "Sybd-外交部");
toastr.error("术语表填写SYBD未配置 API 连接配置,请前往 API 连接配置面板分配 profile 或填写 SYBD 独立设置。", "Amily2-SYBD 未配置");
return null;
}
}
@@ -159,9 +162,11 @@ export async function callSybdAI(messages, options = {}) {
}
async function callSybdOpenAITest(messages, options) {
const isGoogleApi = options.apiUrl.includes('googleapis.com');
const isGoogleApi = (await detectVendor(options.apiUrl)) === 'google';
const body = {
top_p: options.top_p || 1,
...(options.customParams || {}),
chat_completion_source: 'openai',
messages: messages,
model: options.model,
@@ -170,7 +175,6 @@ async function callSybdOpenAITest(messages, options) {
stream: false,
max_tokens: options.maxTokens || 30000,
temperature: options.temperature || 1,
top_p: options.top_p || 1,
};
if (!isGoogleApi) {
@@ -244,7 +248,8 @@ async function callSybdSillyTavernPreset(messages, options) {
responsePromise = context.ConnectionManagerRequestService.sendRequest(
targetProfile.id,
messages,
options.maxTokens || 4000
options.maxTokens || 4000,
options.customParams || {}
);
} finally {

View File

@@ -4,6 +4,7 @@ import { characters, this_chid, saveSettingsDebounced, getCharacters } from "/sc
import { world_names } from "/scripts/world-info.js";
import { getApiConfig, setApiConfig, testConnection, fetchModels } from "./api.js";
import { tools } from "./tools.js";
import { syncSlot } from "../../ui/profile-sync.js";
const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`;
@@ -42,6 +43,7 @@ export async function openAutoCharCardWindow() {
try {
populateDropdowns();
loadApiSettings();
await syncSlot('autoCharCard');
renderRulesList();
renderSessionsList();
restoreChatHistory();

View File

@@ -15,7 +15,7 @@ import { compatibleWriteToLorebook } from "./tavernhelper-compatibility.js";
import { ingestTextToHanlinyuan } from "./rag-processor.js";
import { showSummaryModal, showHtmlModal } from "../ui/page-window.js";
import { getPresetPrompts, getMixedOrder } from '../PresetSettings/index.js';
import { callAI, generateRandomSeed } from "./api.js";
import { generateRandomSeed } from "./api.js";
import { callNgmsAI } from "./api/Ngms_api.js";
import { executeAutoHide } from "./autoHideManager.js";
import { resolveHistoriographyRuleConfig } from "../utils/config/RuleProfileManager.js";
@@ -384,7 +384,9 @@ async function getSummary(formattedHistory, toastTitle, retryCount = 0) {
}
}
const summary = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages);
// 历史总结统一走 NGMS slotngms 未配置时 callNgmsAI 自带模块名错误提示。
// 旧 ngmsEnabled 三元式 fallback 到 main 的设计已在主 API 移除后失效。
const summary = await callNgmsAI(messages);
console.log('[大史官-微言录] AI回复的全部内容:', summary);
if (!summary || !summary.trim()) {
@@ -603,7 +605,8 @@ export async function executeRefinement(worldbook, loreKey) {
const getRefinedContent = async (retryCount = 0) => {
toastr.info("正在召唤模型进行内容精炼...", "宏史卷重铸");
const content = settings.ngmsEnabled ? await callNgmsAI(messages) : await callAI(messages);
// 历史总结统一走 NGMS slotngms 未配置时 callNgmsAI 自带错误提示。
const content = await callNgmsAI(messages);
if (!content || !content.trim()) {
const maxRetries = settings.historiographyMaxRetries ?? 2;

View File

@@ -33,7 +33,7 @@ export async function getEmbedRetrievalSettings() {
const profile = await getSlotProfile('ragEmbed');
if (profile) {
return {
apiEndpoint: 'custom',
apiEndpoint: profile.provider === 'google' ? 'google_direct' : 'custom',
customApiUrl: profile.apiUrl,
apiKey: profile.apiKey ?? '',
embeddingModel: profile.model,

View File

@@ -0,0 +1,190 @@
/**
* @file Action: applyOperations —— 表格操作推演核心。
*
* 输入:基准 state + Operation[]
* 输出:新 state深拷贝+ Change[] 变更记录
*
* 不依赖任何 formatter / store / persistence —— 纯函数。
* 所有 formatter (legacy / json / toolcall) 解析完都吐 Operation[] 给本函数。
*
* 历史来源:从 executor.js 中 insertRow / updateRow / deleteRow 三个内部函数
* 抽出行为完全等价。executeCommands 改造为parse 文本 → ops → 调本函数。
*
* 关键行为约定(不要随便改,否则破坏老存档):
* - 入参 state 不被修改;返回的 state 是 JSON 深拷贝
* - updateRow 的 rowIndex 越界 → 自动转换为 insertRow历史智能修正
* - deleteRow 是延迟删除rowStatuses[rowIndex] = 'pending-deletion',行不实际从 rows 中移除
* - insertRow 的 changes 用 type='update'(每个被填的单元格一条),不要发明 'insert'
*
* @typedef {import('../dto/Table.js').TableState} TableState
* @typedef {import('../dto/Operation.js').Operation} Operation
* @typedef {import('../dto/Operation.js').InsertRowOperation} InsertRowOperation
* @typedef {import('../dto/Operation.js').UpdateRowOperation} UpdateRowOperation
* @typedef {import('../dto/Operation.js').DeleteRowOperation} DeleteRowOperation
* @typedef {import('../dto/Change.js').Change} Change
*/
import { log } from '../logger.js';
/**
* 在表格末尾插入一行。in-place mutation调用方已 clone
* @param {TableState} state
* @param {number} tableIndex
* @param {Object<string, string>} data
* @returns {{ state: TableState, changes: Change[] }}
*/
function _insertRow(state, tableIndex, data) {
if (!state[tableIndex]) {
log(`AI指令错误尝试在不存在的表格索引 ${tableIndex} 中插入行。`, 'error');
return { state, changes: [] };
}
if (typeof data !== 'object' || data === null) {
log(`AI指令错误insertRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
return { state, changes: [] };
}
const table = state[tableIndex];
const colCount = table.headers.length;
const newRow = Array(colCount).fill('');
/** @type {Change[]} */
const changes = [];
const newRowIndex = table.rows.length;
for (const colIndex in data) {
const cIndex = parseInt(colIndex, 10);
if (cIndex < colCount) {
newRow[cIndex] = data[colIndex];
changes.push({ type: 'update', tableIndex, rowIndex: newRowIndex, colIndex: cIndex });
}
}
table.rows.push(newRow);
// 同步更新 rowStatuses
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length - 1).fill('normal');
}
table.rowStatuses.push('normal');
return { state, changes };
}
/**
* 更新指定行。in-place mutation。
* 历史智能修正rowIndex 越界自动降级为 insertRow。
* @param {TableState} state
* @param {number} tableIndex
* @param {number} rowIndex
* @param {Object<string, string>} data
* @returns {{ state: TableState, changes: Change[] }}
*/
function _updateRow(state, tableIndex, rowIndex, data) {
if (!state[tableIndex]) {
log(`AI指令错误尝试更新不存在的表格 ${tableIndex}`, 'error');
return { state, changes: [] };
}
if (typeof data !== 'object' || data === null) {
log(`AI指令错误updateRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
return { state, changes: [] };
}
const table = state[tableIndex];
if (rowIndex >= table.rows.length) {
log(`AI指令修正updateRow 的行索引 ${rowIndex} 超出范围,自动转换为 insertRow。`, 'warn');
return _insertRow(state, tableIndex, data);
}
const row = table.rows[rowIndex];
/** @type {Change[]} */
const changes = [];
for (const colIndex in data) {
const cIndex = parseInt(colIndex, 10);
if (cIndex < row.length) {
row[cIndex] = data[colIndex];
changes.push({ type: 'update', tableIndex, rowIndex, colIndex: cIndex });
}
}
return { state, changes };
}
/**
* 标记指定行为待删除延迟删除。in-place mutation。
* 不从 rows 实际移除commitPendingDeletions 才会真正 splice。
* @param {TableState} state
* @param {number} tableIndex
* @param {number} rowIndex
* @returns {{ state: TableState, changes: Change[] }}
*/
function _deleteRow(state, tableIndex, rowIndex) {
const table = state[tableIndex];
if (!table || !table.rows[rowIndex]) {
log(`AI指令错误尝试删除不存在的表格 ${tableIndex} 或行 ${rowIndex}`, 'error');
return { state, changes: [] };
}
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length).fill('normal');
}
if (table.rowStatuses[rowIndex] !== 'pending-deletion') {
table.rowStatuses[rowIndex] = 'pending-deletion';
/** @type {Change[]} */
const changes = [{ type: 'delete', tableIndex, rowIndex }];
return { state, changes };
}
return { state, changes: [] };
}
/** @type {Object<string, (state: TableState, op: Operation) => { state: TableState, changes: Change[] }>} */
const HANDLERS = {
insertRow: (state, op) => _insertRow(state, op.tableIndex, /** @type {InsertRowOperation} */(op).data),
updateRow: (state, op) => _updateRow(state, op.tableIndex, /** @type {UpdateRowOperation} */(op).rowIndex, /** @type {UpdateRowOperation} */(op).data),
deleteRow: (state, op) => _deleteRow(state, op.tableIndex, /** @type {DeleteRowOperation} */(op).rowIndex),
};
/**
* 把一组操作推演到 state 上。
*
* @param {TableState} initialState
* @param {Operation[]} operations
* @returns {{ state: TableState, changes: Change[] }}
*/
export function applyOperations(initialState, operations) {
if (!Array.isArray(operations) || operations.length === 0) {
return { state: initialState, changes: [] };
}
let state = JSON.parse(JSON.stringify(initialState));
/** @type {Change[]} */
let allChanges = [];
for (const op of operations) {
if (!op || typeof op !== 'object' || typeof op.op !== 'string') {
log(`跳过非法操作: ${JSON.stringify(op)}`, 'warn');
continue;
}
const handler = HANDLERS[op.op];
if (!handler) {
log(`未知操作类型: ${op.op}`, 'error');
continue;
}
try {
const result = handler(state, op);
state = result.state;
if (result.changes && result.changes.length > 0) {
allChanges = allChanges.concat(result.changes);
}
const opLabel = op.op + '(' + op.tableIndex
+ (typeof (/** @type {any} */(op)).rowIndex === 'number' ? `, ${(/** @type {any} */(op)).rowIndex}` : '')
+ ')';
log(`成功推演操作: ${opLabel}`, 'success');
} catch (e) {
log(`推演操作 ${op.op} 时发生运行时错误: ${e.message}`, 'error');
}
}
return { state, changes: allChanges };
}

View File

@@ -125,8 +125,8 @@ async function callTableModel(messages) {
}
return result;
} else {
log('使用默认 API 进行表格填充...', 'info');
const result = await callAI(messages);
log('使用 tableFilling slot 进行表格填充...', 'info');
const result = await callAI(messages, { slot: 'tableFilling' });
if (!result) {
throw new Error('API返回内容为空。');
}

View File

@@ -0,0 +1,21 @@
/**
* applyOperations 推演完成后吐出的变更记录。供高亮、SuperMemory 同步、UI 刷新使用。
*
* 注意 type 只有 'update' 和 'delete' 两种 —— insertRow 在 executor.js 历史实现里
* 也吐 type='update'(每个被填的单元格一条),不要发明 'insert' type。
*
* @typedef {Object} UpdateChange
* @property {'update'} type
* @property {number} tableIndex
* @property {number} rowIndex
* @property {number} colIndex
*
* @typedef {Object} DeleteChange
* @property {'delete'} type
* @property {number} tableIndex
* @property {number} rowIndex
*
* @typedef {UpdateChange | DeleteChange} Change
*/
export {};

View File

@@ -0,0 +1,26 @@
/**
* LLM 输出的统一动作格式。无论 formatter 是 legacy / json / toolcall
* 解析完都吐 Operation[],下游 applyOperations 不关心来源。
*
* data 字段的 key 是列索引的字符串形式('0', '1', ...),与 executor.js 历史行为对齐。
*
* @typedef {Object} InsertRowOperation
* @property {'insertRow'} op
* @property {number} tableIndex
* @property {Object<string, string>} data { [colIndex]: cellValue }
*
* @typedef {Object} UpdateRowOperation
* @property {'updateRow'} op
* @property {number} tableIndex
* @property {number} rowIndex
* @property {Object<string, string>} data
*
* @typedef {Object} DeleteRowOperation
* @property {'deleteRow'} op
* @property {number} tableIndex
* @property {number} rowIndex
*
* @typedef {InsertRowOperation | UpdateRowOperation | DeleteRowOperation} Operation
*/
export {};

View File

@@ -0,0 +1,38 @@
/**
* @file 表格相关数据形状DTO
* 对应运行时存于 message.extra.amily2_tables_data 的结构。
*/
/**
* 单元格内容;空值约定为空串而非 null/undefined。
* @typedef {string} Cell
*/
/**
* 行状态。'pending-deletion' 表示已标记待删除(延迟删除机制)。
* @typedef {'normal' | 'pending-deletion'} RowStatus
*/
/**
* 单张表格。
* @typedef {Object} Table
* @property {string} name 表格名(唯一标识 + UI 显示名)
* @property {string[]} headers 列头数组,长度 = 列数
* @property {Cell[][]} rows 行数据二维数组rows[i].length = headers.length
* @property {RowStatus[]} [rowStatuses] 行状态数组,与 rows 等长
* @property {(number|null)[]} [columnWidths] 列宽数组UI 用),与 headers 等长null 表示自适应
* @property {string} [note] 表格说明
* @property {string} [rule_add] 添加行规则(自然语言)
* @property {string} [rule_delete] 删除行规则
* @property {string} [rule_update] 更新行规则
* @property {Object<string, number>} [charLimitRules] 多列字符限制:{ "colIndexStr": maxChars }
* @property {number} [rowLimitRule] 行数上限0 表示不限
* @property {number} [simplifyRowThreshold] 历史行简化阈值0 表示不简化
*/
/**
* 表格集合 = 全局状态。
* @typedef {Table[]} TableState
*/
export {};

View File

@@ -0,0 +1,9 @@
/**
* @file TableState 的实际定义已合并至 ./Table.js与 Table 共处一处便于阅读)。
* 本文件保留为转发别名,供需要按 dto 名称单独导入的消费方使用:
* /** @typedef {import('./TableState.js').TableState} TableState *\/
*
* @typedef {import('./Table.js').TableState} TableState
*/
export {};

View File

@@ -1,100 +1,31 @@
/**
* @file 旧版 <Amily2Edit> 文本格式的解析器 + executeCommands 入口。
*
* Phase 0 重构后职责收窄:
* - 仅负责把 LLM 返回的文本块解析成 Operation[]legacy formatter 角色)
* - 推演下推到 actions/applyOperations.js本文件不再持有 insertRow/updateRow/deleteRow 实现
*
* 对外 API
* - parseToOperations(text) : 纯解析,文本 → Op[]Phase B legacy formatter 直接复用)
* - executeCommands(text, state) : 解析 + 推演,返回历史 shape { finalState, hasChanges, changes }
*
* 等 Phase B 引入 formatters/ 目录后,本文件改名为 formatters/legacy.js。
*
* @typedef {import('./dto/Operation.js').Operation} Operation
* @typedef {import('./dto/Table.js').TableState} TableState
*/
import { log } from './logger.js';
import { applyOperations } from './actions/applyOperations.js';
function insertRow(state, tableIndex, data) {
if (!state[tableIndex]) {
log(`AI指令错误尝试在不存在的表格索引 ${tableIndex} 中插入行。`, 'error');
return { state, changes: [] };
}
// 【安全检查】确保 data 是对象
if (typeof data !== 'object' || data === null) {
log(`AI指令错误insertRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
return { state, changes: [] };
}
const table = state[tableIndex];
const colCount = table.headers.length;
const newRow = Array(colCount).fill('');
const changes = [];
const newRowIndex = table.rows.length;
for (const colIndex in data) {
const cIndex = parseInt(colIndex, 10);
if (cIndex < colCount) {
newRow[cIndex] = data[colIndex];
changes.push({ type: 'update', tableIndex, rowIndex: newRowIndex, colIndex: cIndex });
}
}
table.rows.push(newRow);
// 同步更新 rowStatuses
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length - 1).fill('normal');
}
table.rowStatuses.push('normal');
return { state, changes };
}
function updateRow(state, tableIndex, rowIndex, data) {
if (!state[tableIndex]) {
log(`AI指令错误尝试更新不存在的表格 ${tableIndex}`, 'error');
return { state, changes: [] };
}
// 【安全检查】确保 data 是对象
if (typeof data !== 'object' || data === null) {
log(`AI指令错误updateRow 的 data 参数必须是对象,实际收到: ${typeof data} (${data})`, 'error');
return { state, changes: [] };
}
const table = state[tableIndex];
if (rowIndex >= table.rows.length) {
log(`AI指令修正updateRow 的行索引 ${rowIndex} 超出范围,自动转换为 insertRow。`, 'warn');
return insertRow(state, tableIndex, data);
}
const row = table.rows[rowIndex];
const changes = [];
for (const colIndex in data) {
const cIndex = parseInt(colIndex, 10);
if (cIndex < row.length) {
row[cIndex] = data[colIndex];
changes.push({ type: 'update', tableIndex, rowIndex, colIndex: cIndex });
}
}
return { state, changes };
}
function deleteRow(state, tableIndex, rowIndex) {
const table = state[tableIndex];
if (!table || !table.rows[rowIndex]) {
log(`AI指令错误尝试删除不存在的表格 ${tableIndex} 或行 ${rowIndex}`, 'error');
return { state, changes: [] };
}
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length).fill('normal');
}
if (table.rowStatuses[rowIndex] !== 'pending-deletion') {
table.rowStatuses[rowIndex] = 'pending-deletion';
const changes = [{ type: 'delete', tableIndex, rowIndex }];
return { state, changes };
}
return { state, changes: [] };
}
const allowedFunctions = {
insertRow,
updateRow,
deleteRow,
};
const ALLOWED_FN_NAMES = new Set(['insertRow', 'updateRow', 'deleteRow']);
/**
* 把单行函数调用文本解析为 { name, args } 中间表示。
* 内部用不导出。args 是位置参数数组,待 _argsToOperation 转成 Operation 对象。
* @param {string} callString
* @returns {{ name: string, args: any[] } | null}
*/
function parseFunctionCall(callString) {
const match = callString.trim().match(/(\w+)\((.*)\)/);
if (!match) {
@@ -105,7 +36,7 @@ function parseFunctionCall(callString) {
const functionName = match[1];
const argsString = match[2];
if (!allowedFunctions[functionName]) {
if (!ALLOWED_FN_NAMES.has(functionName)) {
log(`检测到非法函数调用: "${functionName}"。已阻止执行。`, 'error');
return null;
}
@@ -116,11 +47,11 @@ function parseFunctionCall(callString) {
let currentArg = '';
let inQuote = false;
let quoteChar = '';
let braceDepth = 0;
let braceDepth = 0;
for (let i = 0; i < argsString.length; i++) {
const char = argsString[i];
if ((char === '"' || char === "'") && (i === 0 || argsString[i-1] !== '\\')) {
if (!inQuote) {
inQuote = true;
@@ -164,7 +95,7 @@ function parseValue(val) {
if (val === 'null') return null;
if (val === 'undefined') return undefined;
if (!isNaN(Number(val)) && val !== '') return Number(val);
if (val.startsWith('"') && val.endsWith('"')) {
try { return JSON.parse(val); } catch (e) { return val.slice(1, -1); }
}
@@ -203,14 +134,14 @@ function parseValue(val) {
function tryParseObject(str) {
if (!str.startsWith('{') || !str.endsWith('}')) return null;
let content = str.slice(1, -1);
const result = {};
let hasMatch = false;
const strings = [];
let placeholderIndex = 0;
// 提取字符串并替换为占位符,避免正则在字符串内部匹配
const stringRegex = /"([^"\\]|\\.)*"|'([^'\\]|\\.)*'/g;
content = content.replace(stringRegex, (match) => {
@@ -219,36 +150,36 @@ function tryParseObject(str) {
placeholderIndex++;
return placeholder;
});
// 匹配键:(开头或逗号/分号/冒号) + (数字 或 字母数字下划线 或 占位符) + 冒号
const keyRegex = /(?:^|[,;:]+\s*)(?:(\d+)|([a-zA-Z0-9_]+)|(__STR_\d+__))\s*:/g;
let match;
let lastIndex = 0;
let lastKey = null;
while ((match = keyRegex.exec(content)) !== null) {
hasMatch = true;
if (lastKey !== null) {
let valStr = content.slice(lastIndex, match.index).trim();
valStr = valStr.replace(/[,;:]+$/, '').trim();
let actualKey = restoreStrings(lastKey, strings);
result[actualKey] = restoreStrings(valStr, strings);
}
lastKey = match[1] || match[2] || match[3];
lastIndex = match.index + match[0].length;
}
if (lastKey !== null) {
let valStr = content.slice(lastIndex).trim();
valStr = valStr.replace(/[,;:]+$/, '').trim();
let actualKey = restoreStrings(lastKey, strings);
result[actualKey] = restoreStrings(valStr, strings);
}
return hasMatch ? result : null;
}
@@ -269,51 +200,77 @@ function cleanValueStr(str) {
return str;
}
/**
* 把 parseFunctionCall 返回的位置参数数组转成 Operation 对象。
* @param {string} name
* @param {any[]} args
* @returns {Operation | null}
*/
function _argsToOperation(name, args) {
if (name === 'insertRow') {
return /** @type {Operation} */ ({ op: 'insertRow', tableIndex: args[0], data: args[1] });
}
if (name === 'updateRow') {
return /** @type {Operation} */ ({ op: 'updateRow', tableIndex: args[0], rowIndex: args[1], data: args[2] });
}
if (name === 'deleteRow') {
return /** @type {Operation} */ ({ op: 'deleteRow', tableIndex: args[0], rowIndex: args[1] });
}
return null;
}
export function executeCommands(aiResponseText, initialState) {
/**
* 把 LLM 返回的文本块解析为 Operation[]。
* 不在文本中找到 <Amily2Edit> 块时返回空数组(不视为错误)。
*
* @param {string} aiResponseText
* @returns {Operation[]}
*/
export function parseToOperations(aiResponseText) {
const commandBlockRegex = /<Amily2Edit>([\s\S]*?)<\/Amily2Edit>/;
const match = aiResponseText.match(commandBlockRegex);
const match = (aiResponseText || '').match(commandBlockRegex);
if (!match) return [];
if (!match) {
return { finalState: initialState, hasChanges: false, changes: [] };
}
log('检测到AI指令块开始推演...', 'info');
const commandBlock = match[1].replace(/<!--|-->/g, '').trim();
if (!commandBlock) {
return { finalState: initialState, hasChanges: false, changes: [] };
}
if (!commandBlock) return [];
const commands = commandBlock.split('\n').filter(line => line.trim() !== '');
if (commands.length === 0) {
if (commands.length === 0) return [];
/** @type {Operation[]} */
const ops = [];
for (const commandString of commands) {
const trimmed = commandString.trim();
if (!trimmed.startsWith('insertRow(') &&
!trimmed.startsWith('updateRow(') &&
!trimmed.startsWith('deleteRow(')) {
continue;
}
const parsed = parseFunctionCall(trimmed);
if (!parsed) continue;
const op = _argsToOperation(parsed.name, parsed.args);
if (op) ops.push(op);
}
return ops;
}
/**
* 解析 LLM 文本指令并推演到 state 上。
* 历史 API调用方期望返回 { finalState, hasChanges, changes }。
*
* @param {string} aiResponseText
* @param {TableState} initialState
* @returns {{ finalState: TableState, hasChanges: boolean, changes: import('./dto/Change.js').Change[] }}
*/
export function executeCommands(aiResponseText, initialState) {
const ops = parseToOperations(aiResponseText);
if (ops.length === 0) {
return { finalState: initialState, hasChanges: false, changes: [] };
}
let currentState = JSON.parse(JSON.stringify(initialState));
let allChanges = [];
log(`检测到 ${ops.length} 条 AI 指令,开始推演...`, 'info');
commands.forEach(commandString => {
const trimmedCommand = commandString.trim();
if (trimmedCommand.startsWith('insertRow(') ||
trimmedCommand.startsWith('deleteRow(') ||
trimmedCommand.startsWith('updateRow('))
{
const parsed = parseFunctionCall(trimmedCommand);
if (parsed) {
try {
const result = allowedFunctions[parsed.name](currentState, ...parsed.args);
currentState = result.state;
if (result.changes && result.changes.length > 0) {
allChanges = allChanges.concat(result.changes);
}
log(`成功推演指令: ${commandString}`, 'success');
} catch (e) {
log(`推演指令 "${commandString}" 时发生运行时错误: ${e.message}`, 'error');
}
}
}
});
const hasChanges = allChanges.length > 0;
return { finalState: currentState, hasChanges, changes: allChanges };
const { state, changes } = applyOperations(initialState, ops);
return { finalState: state, hasChanges: changes.length > 0, changes };
}

View File

@@ -0,0 +1,98 @@
/**
* @file ITablePersistence 实现 —— 表格状态的持久化层。
*
* 替代 manager.js 中:
* - saveStateToMessage(state, targetMessage) → 写入指定消息的 extra
* - 16 处复制样板getContext + saveStateToMessage + saveChat / saveChatDebounced
* 被合并为 commitToLastMessage / commitToLastMessageAsync 两个函数
*
* 不读取 store调用方显式传入要持久化的 state。这样
* - 测试容易(不依赖全局单例)
* - 万一未来需要在事务边界提交"快照"而非当前 state接口已就位
*
* @typedef {import('../dto/Table.js').TableState} TableState
*/
import { saveChat } from '/script.js';
import { getContext } from '/scripts/extensions.js';
import { saveChatDebounced } from '../../../utils/utils.js';
import { log } from '../logger.js';
/**
* message.extra 中存储表格状态的 key。
* 此值不能轻易改 —— 所有历史聊天的存档都用这个 key。
*/
export const TABLE_DATA_KEY = 'amily2_tables_data';
/**
* 把状态深拷贝写入指定消息的 metadata。
* 不主动调用 saveChat —— 写盘时机由调用方决定。
*
* @param {TableState | null} stateToSave
* @param {Object} targetMessage
* @returns {boolean} 是否写入成功
*/
export function saveStateToMessage(stateToSave, targetMessage) {
if (!stateToSave || !targetMessage) {
log('缺少状态或目标消息,无法保存。', 'error');
return false;
}
if (!targetMessage.extra) {
targetMessage.extra = {};
}
targetMessage.extra[TABLE_DATA_KEY] = JSON.parse(JSON.stringify(stateToSave));
log(`表格状态已准备写入消息 [${targetMessage.mes.substring(0, 20)}...]`, 'info');
return true;
}
/**
* 把 state 提交到 chat 最新一条消息并立即 saveChat。
*
* 该函数封装了 manager.js 中复制了 16 次的样板:
* const context = getContext();
* if (context.chat && context.chat.length > 0) {
* const lastMessage = context.chat[context.chat.length - 1];
* if (saveStateToMessage(state, lastMessage)) {
* saveChat();
* return;
* }
* }
* saveChatDebounced();
*
* @param {TableState | null} state
* @returns {boolean} true = 走 last-message commit 路径false = 降级到 debounced
*/
export function commitToLastMessage(state) {
const context = getContext();
if (context.chat && context.chat.length > 0) {
const lastMessage = context.chat[context.chat.length - 1];
if (saveStateToMessage(state, lastMessage)) {
saveChat();
return true;
}
}
saveChatDebounced();
return false;
}
/**
* commitToLastMessage 的 async 变体。
* deleteRow / restoreRow / rollbackState 等需要等 saveChat 完成后才做后续渲染的场景使用。
*
* @param {TableState | null} state
* @returns {Promise<boolean>}
*/
export async function commitToLastMessageAsync(state) {
const context = getContext();
if (context.chat && context.chat.length > 0) {
const lastMessage = context.chat[context.chat.length - 1];
if (saveStateToMessage(state, lastMessage)) {
await saveChat();
return true;
}
}
await saveChatDebounced();
return false;
}

View File

@@ -0,0 +1,117 @@
/**
* @file ITableStore 实现 —— 表格运行时状态的唯一所有者。
*
* 替代 manager.js 中三个 module-level 可变量:
* currentTablesState → 通过 getState/setState 访问
* highlightedCells → addHighlight/getHighlights/clearHighlights
* updatedTables → markTableUpdated/getUpdatedTables/clearUpdatedTables
*
* 本模块只承担"存",不触发任何副作用(不保存、不渲染、不发事件总线消息)。
* 副作用编排留给 Service 层 / Action 层。
*
* setState 会触发 subscribe 注册的回调,给 UI / SuperMemory 一个钩子,
* 但不直接 import UI保持 domain 纯度)。
*
* @typedef {import('../dto/Table.js').TableState} TableState
*/
import { log } from '../logger.js';
/** @type {TableState | null} */
let _state = null;
/** @type {Set<string>} 形如 "tableIndex-rowIndex-colIndex" */
const _highlights = new Set();
/** @type {Set<number>} 标记本周期内被改过的表格索引 */
const _updatedTables = new Set();
/** @type {Set<(state: TableState | null) => void>} */
const _listeners = new Set();
// ── 主状态 ────────────────────────────────────────────────────────────────
/**
* @returns {TableState | null}
*/
export function getState() {
return _state;
}
/**
* 直接替换全局状态。注意:不做深拷贝,调用方需自己负责传入的 state 不被外部 mutate。
* @param {TableState | null} newState
*/
export function setState(newState) {
_state = newState;
_notify();
}
/**
* 订阅 setState 触发的变更通知。返回取消订阅函数。
* 仅在 setState 被调用时触发mutate 同一引用不会触发。
* @param {(state: TableState | null) => void} listener
* @returns {() => void}
*/
export function subscribe(listener) {
_listeners.add(listener);
return () => _listeners.delete(listener);
}
function _notify() {
for (const l of _listeners) {
try {
l(_state);
} catch (e) {
console.error('[TableStore] listener error:', e);
}
}
}
// ── 单元格高亮 ─────────────────────────────────────────────────────────────
/**
* @param {number} tableIndex
* @param {number} rowIndex
* @param {number} colIndex
*/
export function addHighlight(tableIndex, rowIndex, colIndex) {
_highlights.add(`${tableIndex}-${rowIndex}-${colIndex}`);
}
/**
* @returns {Set<string>}
*/
export function getHighlights() {
return _highlights;
}
export function clearHighlights() {
if (_highlights.size > 0) {
_highlights.clear();
log('已清除所有单元格高亮标记。', 'info');
}
}
// ── 更新过的表格标记 ───────────────────────────────────────────────────────
/**
* @param {number} tableIndex
*/
export function markTableUpdated(tableIndex) {
_updatedTables.add(tableIndex);
}
/**
* @returns {Set<number>}
*/
export function getUpdatedTables() {
return _updatedTables;
}
export function clearUpdatedTables() {
if (_updatedTables.size > 0) {
_updatedTables.clear();
log('已清除所有表格的更新标记。', 'info');
}
}

File diff suppressed because it is too large Load Diff

329
core/table-system/preset.js Normal file
View File

@@ -0,0 +1,329 @@
/**
* @file 表格预设的导入 / 导出 / 全局预设管理。
*
* 历史来源:从 manager.js 抽出
* - exportPreset / exportPresetFull → 调内部 exportPresetBase
* - importPreset → 接受 hooks 注入 SuperMemory 同步等副作用
* - clearGlobalPreset → 清除 extension_settings 中的全局预设
* - importGlobalPreset → 写入全局预设
*
* 设计要点:
* - 不内含 SuperMemory dispatch 逻辑(避免与 manager.js 循环依赖)
* - importPreset 接受 hooks: { onAfterApply, onImported },调用方注入需要的副作用
* - 所有持久化走 infra/persistence.js不再复制 saveStateToMessage 样板
*/
import { extension_settings, getContext } from '/scripts/extensions.js';
import { saveSettingsDebounced } from '/script.js';
import { extensionName } from '../../utils/settings.js';
import { log } from './logger.js';
import { getState, setState } from './infra/store.js';
import { saveStateToMessage, commitToLastMessage } from './infra/persistence.js';
import {
getBatchFillerRuleTemplate,
getBatchFillerFlowTemplate,
saveBatchFillerRuleTemplate,
saveBatchFillerFlowTemplate,
saveAiTemplate,
} from './templates.js';
/**
* @typedef {{
* onAfterApply?: () => void,
* onImported?: () => void
* }} ImportPresetHooks
*/
// ── 导出 ──────────────────────────────────────────────────────────────────
/**
* @param {boolean} includeData 是否包含 rows 实际数据
*/
function exportPresetBase(includeData = false) {
const state = getState();
if (!state) {
log('无法导出:当前表格状态为空。', 'error');
toastr.error('没有可导出的表格数据。');
return;
}
let tablesToExport;
let fileNameSuffix;
if (includeData) {
// 完整备份
tablesToExport = JSON.parse(JSON.stringify(state));
fileNameSuffix = '完整备份';
} else {
// 纯净预设:仅结构 + 规则,不带数据
tablesToExport = state.map(table => ({
name: table.name,
headers: table.headers,
columnWidths: table.columnWidths || [],
note: table.note,
rule_add: table.rule_add,
rule_delete: table.rule_delete,
rule_update: table.rule_update,
charLimitRules: table.charLimitRules || {},
rowLimitRule: table.rowLimitRule || 0,
// simplifyRowThreshold 不导出:与当前聊天进度强绑定的临时设置
rows: [],
rowStatuses: [],
}));
fileNameSuffix = '纯净预设';
}
const preset = {
version: 'Amily2-Table-Preset-v3.0-separated_templates',
batchFillerRuleTemplate: getBatchFillerRuleTemplate(),
batchFillerFlowTemplate: getBatchFillerFlowTemplate(),
tables: tablesToExport,
};
const blob = new Blob([JSON.stringify(preset, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Amily2-${fileNameSuffix}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log(`${fileNameSuffix}】已成功导出。`, 'success');
toastr.success(`${fileNameSuffix}】已开始下载。`, '导出成功');
}
export function exportPreset() {
exportPresetBase(false);
}
export function exportPresetFull() {
exportPresetBase(true);
}
// ── 导入 ──────────────────────────────────────────────────────────────────
/**
* 把导入的 tables 数组归一化(补字段 + 兼容旧版结构。in-place mutation。
*/
function _normalizeImportedTables(importedTables) {
importedTables.forEach(table => {
if (table.name === undefined || table.headers === undefined || table.rows === undefined) {
throw new Error(`导入的表格数据格式不正确: ${JSON.stringify(table)}`);
}
if (table.note === undefined) table.note = '无';
if (table.rule_add === undefined) table.rule_add = '允许';
if (table.rule_delete === undefined) table.rule_delete = '允许';
if (table.rule_update === undefined) table.rule_update = '允许';
// 多列规则兼容:旧 charLimitRule 单列对象 → 新 charLimitRules 对象映射
if (table.charLimitRule && !table.charLimitRules) {
table.charLimitRules = {};
if (table.charLimitRule.columnIndex !== -1 && table.charLimitRule.limit > 0) {
table.charLimitRules[table.charLimitRule.columnIndex] = table.charLimitRule.limit;
}
} else if (table.charLimitRules === undefined) {
table.charLimitRules = {};
}
delete table.charLimitRule;
// 延迟删除rowStatuses 必须存在
if (!table.rowStatuses) {
table.rowStatuses = Array(table.rows.length).fill('normal');
}
if (table.rowLimitRule === undefined) table.rowLimitRule = 0;
if (table.columnWidths === undefined) table.columnWidths = [];
});
}
/**
* 把导入的预设里的模板字段写回 extension_settings。版本兼容三档
* v3.0(separated) / v2.1(aiRule+aiFlow) / v2.0(aiTemplate)
*/
function _applyImportedTemplates(preset) {
if (preset.version === 'Amily2-Table-Preset-v3.0-separated_templates') {
saveBatchFillerRuleTemplate(preset.batchFillerRuleTemplate || '');
saveBatchFillerFlowTemplate(preset.batchFillerFlowTemplate || '');
saveAiTemplate(preset.injectionFlowTemplate || '');
} else if (preset.aiRuleTemplate !== undefined && preset.aiFlowTemplate !== undefined) {
saveBatchFillerRuleTemplate(preset.aiRuleTemplate || '');
saveBatchFillerFlowTemplate(preset.aiFlowTemplate || '');
saveAiTemplate(preset.aiFlowTemplate || '');
} else if (preset.aiTemplate) {
saveBatchFillerRuleTemplate('');
saveBatchFillerFlowTemplate(preset.aiTemplate || '');
saveAiTemplate(preset.aiTemplate || '');
} else {
log('导入的预设中缺少指令模板字段,模板将不会被更新。', 'warn');
}
}
/**
* 弹出文件选择 → 解析 JSON → 归一化 → 写入 store + 持久化。
*
* hooks.onAfterApply 在 setState 之后、saveChat 之前触发(用于注入 SuperMemory 同步等副作用)。
* hooks.onImported 在全部完成后触发UI 刷新)。
*
* @param {ImportPresetHooks | (() => void)} [hooksOrCallback] 兼容旧签名 importPreset(callback)
*/
export function importPreset(hooksOrCallback) {
/** @type {ImportPresetHooks} */
const hooks = typeof hooksOrCallback === 'function'
? { onImported: hooksOrCallback }
: (hooksOrCallback || {});
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = event => {
try {
const preset = JSON.parse(event.target.result);
if (!preset.version || !Array.isArray(preset.tables)) {
throw new Error('文件格式无效或缺少版本号/表格数据。');
}
const confirmation = window.confirm(
'【警告】\n\n导入操作将完全覆盖您当前的AI指令模板和所有表格包括结构和内容。\n\n此操作不可逆是否确定要继续'
);
if (!confirmation) {
log('用户取消了导入操作。', 'info');
toastr.info('导入操作已取消。');
return;
}
_applyImportedTemplates(preset);
const importedTables = preset.tables;
_normalizeImportedTables(importedTables);
setState(importedTables);
// 钩子:让调用方注入 SuperMemory 全量同步等副作用
if (typeof hooks.onAfterApply === 'function') {
try { hooks.onAfterApply(); } catch (e) {
log(`importPreset onAfterApply 抛错: ${e.message}`, 'error');
}
}
commitToLastMessage(getState());
log('导入的预设已强制写入最新消息并立即保存。', 'success');
log('预设已成功导入并应用。', 'success');
toastr.success('预设已成功导入!', '导入成功');
if (typeof hooks.onImported === 'function') {
try { hooks.onImported(); } catch (e) {
log(`importPreset onImported 抛错: ${e.message}`, 'error');
}
}
} catch (error) {
log(`导入预设失败: ${error.message}`, 'error');
toastr.error(`导入失败:${error.message}`, '错误');
}
};
reader.readAsText(file);
};
input.click();
}
// ── 全局预设 ──────────────────────────────────────────────────────────────
export function clearGlobalPreset() {
if (extension_settings[extensionName] && extension_settings[extensionName].global_table_preset) {
const confirmation = window.confirm(
'【清除全局预设】\n\n您确定要清除已设置的全局预设吗\n\n清除后新聊天将恢复使用扩展内置的默认表格模板。'
);
if (confirmation) {
delete extension_settings[extensionName].global_table_preset;
saveSettingsDebounced();
log('全局预设已被清除。', 'success');
toastr.success('全局预设已清除,新聊天将使用默认模板。', '操作成功');
} else {
log('用户取消了清除全局预设的操作。', 'info');
toastr.info('操作已取消。');
}
} else {
log('无需清除,当前未设置任何全局预设。', 'info');
toastr.info('当前没有设置全局预设。', '提示');
}
}
/**
* @param {(() => void) | undefined} onImported
*/
export function importGlobalPreset(onImported) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = event => {
try {
const preset = JSON.parse(event.target.result);
if (!preset.version || !Array.isArray(preset.tables)) {
throw new Error('文件格式无效或缺少版本号/表格数据。');
}
const confirmation = window.confirm(
'【全局预设导入】\n\n这将把选定的预设设置为所有新聊天的默认表格。\n\n此操作将覆盖任何已存在的全局预设是否确定'
);
if (!confirmation) {
log('用户取消了全局预设导入操作。', 'info');
toastr.info('操作已取消。');
return;
}
// 纯净副本:仅结构,不含 rows
const cleanTables = preset.tables.map(table => ({
name: table.name,
headers: table.headers,
note: table.note,
rule_add: table.rule_add,
rule_delete: table.rule_delete,
rule_update: table.rule_update,
rows: [],
}));
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].global_table_preset = {
version: preset.version,
tables: cleanTables,
batchFillerRuleTemplate: preset.batchFillerRuleTemplate,
batchFillerFlowTemplate: preset.batchFillerFlowTemplate,
};
saveSettingsDebounced();
_applyImportedTemplates(preset);
log('全局预设已成功导入并保存到扩展设置中。', 'success');
toastr.success('全局预设已设置!新聊天将默认使用此预设。', '设置成功');
if (typeof onImported === 'function') {
try { onImported(); } catch (e) {
log(`importGlobalPreset onImported 抛错: ${e.message}`, 'error');
}
}
} catch (error) {
log(`导入全局预设失败: ${error.message}`, 'error');
toastr.error(`导入失败:${error.message}`, '错误');
}
};
reader.readAsText(file);
};
input.click();
}

View File

@@ -0,0 +1,239 @@
/**
* @file Markdown/CSV 渲染 —— 把 TableState 渲染为 prompt 可用的字符串。
*
* 纯函数:吃 state、吐字符串。不读 store、不写盘、不发事件。
*
* 历史来源:从 manager.js 抽出
* - convertTablesToCsvString → tablesToCsv
* - convertSelectedTablesToCsvString → tablesToCsvWithSelection
* - convertTablesToCsvStringForContentOnly → tablesToCsvContentOnly
* - checkTableRules (内部) → _checkTableRules (内部)
*
* manager.js 保留同名 export 作 wrapper自动注入 getState()),所有外部调用点零改动。
*
* @typedef {import('./dto/Table.js').Table} Table
* @typedef {import('./dto/Table.js').TableState} TableState
*/
/**
* 检查表格规则违规,返回聚合警告字符串(多行)。
* 行数超限 + 多列字符限制超限。
* @param {Table} table
* @returns {string}
*/
function _checkTableRules(table) {
const warnings = [];
// 行数限制
if (table.rowLimitRule && table.rowLimitRule > 0 && table.rows.length > table.rowLimitRule) {
warnings.push(`【当前(${table.name})超出规定(${table.rowLimitRule})行,请结合剧情缩减至(${table.rowLimitRule})行以下,但切莫完全删除。】`);
}
// 多列字符限制
const charLimitRules = table.charLimitRules || {};
for (const colIndexStr in charLimitRules) {
const colIndex = parseInt(colIndexStr, 10);
const limit = charLimitRules[colIndex];
if (limit > 0 && colIndex >= 0 && colIndex < table.headers.length) {
const colName = table.headers[colIndex];
const offendingRows = [];
table.rows.forEach((row, rowIndex) => {
if (table.rowStatuses && table.rowStatuses[rowIndex] === 'pending-deletion') return;
const cellContent = row[colIndex] || '';
if (cellContent.length > limit) offendingRows.push(rowIndex);
});
if (offendingRows.length > 0) {
warnings.push(`【当前(${table.name})第(${offendingRows.join('、')})行(${colName})列,字符超出规定(${limit})字限制,请进行缩减。】`);
}
}
}
return warnings.join('\n');
}
/**
* 把单个 table 的"内容主体"(含 simplify 处理 + warnings写入到 fullString 末尾。
* 提取自三个渲染函数中重复的内层逻辑。
*
* @param {Table} table
* @param {string} tagName
* @returns {string}
*/
function _renderTableBody(table, tagName) {
let out = '';
const activeRows = table.rows.filter((row, i) => !table.rowStatuses || table.rowStatuses[i] !== 'pending-deletion');
if (activeRows.length === 0) {
out += '(该表当前内容为空)\n';
} else {
const simplifyThreshold = table.simplifyRowThreshold || 0;
let simplifiedCount = 0;
table.rows.forEach((row, rowIndex) => {
if (table.rowStatuses && table.rowStatuses[rowIndex] === 'pending-deletion') return;
// 历史内容简化:前 N 行用 ---已锁定--- 占位
if (simplifyThreshold > 0 && rowIndex < simplifyThreshold) {
if (simplifiedCount === 0) {
const placeholderCells = row.map(() => '---已锁定---');
out += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`;
out += `| ... | ${row.map(() => '...').join(' | ')} |\n`;
}
if (rowIndex === simplifyThreshold - 1) {
const placeholderCells = row.map(() => '---已锁定---');
out += `| ${rowIndex} | ${placeholderCells.join(' | ')} |\n`;
}
simplifiedCount++;
return;
}
if (Array.isArray(row)) {
const rowCells = row.map(cell => {
const cellContent = (cell === null || cell === undefined || cell === '') ? '未知' : String(cell);
return cellContent.replace(/\|/g, '');
});
out += `| ${rowIndex} | ${rowCells.join(' | ')} |\n`;
}
});
if (simplifiedCount > 0) {
out += `\n【系统提示】:表格前 ${simplifiedCount} 行(索引 0 到 ${simplifiedCount - 1})的历史内容已简化并锁定,无需读取或修改。请专注于后续行的内容。\n`;
}
}
return out;
}
/**
* 完整渲染:所有表格内容 + 规则 + 警告,注入到主流程 prompt。
* 对应 manager.js#convertTablesToCsvString。
*
* @param {TableState | null} state
* @returns {string}
*/
export function tablesToCsv(state) {
if (!state || state.length === 0) return '';
let fullString = '';
state.forEach((table, tableIndex) => {
// 标题
fullString += `\n* ${tableIndex}:${table.name}\n`;
// 说明
fullString += `【说明】:\n${table.note || '无'}\n`;
// 内容Markdown 表)
const tagName = table.name.replace(/\s/g, '') + '内容';
fullString += `<${tagName}>\n`;
const headerWithIndex = ['rowIndex', ...table.headers.map((h, i) => `${i}:${h}`)];
fullString += `| ${headerWithIndex.join(' | ')} |\n`;
fullString += `|${headerWithIndex.map(() => '---').join('|')}|\n`;
fullString += _renderTableBody(table, tagName);
// 警告
const warnings = _checkTableRules(table);
if (warnings) fullString += `${warnings}\n`;
fullString += `</${tagName}>\n`;
// 规则
fullString += `【增加】: ${table.rule_add || '允许'}\n`;
fullString += `【删除】: ${table.rule_delete || '允许'}\n`;
fullString += `【修改】: ${table.rule_update || '允许'}\n`;
if (tableIndex < state.length - 1) fullString += '\n---\n';
});
return fullString;
}
/**
* 选中态渲染:未选中的表格只展示表头作为索引参考;选中的展示完整内容。
* 对应 manager.js#convertSelectedTablesToCsvString。
*
* @param {TableState | null} state
* @param {number[]} selectedIndices
* @returns {string}
*/
export function tablesToCsvWithSelection(state, selectedIndices) {
if (!state || state.length === 0) return '';
const selected = Array.isArray(selectedIndices) ? selectedIndices : [];
let fullString = '';
state.forEach((table, tableIndex) => {
const isSelected = selected.includes(tableIndex);
// 标题
fullString += `\n* ${tableIndex}:${table.name}`;
if (!isSelected) fullString += ' (本表格无需重新整理,仅供参考)';
fullString += '\n';
// 说明
fullString += `【说明】:\n${table.note || '无'}\n`;
const tagName = table.name.replace(/\s/g, '') + '内容';
fullString += `<${tagName}>\n`;
const headerWithIndex = ['rowIndex', ...table.headers.map((h, i) => `${i}:${h}`)];
fullString += `| ${headerWithIndex.join(' | ')} |\n`;
fullString += `|${headerWithIndex.map(() => '---').join('|')}|\n`;
if (isSelected) {
fullString += _renderTableBody(table, tagName);
const warnings = _checkTableRules(table);
if (warnings) fullString += `${warnings}\n`;
} else {
fullString += '(此处省略未选中的表格内容,仅提供表头供索引参考)\n';
}
fullString += `</${tagName}>\n`;
// 规则
if (isSelected) {
fullString += `【增加】: ${table.rule_add || '允许'}\n`;
fullString += `【删除】: ${table.rule_delete || '允许'}\n`;
fullString += `【修改】: ${table.rule_update || '允许'}\n`;
} else {
fullString += `【操作权限】: 禁止修改此表格\n`;
}
if (tableIndex < state.length - 1) fullString += '\n---\n';
});
return fullString;
}
/**
* 仅内容渲染:不带规则、不带 rowIndex 列、不带说明。
* 用于"分步填表"和"优化中填表"模式下的 prompt 注入(只展示数据本身)。
* 对应 manager.js#convertTablesToCsvStringForContentOnly。
*
* @param {TableState | null} state
* @returns {string}
*/
export function tablesToCsvContentOnly(state) {
if (!state || state.length === 0) return '';
let outputString = '';
state.forEach(table => {
outputString += `\n<${table.name}>\n`;
// Markdown 表头
outputString += `| ${table.headers.join(' | ')} |\n`;
outputString += `|${table.headers.map(() => '---').join('|')}|\n`;
// 数据
const activeRows = table.rows.filter((row, i) => !table.rowStatuses || table.rowStatuses[i] !== 'pending-deletion');
if (activeRows.length > 0) {
activeRows.forEach(row => {
if (Array.isArray(row)) {
const rowContent = row.map(cell => (cell === null || cell === undefined || cell === '') ? ' ' : cell.toString());
outputString += `| ${rowContent.join(' | ')} |\n`;
}
});
} else {
outputString += '(该表当前内容为空)\n';
}
outputString += `</${table.name}>\n`;
});
return outputString.trim();
}

View File

@@ -71,8 +71,8 @@ export async function reorganizeTableContent(selectedTableIndices) {
console.log('[Amily2-重新整理] 使用 Nccs API 进行表格重整...');
rawContent = await callNccsAI(messages);
} else {
console.log('[Amily2-重新整理] 使用默认 API 进行表格重整...');
rawContent = await callAI(messages);
console.log('[Amily2-重新整理] 使用 tableFilling slot 进行表格重整...');
rawContent = await callAI(messages, { slot: 'tableFilling' });
}
if (!rawContent) {

View File

@@ -277,8 +277,8 @@ export async function fillWithSecondaryApi(latestMessage, forceRun = false) {
console.log('[Amily2-副API] 使用 Nccs API 进行分步填表...');
rawContent = await callNccsAI(messages);
} else {
console.log('[Amily2-副API] 使用默认 API 进行分步填表...');
rawContent = await callAI(messages);
console.log('[Amily2-副API] 使用 tableFilling slot 进行分步填表...');
rawContent = await callAI(messages, { slot: 'tableFilling' });
}
if (!rawContent) {

View File

@@ -0,0 +1,78 @@
/**
* @file 表格 prompt 模板的 getter/setter 集中点。
*
* 三套模板:
* - batch_filler_rule_template 规则模板(系统提示词部分)
* - batch_filler_flow_template 流程模板(含 {{{Amily2TableData}}} 占位符)
* - amily2_ai_template 注入模板(主 API 模式下走的注入)
*
* 所有读写都落到 extension_settings[extensionName]saveSettingsDebounced 触发持久化。
*
* 历史来源:从 manager.js 抽出
* - getBatchFillerRuleTemplate / saveBatchFillerRuleTemplate
* - getBatchFillerFlowTemplate / saveBatchFillerFlowTemplate
* - getAiFlowTemplateForInjection
* - saveAiTemplate / getAiTemplate
*/
import { extension_settings } from '/scripts/extensions.js';
import { saveSettingsDebounced } from '/script.js';
import { extensionName } from '../../utils/settings.js';
import { DEFAULT_AI_RULE_TEMPLATE, DEFAULT_AI_FLOW_TEMPLATE } from './settings.js';
/**
* @returns {string}
*/
export function getBatchFillerRuleTemplate() {
return extension_settings[extensionName]?.batch_filler_rule_template ?? DEFAULT_AI_RULE_TEMPLATE;
}
/**
* @param {string} template
*/
export function saveBatchFillerRuleTemplate(template) {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].batch_filler_rule_template = template;
saveSettingsDebounced();
}
/**
* @returns {string}
*/
export function getBatchFillerFlowTemplate() {
return extension_settings[extensionName]?.batch_filler_flow_template ?? DEFAULT_AI_FLOW_TEMPLATE;
}
/**
* @param {string} template
*/
export function saveBatchFillerFlowTemplate(template) {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].batch_filler_flow_template = template;
saveSettingsDebounced();
}
/**
* 主 API 模式下注入用的流程模板。与 batch_filler_flow_template 是两套独立配置。
* @returns {string}
*/
export function getAiFlowTemplateForInjection() {
return extension_settings[extensionName]?.amily2_ai_template ?? DEFAULT_AI_FLOW_TEMPLATE;
}
/**
* @param {string} template
*/
export function saveAiTemplate(template) {
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
extension_settings[extensionName].amily2_ai_template = template;
saveSettingsDebounced();
}
/**
* 别名 —— 历史 manager.js 同名函数,等价于 getAiFlowTemplateForInjection。
* @returns {string}
*/
export function getAiTemplate() {
return getAiFlowTemplateForInjection();
}