Files
gemini_boy/static/js/ui_manager.js

514 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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,
};