Files
ST-Amily2-Chat-Optimisation/core/auto-char-card/ui-bindings.js

1792 lines
61 KiB
JavaScript

import { extensionName } from "../../utils/settings.js";
import { AgentManager } from "./agent-manager.js";
import { characters, this_chid, saveSettingsDebounced, getCharacters } from "/script.js";
import { world_names } from "/scripts/world-info.js";
import { getApiConfig, setApiConfig, testConnection, fetchModels } from "./api.js";
import { tools } from "./tools.js";
const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`;
let isInitialized = false;
let agentManager = null;
let previousCharData = {};
let previousWorldData = {};
let isWaitingForApproval = false;
let openedFiles = new Map();
let activeFileId = null;
let promptLogContent = "=== Prompt Log ===\n\n";
export async function openAutoCharCardWindow() {
if ($('#acc-window').length > 0) {
$('#acc-window').show();
return;
}
if (!$('#acc-style').length) {
$('<link>')
.attr('id', 'acc-style')
.attr('rel', 'stylesheet')
.attr('type', 'text/css')
.attr('href', `${extensionFolderPath}/assets/auto-char-card/style.css`)
.appendTo('head');
}
try {
const htmlContent = await $.get(`${extensionFolderPath}/assets/auto-char-card/index.html`);
$('body').append(htmlContent);
bindEvents();
agentManager = new AgentManager();
try {
populateDropdowns();
loadApiSettings();
renderRulesList();
renderSessionsList();
restoreChatHistory();
} catch (dataError) {
console.error('[Amily2 AutoCharCard] Failed to load data:', dataError);
toastr.warning('数据加载部分失败,请检查控制台。');
}
isInitialized = true;
console.log('[Amily2 AutoCharCard] Window initialized.');
} catch (error) {
console.error('[Amily2 AutoCharCard] Failed to initialize window:', error);
toastr.error(`无法加载自动构建器界面: ${error.message}`);
$('#acc-window').remove();
}
}
function populateDropdowns() {
const charSelect = $('#acc-target-char');
const prevCharId = charSelect.val();
charSelect.empty().append('<option value="">-- 请选择 --</option>');
charSelect.append('<option value="new">新建角色卡</option>');
let isPrevCharStillPresent = false;
characters.forEach((char, index) => {
if (char) {
charSelect.append($('<option>').val(index).text(char.name));
if (String(index) === prevCharId) {
isPrevCharStillPresent = true;
}
}
});
if (isPrevCharStillPresent) {
charSelect.val(prevCharId);
} else if (this_chid !== undefined) {
charSelect.val(this_chid);
}
const worldSelect = $('#acc-target-world');
const prevWorldName = worldSelect.val();
worldSelect.empty().append('<option value="">-- 请选择 --</option>');
worldSelect.append('<option value="new">新建世界书</option>');
let isPrevWorldStillPresent = false;
world_names.forEach(name => {
worldSelect.append($('<option>').val(name).text(name));
if (name === prevWorldName) {
isPrevWorldStillPresent = true;
}
});
if (isPrevWorldStillPresent) {
worldSelect.val(prevWorldName);
}
}
async function handleContextUpdate(type, value) {
console.log(`[Amily2 AutoCharCard] Context Update: ${type} -> ${value}`);
if (type === 'char') {
await getCharacters();
}
populateDropdowns();
if (type === 'char') {
$('#acc-target-char').val(value);
} else if (type === 'world') {
$('#acc-target-world').val(value);
}
}
function handlePromptLog(messages) {
const userType = localStorage.getItem("plugin_user_type");
if (userType !== "3") return;
const timestamp = new Date().toLocaleTimeString();
let logEntry = `\n\n--- [${timestamp}] New Request ---\n`;
messages.forEach(msg => {
logEntry += `\n[${msg.role.toUpperCase()}]\n${msg.content}\n`;
});
promptLogContent += logEntry;
if (openedFiles.has('debug-prompt-log')) {
const file = openedFiles.get('debug-prompt-log');
file.content = promptLogContent;
if (activeFileId === 'debug-prompt-log') {
renderEditor();
}
}
}
function restoreChatHistory() {
const stream = $('#acc-chat-stream');
stream.empty();
if (agentManager && agentManager.history && agentManager.history.length > 0) {
agentManager.history.forEach(msg => {
addMessage(msg.role, msg.content);
});
} else {
stream.append(`
<div class="acc-message system">
<div class="acc-message-content">
欢迎使用 Amily2 自动构建器。<br>
请在左侧配置工作区,然后在下方输入您的需求。<br>
当使用时,最好不要进入所选的角色卡中,以便后台执行即时生效。
</div>
</div>
`);
}
}
function renderSessionsList() {
const list = $('#acc-sessions-list');
list.empty();
if (!agentManager) return;
const sessions = agentManager.getSessionsList();
if (sessions.length === 0) {
list.append('<div class="acc-empty-state" style="padding: 10px;">暂无历史会话</div>');
return;
}
sessions.forEach(session => {
const isActive = session.id === agentManager.sessionId;
const item = $('<div>').addClass('acc-session-item').css({
'background': isActive ? 'rgba(76, 175, 80, 0.2)' : 'rgba(0,0,0,0.1)',
'border': isActive ? '1px solid #4caf50' : '1px solid transparent',
'padding': '8px',
'margin-bottom': '5px',
'border-radius': '4px',
'display': 'flex',
'justify-content': 'space-between',
'align-items': 'center',
'cursor': 'pointer'
});
const date = new Date(session.timestamp).toLocaleString();
const textContainer = $('<div>').css({
'display': 'flex',
'flex-direction': 'column',
'flex': '1',
'overflow': 'hidden',
'margin-right': '10px'
});
const titleSpan = $('<span>').text(session.title).css({
'font-weight': 'bold',
'white-space': 'nowrap',
'overflow': 'hidden',
'text-overflow': 'ellipsis'
});
const dateSpan = $('<span>').text(date).css({
'font-size': '10px',
'color': '#888'
});
textContainer.append(titleSpan).append(dateSpan);
const delBtn = $('<button>').addClass('acc-btn-danger').html('<i class="fas fa-trash"></i>').css({
'padding': '4px 8px',
'font-size': '12px'
});
item.on('click', (e) => {
if (e.target === delBtn[0] || delBtn.has(e.target).length > 0) return;
if (!isActive) {
if (agentManager.loadSession(session.id)) {
restoreChatHistory();
renderSessionsList();
populateDropdowns();
toastr.success('已切换会话');
} else {
toastr.error('加载会话失败');
}
}
});
delBtn.on('click', (e) => {
e.stopPropagation();
if (confirm('确定要删除这个会话吗?')) {
agentManager.deleteSession(session.id);
renderSessionsList();
if (isActive) {
restoreChatHistory();
populateDropdowns();
}
}
});
item.append(textContainer).append(delBtn);
list.append(item);
});
}
function renderRulesList() {
const list = $('#acc-rules-list');
list.empty();
if (!agentManager || !agentManager.contextManager) return;
const rules = agentManager.contextManager.rules;
if (rules.length === 0) {
list.append('<div class="acc-empty-state" style="padding: 10px;">暂无规则</div>');
return;
}
rules.forEach((rule, index) => {
const item = $('<div>').addClass('acc-rule-item').css({
'background': 'rgba(0,0,0,0.1)',
'padding': '5px',
'margin-bottom': '5px',
'border-radius': '4px',
'display': 'flex',
'justify-content': 'space-between',
'align-items': 'center'
});
const text = $('<span>').text(`${rule.keyword ? `[${rule.keyword}] ` : ''}${rule.content}`);
const delBtn = $('<button>').addClass('acc-btn-danger').html('<i class="fas fa-trash"></i>').css({
'padding': '2px 5px',
'font-size': '12px'
});
delBtn.on('click', () => {
agentManager.contextManager.removeRule(index);
renderRulesList();
});
item.append(text).append(delBtn);
list.append(item);
});
}
function loadApiSettings() {
const executorConfig = getApiConfig('executor');
$('#acc-executor-url').val(executorConfig.apiUrl);
$('#acc-executor-key').val(executorConfig.apiKey);
$('#acc-executor-max-tokens').val(executorConfig.maxTokens || 4000);
const executorModelSelect = $('#acc-executor-model');
if (executorConfig.model) {
if (executorModelSelect.find(`option[value="${executorConfig.model}"]`).length === 0) {
executorModelSelect.append(new Option(executorConfig.model, executorConfig.model));
}
executorModelSelect.val(executorConfig.model);
}
}
function bindEvents() {
const windowEl = $('#acc-window');
const minIcon = $('#acc-minimized-icon');
$('#acc-file-selector').on('change', async function() {
const val = $(this).val();
if (!val) return;
const [type, id, subId] = val.split('|');
if (type === 'debug' && id === 'log') {
const userType = localStorage.getItem("plugin_user_type");
if (userType !== "3") {
toastr.warning('权限不足:仅开发者可查看调试日志。');
$(this).val('');
return;
}
const fileId = 'debug-prompt-log';
openedFiles.set(fileId, {
title: 'Prompt Log',
content: promptLogContent,
type: 'log',
metadata: null
});
activeFileId = fileId;
renderEditor();
$(this).val('');
return;
}
if (type === 'char') {
const chid = id;
const field = subId;
const fileId = `char-${chid}-${field}`;
if (openedFiles.has(fileId)) {
activeFileId = fileId;
renderEditor();
return;
}
let content = '';
try {
console.log(`[AutoCharCard] Reading char ${chid}, field ${field}`);
const charData = await tools.read_character_card({ chid });
const response = JSON.parse(charData);
if (response.status !== 'success' || !response.data) {
throw new Error(response.message || 'Unknown error');
}
const char = response.data;
previousCharData = char;
console.log(`[AutoCharCard] Char data:`, char);
if (field.startsWith('greeting_')) {
const index = parseInt(field.split('_')[1]);
content = char.alternate_greetings[index];
} else {
content = char[field];
}
console.log(`[AutoCharCard] Content for ${field}:`, content);
} catch (e) {
console.error(e);
toastr.error('无法读取角色卡内容');
return;
}
openedFiles.set(fileId, {
title: field.startsWith('greeting_') ? `Greeting #${field.split('_')[1]}` : field,
content: content || '',
type: 'normal',
metadata: { type: 'char', chid, field }
});
activeFileId = fileId;
renderEditor();
} else if (type === 'wi') {
const bookName = id;
const uid = subId;
const fileId = `wi-${bookName}-${uid}`;
if (openedFiles.has(fileId)) {
activeFileId = fileId;
renderEditor();
return;
}
try {
const entryData = await tools.read_world_entry({ book_name: bookName, uid: uid });
const response = JSON.parse(entryData);
if (response.status !== 'success' || !response.data) {
throw new Error(response.message || 'Unknown error');
}
const entry = response.data;
let keys = entry.key;
if (Array.isArray(keys)) keys = keys.join(', ');
openedFiles.set(fileId, {
title: `WI: ${keys}`,
content: entry.content,
type: 'normal',
metadata: { type: 'wi', bookName, uid }
});
activeFileId = fileId;
renderEditor();
} catch (e) {
console.error(e);
toastr.error('无法读取世界书条目');
}
}
$(this).val('');
});
$('#acc-close-btn').on('click', () => {
if (confirm('确定要关闭自动构建器吗?当前任务可能会丢失。')) {
windowEl.remove();
minIcon.hide();
isInitialized = false;
agentManager = null;
}
});
$('#acc-minimize-btn').on('click', () => {
windowEl.hide();
minIcon.show();
});
minIcon.on('click', () => {
minIcon.hide();
windowEl.show();
minIcon.find('.acc-notification-dot').hide();
});
$('#acc-send-btn').on('click', handleSendMessage);
$('#acc-user-input').on('keypress', (e) => {
if (e.which === 13 && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
});
$('#acc-stop-btn').on('click', () => {
if (agentManager) {
agentManager.stop();
toastr.info('已请求停止生成');
$('#acc-stop-btn').hide();
$('#acc-status-indicator').removeClass('status-working').addClass('status-idle').text('已停止');
$('#acc-send-btn').prop('disabled', false);
}
});
$('#acc-require-approval').on('change', function() {
if (agentManager) {
agentManager.setApprovalRequired($(this).is(':checked'));
}
});
const previewHeader = $('.acc-right-panel .acc-panel-header');
if (previewHeader.find('#acc-refresh-preview').length === 0) {
const refreshBtn = $('<button>')
.attr('id', 'acc-refresh-preview')
.addClass('acc-control-btn')
.attr('title', '加载当前所有文件')
.html('<i class="fas fa-sync-alt"></i>')
.css({ 'margin-left': 'auto', 'font-size': '12px' });
previewHeader.append(refreshBtn);
refreshBtn.on('click', () => {
loadContextToEditor();
toastr.info('已加载当前角色和世界书内容');
});
}
$('#acc-sessions-toggle').on('click', function() {
const content = $('#acc-sessions-content');
const icon = $(this).find('.fa-chevron-down, .fa-chevron-up');
if (content.is(':visible')) {
content.slideUp();
icon.removeClass('fa-chevron-up').addClass('fa-chevron-down');
} else {
content.slideDown();
icon.removeClass('fa-chevron-down').addClass('fa-chevron-up');
}
});
$('#acc-new-session-btn').on('click', () => {
if (agentManager) {
agentManager.createNewSession();
restoreChatHistory();
renderSessionsList();
populateDropdowns();
toastr.success('已创建新会话');
}
});
$('#acc-rules-toggle').on('click', function() {
const content = $('#acc-rules-content');
const icon = $(this).find('.fa-chevron-down, .fa-chevron-up');
if (content.is(':visible')) {
content.slideUp();
icon.removeClass('fa-chevron-up').addClass('fa-chevron-down');
} else {
content.slideDown();
icon.removeClass('fa-chevron-down').addClass('fa-chevron-up');
}
});
$('#acc-add-rule-btn').on('click', () => {
const input = $('#acc-new-rule-input');
const val = input.val().trim();
if (!val) return;
const parts = val.split('|');
let keyword = null;
let content = val;
if (parts.length > 1) {
keyword = parts[0].trim();
content = parts.slice(1).join('|').trim();
}
if (agentManager && agentManager.contextManager) {
agentManager.contextManager.addRule({ keyword, content });
renderRulesList();
input.val('');
toastr.success('规则已添加');
}
});
$('#acc-api-settings-toggle').on('click', function() {
const content = $('#acc-api-settings-content');
const icon = $(this).find('.fa-chevron-down, .fa-chevron-up');
if (content.is(':visible')) {
content.slideUp();
icon.removeClass('fa-chevron-up').addClass('fa-chevron-down');
} else {
content.slideDown();
icon.removeClass('fa-chevron-down').addClass('fa-chevron-up');
}
});
$('#acc-save-api').on('click', () => {
const execMaxTokens = parseInt($('#acc-executor-max-tokens').val());
const executorConfig = {
apiUrl: $('#acc-executor-url').val().trim(),
apiKey: $('#acc-executor-key').val().trim(),
model: $('#acc-executor-model').val() || '',
maxTokens: isNaN(execMaxTokens) ? 0 : execMaxTokens
};
setApiConfig('executor', executorConfig);
saveSettingsDebounced();
toastr.success('API 配置已保存');
});
const handleRefreshModels = async (role) => {
const urlInput = $(`#acc-${role}-url`);
const keyInput = $(`#acc-${role}-key`);
const select = $(`#acc-${role}-model`);
const btn = $(`#acc-${role}-refresh-models`);
const apiUrl = urlInput.val().trim();
const apiKey = keyInput.val().trim();
if (!apiUrl) {
toastr.warning('请先输入 API URL');
return;
}
const originalIcon = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>');
select.empty().append('<option value="">加载中...</option>');
try {
const models = await fetchModels(apiUrl, apiKey);
select.empty().append('<option value="">-- 请选择模型 --</option>');
if (models.length === 0) {
select.append('<option value="" disabled>未找到模型</option>');
} else {
models.forEach(model => {
select.append(new Option(model, model));
});
toastr.success(`成功获取 ${models.length} 个模型`);
}
} catch (error) {
console.error(`[AutoCharCard] Failed to fetch models for ${role}:`, error);
toastr.error(`获取模型失败: ${error.message}`);
select.empty().append('<option value="">获取失败</option>');
} finally {
btn.prop('disabled', false).html(originalIcon);
}
};
$('#acc-executor-refresh-models').on('click', () => handleRefreshModels('executor'));
$('#acc-executor-test').on('click', async function() {
const btn = $(this);
btn.prop('disabled', true).text('测试中...');
const result = await testConnection('executor');
btn.prop('disabled', false).text('测试连接');
if (result.success) {
toastr.success('连接成功');
} else {
toastr.error(`连接失败: ${result.error || '未知错误'}`);
}
});
$('.acc-nav-btn').on('click', function() {
const targetClass = $(this).data('target');
$('.acc-nav-btn').removeClass('active');
$(this).addClass('active');
$('.acc-column').removeClass('mobile-active');
$(`.${targetClass}`).addClass('mobile-active');
});
if (window.innerWidth <= 768) {
$('.acc-center-panel').addClass('mobile-active');
}
}
async function handleSendMessage() {
const input = $('#acc-user-input');
const message = input.val().trim();
if (isWaitingForApproval) {
if (!agentManager) return;
isWaitingForApproval = false;
const btn = $('#acc-send-btn');
btn.html('<i class="fas fa-paper-plane"></i>');
btn.prop('title', '发送');
btn.removeClass('acc-btn-success');
$('#acc-reject-btn').remove();
input.attr('placeholder', '描述您的需求...');
input.val('');
if (message) {
addMessage('user', message);
await agentManager.resumeWithApproval(
false,
message,
(content, role) => addMessage(role, content),
updatePreview,
showApprovalRequest,
handleContextUpdate,
handlePromptLog
);
} else {
await agentManager.resumeWithApproval(
true,
null,
(content, role) => addMessage(role, content),
updatePreview,
showApprovalRequest,
handleContextUpdate,
handlePromptLog
);
}
return;
}
if (!message) return;
if (!agentManager) {
toastr.error('Agent 未初始化');
return;
}
const selectedCharId = $('#acc-target-char').val();
const selectedWorld = $('#acc-target-world').val();
if (!selectedCharId && selectedCharId !== '0') {
toastr.warning('请先选择一个目标角色(或选择新建)');
return;
}
addMessage('user', message);
input.val('');
$('#acc-send-btn').prop('disabled', true);
$('#acc-status-indicator').removeClass('status-idle').addClass('status-working').text('工作中...');
try {
agentManager.setContext(selectedCharId, selectedWorld);
agentManager.setApprovalRequired($('#acc-require-approval').is(':checked'));
$('#acc-stop-btn').show();
await agentManager.handleUserMessage(
message,
(content, role) => {
addMessage(role, content);
},
updatePreview,
showApprovalRequest,
handleContextUpdate,
handlePromptLog
);
} catch (error) {
console.error('Agent Error:', error);
addMessage('system', `[Error] 发生错误: ${error.message}`);
} finally {
$('#acc-send-btn').prop('disabled', false);
$('#acc-stop-btn').hide();
$('#acc-status-indicator').removeClass('status-working').addClass('status-idle').text('空闲');
}
}
function showApprovalRequest(toolName, args) {
isWaitingForApproval = true;
updatePreview(toolName, args, false);
const btn = $('#acc-send-btn');
btn.html('<i class="fas fa-check"></i>');
btn.prop('title', '批准执行');
btn.addClass('acc-btn-success');
$('#acc-user-input').attr('placeholder', '输入反馈以修改,或点击 √ 批准,点击 X 拒绝...');
if ($('#acc-reject-btn').length === 0) {
const rejectBtn = $('<button>')
.attr('id', 'acc-reject-btn')
.addClass('acc-btn-danger')
.html('<i class="fas fa-times"></i>')
.attr('title', '拒绝执行')
.css({
'margin-right': '5px',
'width': '40px',
'height': '40px',
'border-radius': '50%',
'border': 'none',
'cursor': 'pointer',
'display': 'flex',
'align-items': 'center',
'justify-content': 'center'
});
rejectBtn.insertBefore(btn);
rejectBtn.on('click', async () => {
if (!isWaitingForApproval) return;
isWaitingForApproval = false;
const input = $('#acc-user-input');
const message = input.val().trim();
btn.html('<i class="fas fa-paper-plane"></i>');
btn.prop('title', '发送');
btn.removeClass('acc-btn-success');
rejectBtn.remove();
input.attr('placeholder', '描述您的需求...');
input.val('');
updatePreview(toolName, args, false, true);
const feedback = message || "用户拒绝了操作。";
addMessage('user', `[拒绝] ${feedback}`);
await agentManager.resumeWithApproval(
false,
feedback,
(content, role) => addMessage(role, content),
updatePreview,
showApprovalRequest,
handleContextUpdate,
handlePromptLog
);
});
}
const toolDisplay = `
<div class="acc-tool-request">
<details>
<summary class="acc-tool-header" style="cursor: pointer;">
<i class="fas fa-code"></i> 请求执行: ${toolName}
<span style="float: right; font-size: 10px; color: #888;">(点击展开)</span>
</summary>
<pre class="acc-tool-content">${JSON.stringify(args, null, 2)}</pre>
</details>
</div>
`;
addMessage('system', toolDisplay);
}
function addMessage(role, content) {
const stream = $('#acc-chat-stream');
if (role === 'stream-assistant') {
let lastMsg = stream.children().last();
if (!lastMsg.hasClass('assistant') || !lastMsg.hasClass('acc-streaming')) {
const msgDiv = $('<div>').addClass('acc-message assistant acc-streaming');
const avatarDiv = $('<div>').addClass('acc-avatar').html('<i class="fas fa-robot" style="color: #4caf50;"></i>');
const contentDiv = $('<div>').addClass('acc-message-content');
msgDiv.append(avatarDiv).append(contentDiv);
stream.append(msgDiv);
lastMsg = msgDiv;
}
const contentDiv = lastMsg.find('.acc-message-content');
const escapedContent = content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/\n/g, '<br>');
contentDiv.append(escapedContent);
stream.scrollTop(stream[0].scrollHeight);
return;
}
let displayContent = content;
if (role === 'executor' || role === 'assistant') {
displayContent = displayContent
.replace(/<thinking(?:\s+[^>]*)?>[\s\S]*?<\/thinking>/gi, '')
.replace(/<\/thinking>/gi, '')
.replace(/<tool_code(?:\s+[^>]*)?>[\s\S]*?<\/tool_code>/gi, '')
.trim();
const toolNames = Object.keys(tools);
const regex = new RegExp(`<(${toolNames.join('|')})(?:\\s+[^>]*)?>[\\s\\S]*?<\\/\\1>`, 'gi');
displayContent = displayContent.replace(regex, '').trim();
if (!displayContent && role === 'executor') {
displayContent = "<i>(正在执行操作...)</i>";
}
if (role === 'assistant') {
stream.find('.acc-streaming').remove();
}
}
let formattedContent;
if (displayContent.trim().startsWith('<div class="acc-tool-request"')) {
formattedContent = displayContent;
} else {
formattedContent = parseMarkdown(displayContent);
}
const msgDiv = $('<div>').addClass(`acc-message ${role}`);
const avatarDiv = $('<div>').addClass('acc-avatar');
if (role === 'user') {
avatarDiv.html('<i class="fas fa-user"></i>');
} else if (role === 'assistant') {
avatarDiv.html('<i class="fas fa-robot" style="color: #4caf50;"></i>');
} else if (role === 'thought') {
avatarDiv.html('<i class="fas fa-brain" style="color: #9c27b0;"></i>');
} else if (role === 'executor') {
avatarDiv.html('<i class="fas fa-robot" style="color: #4caf50;"></i>');
} else if (role === 'system') {
avatarDiv.html('<i class="fas fa-info-circle"></i>');
}
const contentDiv = $('<div>').addClass('acc-message-content');
if (role === 'thought') {
msgDiv.addClass('acc-thought-message');
contentDiv.css({
'font-style': 'italic',
'color': '#aaa',
'font-size': '0.9em'
});
}
msgDiv.append(avatarDiv);
msgDiv.append(contentDiv);
stream.append(msgDiv);
contentDiv.html(formattedContent);
stream.scrollTop(stream[0].scrollHeight);
}
function parseMarkdown(text) {
if (!text) return '';
let html = text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
html = html.replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>');
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/^#### (.*$)/gm, '<h4>$1</h4>');
html = html.replace(/^### (.*$)/gm, '<h3>$1</h3>');
html = html.replace(/^## (.*$)/gm, '<h2>$1</h2>');
html = html.replace(/^# (.*$)/gm, '<h1>$1</h1>');
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
html = html.replace(/~~(.*?)~~/g, '<del>$1</del>');
html = html.replace(/^[\*\-]{3,}$/gm, '<hr>');
html = html.replace(/^\s*[\-\*]\s+(.*$)/gm, '<li>$1</li>');
html = html.replace(/\n/g, '<br>');
return html;
}
function renderEditor() {
const container = $('#acc-preview-container');
const tabsContainer = $('.acc-preview-tabs');
container.empty();
tabsContainer.empty();
if (openedFiles.size === 0) {
container.html('<div class="acc-empty-state"><i class="fas fa-file-alt"></i><p>暂无内容</p></div>');
return;
}
if (!activeFileId || !openedFiles.has(activeFileId)) {
activeFileId = openedFiles.keys().next().value;
}
openedFiles.forEach((file, id) => {
const isActive = id === activeFileId;
const tabBtn = $('<button>')
.addClass(`acc-tab-btn ${isActive ? 'active' : ''}`)
.attr('title', file.title)
.on('click', () => {
activeFileId = id;
renderEditor();
});
const icon = $('<i class="fas fa-file-alt"></i>');
const titleSpan = $('<span>').addClass('acc-tab-title').text(file.title);
const closeBtn = $('<span>')
.html('&times;')
.addClass('acc-tab-close')
.on('click', (e) => {
e.stopPropagation();
openedFiles.delete(id);
if (activeFileId === id) activeFileId = null;
renderEditor();
});
tabBtn.append(icon).append(titleSpan).append(closeBtn);
tabsContainer.append(tabBtn);
if (isActive) {
const contentDiv = $('<div>')
.addClass('acc-editor-content')
.css('display', 'flex')
.css('flex-direction', 'column')
.css('height', '100%');
const toolbar = $('<div>').addClass('acc-editor-toolbar').css({
'padding': '5px',
'border-bottom': '1px solid #444',
'display': 'flex',
'justify-content': 'flex-end',
'gap': '10px'
});
const saveBtn = $('<button>')
.addClass('acc-btn-primary')
.html('<i class="fas fa-save"></i> 保存')
.on('click', () => saveFile(id));
toolbar.append(saveBtn);
contentDiv.append(toolbar);
if (file.type === 'diff-view') {
const editorDiv = $('<div>')
.addClass('acc-editor-diff-view')
.css({
'flex': '1',
'width': '100%',
'background': '#1e1e1e',
'color': '#d4d4d4',
'padding': '10px',
'font-family': 'monospace',
'overflow-y': 'auto',
'white-space': 'pre-wrap'
});
if (file.segments) {
file.segments.forEach((segment) => {
if (segment.type === 'text') {
editorDiv.append($('<span>').text(segment.content));
} else if (segment.type === 'change') {
const container = $('<div>').addClass('acc-diff-container').css({
'display': 'block',
'margin': '10px 0',
'border': '1px solid #444',
'padding': '5px',
'border-radius': '4px'
});
const renderChange = () => {
container.empty();
if (segment.active) {
const removed = $('<div>')
.text(segment.original)
.css({
'background-color': 'rgba(255, 0, 0, 0.2)',
'cursor': 'pointer',
'padding': '5px',
'margin-bottom': '2px',
'white-space': 'pre-wrap',
'color': '#d4d4d4'
})
.attr('title', '点击恢复 (Click to restore)');
const added = $('<div>')
.text(segment.new)
.attr('contenteditable', 'true')
.css({
'background-color': 'rgba(0, 255, 0, 0.2)',
'cursor': 'text',
'padding': '5px',
'white-space': 'pre-wrap',
'color': '#d4d4d4',
'outline': 'none'
})
.attr('title', '点击编辑');
const toggle = () => {
segment.active = false;
renderChange();
if (agentManager) {
const newDiff = reconstructDiff(file.segments);
agentManager.updatePendingToolArgs({ diff: newDiff });
}
};
removed.on('click', toggle);
added.on('input', function() {
segment.new = $(this).text();
if (agentManager) {
const newDiff = reconstructDiff(file.segments);
agentManager.updatePendingToolArgs({ diff: newDiff });
}
});
container.append(removed).append(added);
} else {
const restored = $('<div>')
.text(segment.original)
.css({
'cursor': 'pointer',
'border-left': '3px solid #666',
'padding': '5px',
'white-space': 'pre-wrap',
'opacity': '0.7'
})
.attr('title', '点击重新应用修改 (Click to re-apply change)');
restored.on('click', () => {
segment.active = true;
renderChange();
if (agentManager) {
const newDiff = reconstructDiff(file.segments);
agentManager.updatePendingToolArgs({ diff: newDiff });
}
});
container.append(restored);
}
};
renderChange();
editorDiv.append(container);
}
});
} else {
editorDiv.text('Error: No segments found for diff view.');
}
contentDiv.append(editorDiv);
} else {
const textarea = $('<textarea>')
.addClass('acc-editor-textarea')
.val(file.content)
.css({
'flex': '1',
'width': '100%',
'background': '#1e1e1e',
'color': '#d4d4d4',
'border': 'none',
'padding': '10px',
'font-family': 'monospace',
'resize': 'none',
'outline': 'none'
})
.on('input', function() {
file.content = $(this).val();
});
contentDiv.append(textarea);
}
container.append(contentDiv);
}
});
}
async function saveFile(id) {
const file = openedFiles.get(id);
if (!file) return;
const meta = file.metadata;
if (!meta) {
toastr.warning('该文件无法保存(缺少元数据)');
return;
}
let contentToSave = file.content;
if (file.type === 'diff-view' && file.segments) {
contentToSave = file.segments.map(seg => {
if (seg.type === 'text') return seg.content;
if (seg.type === 'change') {
return seg.active ? seg.new : seg.original;
}
return '';
}).join('');
}
try {
let result;
if (meta.type === 'char') {
if (meta.field.startsWith('greeting_')) {
const index = parseInt(meta.field.split('_')[1]);
result = await tools.manage_first_message({
action: 'update',
chid: meta.chid,
index: index + 1,
message: contentToSave
});
} else {
result = await tools.update_character_card({
chid: meta.chid,
[meta.field]: contentToSave
});
}
} else if (meta.type === 'wi') {
if (meta.uid !== undefined) {
result = await tools.write_world_info_entry({
book_name: meta.bookName,
entries: [{ uid: meta.uid, content: contentToSave }]
});
} else {
try {
const entry = JSON.parse(contentToSave);
result = await tools.write_world_info_entry({
book_name: meta.bookName,
entries: [entry]
});
} catch (e) {
toastr.error('保存失败: 内容必须是有效的 JSON (针对新建条目) 或包含 UID');
return;
}
}
}
if (result && !result.startsWith('Error') && !result.includes('失败')) {
toastr.success('保存成功');
if (file.type === 'diff-view') {
file.type = 'normal';
delete file.segments;
file.content = contentToSave;
renderEditor();
}
} else {
toastr.error(result || '保存失败');
}
} catch (e) {
console.error('Save failed:', e);
toastr.error(`保存异常: ${e.message}`);
}
}
async function loadContextToEditor() {
const chid = $('#acc-target-char').val();
const bookName = $('#acc-target-world').val();
const selector = $('#acc-file-selector');
selector.empty().append('<option value="">-- 选择文件 --</option>');
if (chid && chid !== 'new') {
try {
const charData = await tools.read_character_card({ chid });
const response = JSON.parse(charData);
if (response.status !== 'success' || !response.data) {
console.error("Failed to read character:", response);
return;
}
const char = response.data;
previousCharData = char;
const charGroup = $('<optgroup label="角色卡字段">');
const fields = ['description', 'personality', 'first_mes', 'scenario', 'mes_example'];
fields.forEach(field => {
charGroup.append(`<option value="char|${chid}|${field}">${field}</option>`);
});
if (char.alternate_greetings && char.alternate_greetings.length > 0) {
char.alternate_greetings.forEach((_, index) => {
charGroup.append(`<option value="char|${chid}|greeting_${index}">开场白 #${index + 1}</option>`);
});
}
selector.append(charGroup);
const userType = localStorage.getItem("plugin_user_type");
if (userType === "3") {
selector.append('<option value="debug|log">Debug: Prompt Log</option>');
}
if (openedFiles.size === 0 && char.description) {
const id = `char-${chid}-description`;
openedFiles.set(id, {
title: 'description',
content: char.description,
type: 'normal',
metadata: { type: 'char', chid, field: 'description' }
});
activeFileId = id;
}
} catch (e) {
console.error("Failed to load character for editor:", e);
}
}
if (bookName && bookName !== 'new') {
try {
const indexData = await tools.read_world_info({ book_name: bookName, return_full: false });
const index = JSON.parse(indexData);
const wiGroup = $('<optgroup label="世界书条目">');
if (index.entries) {
index.entries.forEach(entry => {
const name = entry.comment || entry.keys || `Entry ${entry.uid}`;
wiGroup.append(`<option value="wi|${bookName}|${entry.uid}">${name}</option>`);
});
}
selector.append(wiGroup);
} catch (e) {
console.error("Failed to load world info for editor:", e);
}
}
renderEditor();
}
async function updatePreview(toolName, args, isPartial = false, isExecuted = false) {
let chid = args.chid;
if (chid === undefined || chid === null || chid === '') {
const uiVal = $('#acc-target-char').val();
if (uiVal !== 'new' && uiVal !== '') {
chid = uiVal;
}
}
chid = String(chid);
let bookName = args.book_name;
if (bookName === undefined || bookName === null || bookName === '') {
const uiVal = $('#acc-target-world').val();
if (uiVal !== 'new' && uiVal !== '') {
bookName = uiVal;
}
}
bookName = String(bookName);
if (toolName === 'update_character_card') {
const fields = ['description', 'personality', 'first_mes', 'scenario', 'mes_example'];
fields.forEach(field => {
let content = args[field];
if (args.updates && args.updates[field]) content = args.updates[field];
if (content !== undefined) {
const id = `char-${chid}-${field}`;
openedFiles.set(id, {
title: field,
content: content,
type: 'normal',
metadata: { type: 'char', chid, field }
});
activeFileId = id;
}
});
} else if (toolName === 'edit_character_text') {
const field = args.field || 'Unknown Field';
const diff = args.diff || '';
const id = `char-${chid}-${field}`;
// Clean up any tabs with undefined chid or Unknown Field
openedFiles.forEach((file, fileId) => {
if (fileId.startsWith('diff-') && !fileId.startsWith('diff-wi-')) {
if (fileId.includes('-undefined') || fileId.includes('-Unknown Field')) {
if (fileId !== `diff-${chid}-${field}`) {
openedFiles.delete(fileId);
}
}
}
if (fileId.startsWith('char-')) {
if (fileId.includes('-undefined') || fileId.includes('-Unknown Field')) {
if (fileId !== id) {
const fileToRename = openedFiles.get(fileId);
openedFiles.delete(fileId);
fileToRename.title = field;
if (fileToRename.metadata) {
fileToRename.metadata.chid = chid;
fileToRename.metadata.field = field;
}
openedFiles.set(id, fileToRename);
if (activeFileId === fileId) activeFileId = id;
}
}
}
});
if (isPartial) {
const diffId = `diff-${chid}-${field}`;
openedFiles.set(diffId, {
title: `Diff: ${field}`,
content: diff,
type: 'diff',
metadata: null
});
activeFileId = diffId;
} else if (isExecuted) {
let success = false;
let content = '';
try {
const charData = await tools.read_character_card({ chid });
const response = JSON.parse(charData);
if (response.status === 'success' && response.data) {
const char = response.data;
if (field.startsWith('greeting_')) {
const index = parseInt(field.split('_')[1]);
content = char.alternate_greetings[index];
} else {
content = char[field];
}
success = true;
}
} catch (e) {
console.error("Failed to refresh content after edit", e);
}
openedFiles.delete(`diff-${chid}-${field}`);
if (!openedFiles.has(id)) {
for (const [key, val] of openedFiles) {
if (val.metadata && val.metadata.chid == chid && val.metadata.field == field) {
id = key;
break;
}
}
}
let foundAndFixed = false;
openedFiles.forEach((file, fileId) => {
if (file.metadata && file.metadata.chid == chid && file.metadata.field == field) {
if (file.type === 'diff-view') {
if (file.segments) {
const newContent = file.segments.map(s => s.type === 'change' ? s.new : s.content).join('');
file.content = newContent;
}
file.type = 'normal';
delete file.segments;
if (fileId !== id) {
openedFiles.delete(fileId);
openedFiles.set(id, file);
if (activeFileId === fileId) activeFileId = id;
}
foundAndFixed = true;
}
}
});
if (success) {
openedFiles.set(id, {
title: field,
content: content,
type: 'normal',
metadata: { type: 'char', chid, field }
});
activeFileId = id;
} else if (!foundAndFixed) {
if (openedFiles.has(id)) {
const file = openedFiles.get(id);
file.type = 'normal';
activeFileId = id;
}
}
renderEditor();
} else {
const diffId = `diff-${chid}-${field}`;
if (openedFiles.has(diffId)) {
openedFiles.delete(diffId);
}
let originalContent = null;
if (openedFiles.has(id)) {
originalContent = openedFiles.get(id).content || '';
} else {
try {
const charData = await tools.read_character_card({ chid });
const response = JSON.parse(charData);
if (response.status === 'success' && response.data) {
const char = response.data;
if (field.startsWith('greeting_')) {
const index = parseInt(field.split('_')[1]);
originalContent = char.alternate_greetings[index] || '';
} else {
originalContent = char[field] || '';
}
}
} catch (e) {
console.error("Failed to fetch original content for diff view", e);
}
}
if (originalContent !== null) {
const segments = parseDiff(originalContent, diff);
openedFiles.set(id, {
title: field,
content: originalContent,
segments: segments,
type: 'diff-view',
metadata: { type: 'char', chid, field }
});
activeFileId = id;
} else {
openedFiles.set(diffId, {
title: `Diff: ${field}`,
content: diff,
type: 'diff',
metadata: null
});
activeFileId = diffId;
}
}
} else if (toolName === 'edit_world_info_entry') {
const uid = args.uid;
const diff = args.diff || '';
const id = `wi-${bookName}-${uid}`;
// Clean up any tabs with undefined bookName or uid
openedFiles.forEach((file, fileId) => {
if (fileId.startsWith('diff-wi-') || fileId.startsWith('wi-')) {
if (fileId.includes('-undefined')) {
if (fileId !== `diff-wi-${bookName}-${uid}` && fileId !== id) {
openedFiles.delete(fileId);
}
}
}
});
if (isPartial) {
const diffId = `diff-wi-${bookName}-${uid}`;
// Clean up any other diff tabs for this book to prevent duplicates during streaming
openedFiles.forEach((file, fileId) => {
if (fileId.startsWith(`diff-wi-${bookName}-`) && fileId !== diffId) {
openedFiles.delete(fileId);
}
});
openedFiles.set(diffId, {
title: uid !== undefined ? `Diff: WI ${uid}` : 'Diff: WI (Generating...)',
content: diff,
type: 'diff',
metadata: null
});
activeFileId = diffId;
} else if (isExecuted) {
try {
const entryData = await tools.read_world_entry({ book_name: bookName, uid: uid });
const response = JSON.parse(entryData);
if (response.status === 'success' && response.data) {
openedFiles.delete(`diff-wi-${bookName}-${uid}`);
openedFiles.delete(id);
openedFiles.set(id, {
title: `WI: ${uid}`,
content: response.data.content,
type: 'normal',
metadata: { type: 'wi', bookName, uid }
});
activeFileId = id;
}
} catch (e) {
console.error("Failed to refresh WI content after edit", e);
}
} else {
const diffId = `diff-wi-${bookName}-${uid}`;
if (openedFiles.has(diffId)) {
openedFiles.delete(diffId);
}
// Clean up any other diff tabs for this book to prevent duplicates
openedFiles.forEach((file, fileId) => {
if (fileId.startsWith(`diff-wi-${bookName}-`) && fileId !== diffId) {
openedFiles.delete(fileId);
}
});
let originalContent = null;
if (openedFiles.has(id)) {
originalContent = openedFiles.get(id).content || '';
} else {
try {
const entryData = await tools.read_world_entry({ book_name: bookName, uid: uid });
const response = JSON.parse(entryData);
if (response.status === 'success' && response.data) {
originalContent = response.data.content || '';
}
} catch (e) {
console.error("Failed to fetch original content for WI diff view", e);
}
}
if (originalContent !== null) {
const segments = parseDiff(originalContent, diff);
openedFiles.set(id, {
title: `WI: ${uid}`,
content: originalContent,
segments: segments,
type: 'diff-view',
metadata: { type: 'wi', bookName, uid }
});
activeFileId = id;
} else {
openedFiles.set(diffId, {
title: uid !== undefined ? `Diff: WI ${uid}` : 'Diff: WI (Generating...)',
content: diff,
type: 'diff',
metadata: null
});
activeFileId = diffId;
}
}
} else if (toolName === 'write_world_info_entry') {
let entries = args.entries;
if (isPartial && typeof entries === 'string') {
const id = `wi-raw-partial`;
openedFiles.set(id, {
title: 'WI Entry (Generating...)',
content: entries,
type: 'json',
metadata: null
});
activeFileId = id;
} else {
if (typeof entries === 'string') {
try { entries = JSON.parse(entries); } catch(e) {}
}
if (!Array.isArray(entries)) entries = [entries];
entries.forEach(entry => {
const keys = Array.isArray(entry.key) ? entry.key.join(', ') : (entry.key || 'New Entry');
const uid = entry.uid || 'new';
const id = `wi-${bookName}-${uid}`;
openedFiles.set(id, {
title: `WI: ${keys}`,
content: entry.content,
type: 'normal',
metadata: { type: 'wi', bookName, uid: entry.uid }
});
activeFileId = id;
});
openedFiles.delete(`wi-raw-partial`);
}
}
renderEditor();
}
function parseDiff(originalContent, diff) {
const segments = [];
let currentIndex = 0;
const parts = diff.split('------- SEARCH');
for (let i = 1; i < parts.length; i++) {
const part = parts[i];
const split1 = part.split('=======');
if (split1.length < 2) continue;
// Remove only the first and last newline to preserve indentation
let searchContent = split1[0].replace(/^\r?\n|\r?\n$/g, '');
const split2 = split1[1].split('+++++++ REPLACE');
if (split2.length < 1) continue;
let replaceContent = split2[0].replace(/^\r?\n|\r?\n$/g, '');
let foundIndex = originalContent.indexOf(searchContent, currentIndex);
// Fallback: try normalizing line endings if exact match fails
if (foundIndex === -1) {
const normalizedOriginal = originalContent.replace(/\r\n/g, '\n');
const normalizedSearch = searchContent.replace(/\r\n/g, '\n');
foundIndex = normalizedOriginal.indexOf(normalizedSearch, currentIndex);
if (foundIndex !== -1) {
// Use the actual original string for the matched portion
searchContent = originalContent.substring(foundIndex, foundIndex + normalizedSearch.length);
}
}
if (foundIndex !== -1) {
if (foundIndex > currentIndex) {
segments.push({
type: 'text',
content: originalContent.substring(currentIndex, foundIndex)
});
}
segments.push({
type: 'change',
original: searchContent,
new: replaceContent,
active: true
});
currentIndex = foundIndex + searchContent.length;
} else {
// If still not found, append it anyway so it doesn't silently disappear
console.warn("Diff search block not found in original content:", searchContent);
// If we haven't added any text yet, add the whole original content first
if (currentIndex === 0 && i === 1) {
segments.push({
type: 'text',
content: originalContent
});
currentIndex = originalContent.length;
}
segments.push({
type: 'change',
original: searchContent,
new: replaceContent,
active: true,
error: true
});
}
}
if (currentIndex < originalContent.length) {
segments.push({
type: 'text',
content: originalContent.substring(currentIndex)
});
}
return segments;
}
function reconstructDiff(segments) {
return segments
.filter(s => s.type === 'change' && s.active)
.map(s => `------- SEARCH\n${s.original}\n=======\n${s.new}\n+++++++ REPLACE`)
.join('\n');
}