514 lines
20 KiB
JavaScript
514 lines
20 KiB
JavaScript
// static/js/ui_manager.js
|
||
|
||
// --- UI 元素选择器 ---
|
||
let Elements = {}; // 初始化为空对象,稍后填充
|
||
|
||
/* 初始化所有 UI 元素。
|
||
* 在 DOMContentLoaded 事件之后调用此函数。
|
||
*/
|
||
function initializeUIElements() {
|
||
Elements = {
|
||
// 导航
|
||
navItems: document.querySelectorAll('.nav-item'),
|
||
contentSections: document.querySelectorAll('.content-section'),
|
||
// 会话信息
|
||
currentRoleNameSpan: document.getElementById('current-role-name'),
|
||
currentRoleIdSpan: document.getElementById('current-role-id'),
|
||
currentMemoryNameSpan: document.getElementById('current-memory-name'),
|
||
currentMemoryIdSpan: document.getElementById('current-memory-id'),
|
||
currentTurnCounterSpan: document.getElementById('current-turn-counter'),
|
||
memoryUpdateStatusContainer: document.getElementById('memory-update-status'), // 新增:记忆更新状态容器
|
||
memoryUpdateStatusDot: document.querySelector('#memory-update-status .status-dot'),
|
||
memoryUpdateStatusText: document.querySelector('#memory-update-status .status-text'),
|
||
// 聊天
|
||
chatWindow: document.getElementById('chat-window'),
|
||
userInput: document.getElementById('user-input'),
|
||
sendBtn: document.getElementById('send-btn'),
|
||
clearChatHistoryBtn: document.getElementById('clear-chat-history-btn'),
|
||
toggleWidthBtn: document.getElementById('toggle-width-btn'),
|
||
// 配置
|
||
configForm: document.getElementById('config-form'),
|
||
saveConfigBtn: document.getElementById('save-config-btn'),
|
||
showApiKeyCheckbox: document.getElementById('show-api-key'),
|
||
geminiApiKeyInput: document.getElementById('gemini-api-key'),
|
||
defaultGeminiModelSelect: document.getElementById('default-gemini-model'),
|
||
memoryUpdateModelSelect: document.getElementById('memory-update-model'),
|
||
// 特征
|
||
refreshFeaturesBtn: document.getElementById('refresh-features-btn'),
|
||
saveFeaturesBtn: document.getElementById('save-features-btn'),
|
||
roleListDiv: document.getElementById('role-list'),
|
||
newRoleIdInput: document.getElementById('new-role-id'),
|
||
newRoleNameInput: document.getElementById('new-role-name'),
|
||
createRoleBtn: document.getElementById('create-role-btn'),
|
||
featuresContentTextarea: document.getElementById('features-content'),
|
||
// 记忆
|
||
refreshMemoryBtn: document.getElementById('refresh-memory-btn'),
|
||
saveMemoryBtn: document.getElementById('save-memory-btn'),
|
||
triggerMemoryUpdateBtn: document.getElementById('trigger-memory-update-btn'),
|
||
memoryListDiv: document.getElementById('memory-list'),
|
||
newMemoryIdInput: document.getElementById('new-memory-id'),
|
||
newMemoryNameInput: document.getElementById('new-memory-name'),
|
||
createMemoryBtn: document.getElementById('create-memory-btn'),
|
||
memoryContentTextarea: document.getElementById('memory-content'),
|
||
// 日志
|
||
refreshLogBtn: document.getElementById('refresh-log-btn'),
|
||
logContentPre: document.getElementById('log-content'),
|
||
// Toast 消息容器
|
||
toastContainer: document.getElementById('toast-container'),
|
||
// 模态框
|
||
modalOverlay: document.getElementById('modal-overlay'),
|
||
modalTitle: document.getElementById('modal-title'),
|
||
modalMessage: document.getElementById('modal-message'),
|
||
modalConfirmBtn: document.getElementById('modal-confirm-btn'),
|
||
modalCancelBtn: document.getElementById('modal-cancel-btn'),
|
||
};
|
||
}
|
||
|
||
// --- 通用 UI 工具函数 ---
|
||
|
||
/* 切换显示内容区域。
|
||
* @param { string } targetSectionId 要显示的内容区域ID。
|
||
*/
|
||
function showSection(targetSectionId) {
|
||
Elements.contentSections.forEach(section => {
|
||
section.classList.remove('active');
|
||
});
|
||
document.getElementById(targetSectionId).classList.add('active');
|
||
|
||
Elements.navItems.forEach(item => {
|
||
item.classList.remove('active');
|
||
if (item.dataset.target === targetSectionId) {
|
||
item.classList.add('active');
|
||
}
|
||
});
|
||
}
|
||
|
||
/* 显示临时的消息提示。
|
||
* @param { string } message 消息内容。
|
||
* @param { string } type 消息类型 ('success', 'error', 'warning', 'info')。
|
||
* @param { number } duration 消息显示时长(毫秒)。
|
||
*/
|
||
function showToast(message, type = 'info', duration = 3000) {
|
||
if (!Elements.toastContainer) {
|
||
console.error('Toast 容器未找到!');
|
||
return;
|
||
}
|
||
|
||
const toast = document.createElement('div');
|
||
toast.classList.add('toast', type);
|
||
|
||
// 创建图标元素
|
||
const iconElement = document.createElement('i');
|
||
// 根据类型添加 Font Awesome 图标类
|
||
switch (type) {
|
||
case 'success':
|
||
iconElement.classList.add('fas', 'fa-check-circle');
|
||
break;
|
||
case 'error':
|
||
iconElement.classList.add('fas', 'fa-times-circle');
|
||
break;
|
||
case 'warning':
|
||
iconElement.classList.add('fas', 'fa-exclamation-triangle');
|
||
break;
|
||
case 'info':
|
||
default:
|
||
iconElement.classList.add('fas', 'fa-info-circle');
|
||
break;
|
||
}
|
||
toast.appendChild(iconElement);
|
||
|
||
// 创建文本内容元素
|
||
const textSpan = document.createElement('span');
|
||
textSpan.textContent = message;
|
||
toast.appendChild(textSpan);
|
||
|
||
Elements.toastContainer.appendChild(toast);
|
||
|
||
// 移除 toast,与 CSS 动画时间保持一致
|
||
setTimeout(() => {
|
||
toast.remove();
|
||
}, duration);
|
||
}
|
||
|
||
/* 显示一个确认模态框。
|
||
* @param {string} title - 模态框标题。
|
||
* @param {string} message - 模态框消息内容。
|
||
* @param {Function} onConfirm - 用户点击确认按钮时的回调函数。
|
||
* @param {Function} [onCancel] - 用户点击取消按钮或关闭模态框时的回调函数。
|
||
*/
|
||
function showModal(title, message, onConfirm, onCancel) {
|
||
Elements.modalTitle.textContent = title;
|
||
Elements.modalMessage.textContent = message;
|
||
Elements.modalOverlay.classList.add('active');
|
||
|
||
// 清除之前的事件监听器
|
||
Elements.modalConfirmBtn.onclick = null;
|
||
Elements.modalCancelBtn.onclick = null;
|
||
Elements.modalOverlay.onclick = null; // 点击背景关闭
|
||
|
||
Elements.modalConfirmBtn.onclick = () => {
|
||
Elements.modalOverlay.classList.remove('active');
|
||
onConfirm();
|
||
};
|
||
|
||
Elements.modalCancelBtn.onclick = () => {
|
||
Elements.modalOverlay.classList.remove('active');
|
||
if (onCancel) {
|
||
onCancel();
|
||
}
|
||
};
|
||
|
||
Elements.modalOverlay.onclick = (e) => {
|
||
if (e.target === Elements.modalOverlay) {
|
||
Elements.modalOverlay.classList.remove('active');
|
||
if (onCancel) {
|
||
onCancel();
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
/* 禁用 / 启用按钮或输入框。
|
||
* @param { HTMLElement } element 要操作的DOM元素。
|
||
* @param { boolean } isDisabled 是否禁用。
|
||
*/
|
||
function toggleLoadingState(element, isDisabled) {
|
||
if (element) {
|
||
element.disabled = isDisabled;
|
||
element.classList.toggle('loading', isDisabled); // 添加/移除一个loading class用于样式
|
||
}
|
||
}
|
||
|
||
/* 更新侧边栏会话信息。
|
||
* @param { Object } sessionInfo 当前会话信息,包含role_id, memory_id, turn_counter等。
|
||
* @param { Object } roles 角色列表,用于查找角色名称。
|
||
* @param { Object } memories 记忆集列表,用于查找记忆名称。
|
||
*/
|
||
function updateSessionInfo(sessionInfo, roles = [], memories = []) {
|
||
Elements.currentRoleIdSpan.textContent = sessionInfo.role_id;
|
||
Elements.currentMemoryIdSpan.textContent = sessionInfo.memory_id;
|
||
Elements.currentTurnCounterSpan.textContent = sessionInfo.turn_counter;
|
||
|
||
const roleName = roles.find(r => r.id === sessionInfo.role_id)?.name || '未知角色';
|
||
const memoryName = memories.find(m => m.id === sessionInfo.memory_id)?.name || '未知记忆集';
|
||
Elements.currentRoleNameSpan.textContent = roleName;
|
||
Elements.currentMemoryNameSpan.textContent = memoryName;
|
||
|
||
// 根据会话状态设置记忆更新状态
|
||
setMemoryUpdateStatus(sessionInfo.memory_status);
|
||
}
|
||
|
||
/* 更新记忆更新状态指示器。
|
||
* @param { string } status 记忆更新状态 ('idle', 'updating', 'success', 'error')。
|
||
*/
|
||
function setMemoryUpdateStatus(status) {
|
||
const container = Elements.memoryUpdateStatusContainer;
|
||
const dot = Elements.memoryUpdateStatusDot;
|
||
const text = Elements.memoryUpdateStatusText;
|
||
|
||
// 移除所有状态类
|
||
dot.classList.remove('updating', 'success', 'error');
|
||
|
||
switch (status) {
|
||
case 'updating':
|
||
container.style.display = 'flex';
|
||
dot.classList.add('updating');
|
||
text.textContent = '记忆整理中...';
|
||
break;
|
||
case 'success':
|
||
container.style.display = 'flex';
|
||
dot.classList.add('success');
|
||
text.textContent = '记忆更新成功';
|
||
break;
|
||
case 'error':
|
||
container.style.display = 'flex';
|
||
dot.classList.add('error');
|
||
text.textContent = '记忆更新失败';
|
||
break;
|
||
case 'idle':
|
||
default:
|
||
container.style.display = 'none'; // 默认隐藏
|
||
text.textContent = '记忆就绪'; // 保持默认文本,虽然不显示
|
||
break;
|
||
}
|
||
}
|
||
|
||
|
||
// --- 聊天区 UI 函数 ---
|
||
|
||
/* 在聊天窗口添加消息。
|
||
* @param { string } sender 'user' 或 'bot'。
|
||
* @param { string } content 消息内容。
|
||
* @param { string } timestamp 消息时间戳。
|
||
* @param { string } [id] 消息的唯一ID,用于后续更新。
|
||
*/
|
||
function addChatMessage(sender, content, timestamp, id = null) {
|
||
const messageElement = document.createElement('div');
|
||
messageElement.classList.add('chat-message', sender);
|
||
// 确保 messageElement 始终有一个 ID,即使没有显式传入
|
||
messageElement.dataset.messageId = id || `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||
|
||
const avatar = document.createElement('div');
|
||
avatar.classList.add('message-avatar');
|
||
avatar.textContent = sender === 'user' ? 'U' : 'B'; // 用户头像显示 'U', 机器人头像显示 'B'
|
||
|
||
const messageContentWrapper = document.createElement('div');
|
||
messageContentWrapper.classList.add('message-content-wrapper');
|
||
|
||
const messageBubble = document.createElement('div');
|
||
messageBubble.classList.add('message-bubble');
|
||
messageBubble.innerHTML = DOMPurify.sanitize(marked.parse(content));
|
||
|
||
const messageTimestamp = document.createElement('div');
|
||
messageTimestamp.classList.add('message-timestamp');
|
||
messageTimestamp.textContent = new Date(timestamp).toLocaleString(); // 格式化时间戳
|
||
|
||
messageContentWrapper.appendChild(messageBubble);
|
||
messageContentWrapper.appendChild(messageTimestamp);
|
||
|
||
// 总是在正确的位置添加头像和内容
|
||
if (sender === 'user') {
|
||
messageElement.appendChild(messageContentWrapper);
|
||
messageElement.appendChild(avatar);
|
||
} else {
|
||
messageElement.appendChild(avatar);
|
||
messageElement.appendChild(messageContentWrapper);
|
||
}
|
||
|
||
Elements.chatWindow.appendChild(messageElement);
|
||
Elements.chatWindow.scrollTop = Elements.chatWindow.scrollHeight; // 滚动到底部
|
||
}
|
||
|
||
/* 渲染聊天历史记录。
|
||
* @param { Array < Object >} messages 聊天消息数组。
|
||
*/
|
||
function renderChatHistory(messages) {
|
||
Elements.chatWindow.innerHTML = ''; // 清空现有内容
|
||
messages.forEach(msg => {
|
||
// addChatMessage 函数现在会处理 ID 的生成,所以这里直接传递 msg.id
|
||
addChatMessage(msg.role, msg.content, msg.timestamp, msg.id);
|
||
});
|
||
}
|
||
|
||
/* 更新单条聊天消息的内容。
|
||
* @param { string } messageId 消息的唯一ID。
|
||
* @param { string } newContent 新的消息内容。
|
||
*/
|
||
function updateChatMessageContent(messageId, newContent) {
|
||
console.log(`尝试更新消息ID: ${messageId}`);
|
||
const messageElement = Elements.chatWindow.querySelector(`.chat-message[data-message-id="${messageId}"]`);
|
||
if (messageElement) {
|
||
console.log(`找到消息元素,ID: ${messageId}`);
|
||
const messageBubble = messageElement.querySelector('.message-bubble');
|
||
if (messageBubble) {
|
||
console.log(`找到消息气泡,更新内容 for ID: ${messageId}`);
|
||
messageBubble.innerHTML = DOMPurify.sanitize(marked.parse(newContent));
|
||
|
||
// 强制重绘和重排
|
||
messageElement.style.opacity = "0.99";
|
||
setTimeout(() => {
|
||
messageElement.style.opacity = "1";
|
||
// 确保滚动到底部
|
||
Elements.chatWindow.scrollTop = Elements.chatWindow.scrollHeight;
|
||
}, 10);
|
||
|
||
// 在动画帧中重新计算布局和滚动
|
||
requestAnimationFrame(() => {
|
||
Elements.chatWindow.scrollTop = Elements.chatWindow.scrollHeight;
|
||
});
|
||
} else {
|
||
console.warn(`未找到 messageId 为 "${messageId}" 的消息气泡。`);
|
||
}
|
||
} else {
|
||
console.warn(`未找到 messageId 为 "${messageId}" 的聊天消息元素。`);
|
||
}
|
||
}
|
||
|
||
// --- 配置区 UI 函数 ---
|
||
|
||
/* 渲染配置表单。
|
||
* @param { Object } configData 配置数据。
|
||
*/
|
||
function renderConfigForm(configData) {
|
||
const apiConfig = configData.API || {};
|
||
const appConfig = configData.Application || {};
|
||
|
||
document.getElementById('gemini-api-base-url').value = apiConfig.GEMINI_API_BASE_URL || '';
|
||
const geminiApiKeyInput = Elements.geminiApiKeyInput;
|
||
const showApiKeyCheckbox = Elements.showApiKeyCheckbox;
|
||
|
||
// 根据用户要求,始终显示完整的API Key
|
||
geminiApiKeyInput.value = apiConfig.GEMINI_API_KEY || '';
|
||
|
||
// 初始时,API Key输入框应为文本类型,且“显示API Key”复选框选中,以始终显示完整的API Key
|
||
geminiApiKeyInput.type = 'text';
|
||
showApiKeyCheckbox.checked = true;
|
||
document.getElementById('context-window-size').value = appConfig.CONTEXT_WINDOW_SIZE || '';
|
||
document.getElementById('memory-retention-turns').value = appConfig.MEMORY_RETENTION_TURNS || '';
|
||
document.getElementById('max-short-term-events').value = appConfig.MAX_SHORT_TERM_EVENTS || '';
|
||
}
|
||
|
||
/* 填充模型选择下拉框。
|
||
* @param { Array < string >} models 模型名称列表。
|
||
* @param { string } selectedDefault 默认选中的对话模型。
|
||
* @param { string } selectedMemory 默认选中的记忆模型。
|
||
*/
|
||
function populateModelSelects(models, selectedDefault, selectedMemory) {
|
||
Elements.defaultGeminiModelSelect.innerHTML = '<option value="">请选择</option>';
|
||
Elements.memoryUpdateModelSelect.innerHTML = '<option value="">请选择</option>';
|
||
|
||
models.forEach(model => {
|
||
// 只显示模型名称的最后一部分(/之后的部分)
|
||
const displayName = model.split('/').pop();
|
||
|
||
const optionDefault = document.createElement('option');
|
||
optionDefault.value = model;
|
||
optionDefault.textContent = displayName;
|
||
Elements.defaultGeminiModelSelect.appendChild(optionDefault);
|
||
|
||
const optionMemory = document.createElement('option');
|
||
optionMemory.value = model;
|
||
optionMemory.textContent = displayName;
|
||
Elements.memoryUpdateModelSelect.appendChild(optionMemory);
|
||
});
|
||
|
||
// 确保正确选中默认模型
|
||
if (selectedDefault) {
|
||
const defaultOption = Elements.defaultGeminiModelSelect.querySelector(`option[value="${selectedDefault}"]`);
|
||
if (defaultOption) {
|
||
defaultOption.selected = true;
|
||
}
|
||
}
|
||
|
||
// 确保正确选中记忆更新模型
|
||
if (selectedMemory) {
|
||
const memoryOption = Elements.memoryUpdateModelSelect.querySelector(`option[value="${selectedMemory}"]`);
|
||
if (memoryOption) {
|
||
memoryOption.selected = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// --- 特征区 UI 函数 ---
|
||
|
||
/* 渲染角色列表。
|
||
* @param { Array < Object >} roles 角色对象数组。
|
||
* @param { string } currentActiveRoleId 当前活跃角色ID。
|
||
* @param { Function } onSwitchRole 点击切换角色的回调函数。
|
||
* @param { Function } onDeleteRole 点击删除角色的回调函数。
|
||
*/
|
||
function renderRoleList(roles, currentActiveRoleId, onSwitchRole, onDeleteRole) {
|
||
Elements.roleListDiv.innerHTML = '';
|
||
roles.forEach(role => {
|
||
const roleItem = document.createElement('div');
|
||
roleItem.classList.add('role-item');
|
||
if (role.id === currentActiveRoleId) {
|
||
roleItem.classList.add('active-item');
|
||
}
|
||
roleItem.innerHTML = `
|
||
<span>${role.name} (${role.id})</span>
|
||
<button class="delete-btn" data-role-id="${role.id}" title="删除角色">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
`;
|
||
roleItem.querySelector('span').addEventListener('click', () => onSwitchRole(role.id));
|
||
roleItem.querySelector('.delete-btn').addEventListener('click', (e) => {
|
||
e.stopPropagation(); // 阻止事件冒泡到父级的点击事件
|
||
onDeleteRole(role.id);
|
||
});
|
||
Elements.roleListDiv.appendChild(roleItem);
|
||
});
|
||
}
|
||
|
||
/* 渲染特征内容。
|
||
* @param { Object } featuresContent 特征JSON对象。
|
||
*/
|
||
function renderFeaturesContent(featuresContent) {
|
||
Elements.featuresContentTextarea.value = JSON.stringify(featuresContent, null, 2);
|
||
}
|
||
|
||
// --- 记忆区 UI 函数 ---
|
||
|
||
/* 渲染记忆集列表。
|
||
* @param { Array < Object >} memories 记忆集对象数组。
|
||
* @param { string } currentActiveMemoryId 当前活跃记忆集ID。
|
||
* @param { Function } onSwitchMemory 点击切换记忆集的回调函数。
|
||
* @param { Function } onDeleteMemory 点击删除记忆集的回调函数。
|
||
*/
|
||
function renderMemoryList(memories, currentActiveMemoryId, onSwitchMemory, onDeleteMemory) {
|
||
Elements.memoryListDiv.innerHTML = '';
|
||
memories.forEach(memory => {
|
||
const memoryItem = document.createElement('div');
|
||
memoryItem.classList.add('memory-item');
|
||
if (memory.id === currentActiveMemoryId) {
|
||
memoryItem.classList.add('active-item');
|
||
}
|
||
memoryItem.innerHTML = `
|
||
<span>${memory.name} (${memory.id})</span>
|
||
<button class="delete-btn" data-memory-id="${memory.id}" title="删除记忆集">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
`;
|
||
memoryItem.querySelector('span').addEventListener('click', () => onSwitchMemory(memory.id));
|
||
memoryItem.querySelector('.delete-btn').addEventListener('click', (e) => {
|
||
e.stopPropagation(); // 阻止事件冒泡到父级的点击事件
|
||
onDeleteMemory(memory.id);
|
||
});
|
||
Elements.memoryListDiv.appendChild(memoryItem);
|
||
});
|
||
}
|
||
|
||
/* 渲染记忆内容。
|
||
* @param { Object } memoryContent 记忆JSON对象。
|
||
*/
|
||
function renderMemoryContent(memoryContent) {
|
||
Elements.memoryContentTextarea.value = JSON.stringify(memoryContent, null, 2);
|
||
}
|
||
|
||
|
||
// --- 日志区 UI 函数 ---
|
||
|
||
/* 渲染日志内容。
|
||
* @param { string } logContent 日志文本。
|
||
*/
|
||
function renderLogContent(logContent) {
|
||
Elements.logContentPre.textContent = logContent;
|
||
Elements.logContentPre.scrollTop = Elements.logContentPre.scrollHeight; // 滚动到底部
|
||
}
|
||
|
||
/* 从聊天窗口移除单条消息。
|
||
* @param { string } messageId 消息的唯一ID。
|
||
*/
|
||
function removeChatMessage(messageId) {
|
||
const messageElement = Elements.chatWindow.querySelector(`.chat-message[data-message-id="${messageId}"]`);
|
||
if (messageElement) {
|
||
messageElement.remove();
|
||
} else {
|
||
console.warn(`未找到 messageId 为 "${messageId}" 的聊天消息元素,无法移除。`);
|
||
}
|
||
}
|
||
|
||
// 导出所有需要被 app.js 调用的函数和 Elements
|
||
export {
|
||
Elements,
|
||
initializeUIElements, // 导出初始化函数
|
||
showSection,
|
||
showToast,
|
||
showModal,
|
||
toggleLoadingState,
|
||
updateSessionInfo,
|
||
setMemoryUpdateStatus,
|
||
addChatMessage,
|
||
renderChatHistory,
|
||
updateChatMessageContent, // 确保这个函数也被导出
|
||
removeChatMessage, // 新增:导出移除消息函数
|
||
renderConfigForm,
|
||
populateModelSelects,
|
||
renderRoleList,
|
||
renderFeaturesContent,
|
||
renderMemoryList,
|
||
renderMemoryContent,
|
||
renderLogContent,
|
||
};
|