Update: Basic Functions of chat.html 75% maybe

This commit is contained in:
XOF
2025-11-28 00:08:25 +08:00
parent 166437c0ac
commit 0839ec35a2
17 changed files with 3962 additions and 785 deletions

View File

@@ -326,6 +326,41 @@ class UIPatterns {
});
}
}
/**
* Sets a button to a loading state by disabling it and showing a spinner.
* It stores the button's original content to be restored later.
* @param {HTMLButtonElement} button - The button element to modify.
*/
setButtonLoading(button) {
if (!button) return;
// Store original content if it hasn't been stored already
if (!button.dataset.originalContent) {
button.dataset.originalContent = button.innerHTML;
}
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
}
/**
* Restores a button from its loading state to its original content and enables it.
* @param {HTMLButtonElement} button - The button element to restore.
*/
clearButtonLoading(button) {
if (!button) return;
if (button.dataset.originalContent) {
button.innerHTML = button.dataset.originalContent;
// Clean up the data attribute
delete button.dataset.originalContent;
}
button.disabled = false;
}
/**
* Returns the HTML for a streaming text cursor animation.
* This is used as a placeholder in the chat UI while waiting for an assistant's response.
* @returns {string} The HTML string for the loader.
*/
renderStreamingLoader() {
return '<span class="streaming-cursor animate-pulse">▋</span>';
}
}
/**

View File

@@ -16,6 +16,7 @@ const pageModules = {
'dashboard': () => import('./pages/dashboard.js'),
'keys': () => import('./pages/keys/index.js'),
'logs': () => import('./pages/logs/index.js'),
'chat': () => import('./pages/chat/index.js'),
// 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面
// 未来新增的页面只需在这里添加一行映射esbuild会自动处理
};

View File

@@ -0,0 +1,178 @@
// Filename: frontend/js/pages/chat/SessionManager.js
import { nanoid } from 'https://cdn.jsdelivr.net/npm/nanoid/nanoid.js';
const LOCAL_STORAGE_KEY = 'gemini_chat_state';
/**
* Manages the state and persistence of chat sessions.
* This class handles loading from/saving to localStorage,
* and all operations like creating, switching, and deleting sessions.
*/
export class SessionManager {
constructor() {
this.state = null;
}
/**
* Initializes the manager by loading state from localStorage or creating a default state.
*/
init() {
this._loadState();
}
// --- Public API for state access ---
getSessions() {
return this.state.sessions;
}
getCurrentSessionId() {
return this.state.currentSessionId;
}
getCurrentSession() {
return this.state.sessions.find(s => s.id === this.state.currentSessionId);
}
// --- Public API for state mutation ---
/**
* Creates a new, empty session and sets it as the current one.
*/
createSession() {
const newSessionId = nanoid();
const newSession = {
id: newSessionId,
name: '新会话',
systemPrompt: '',
messages: [],
modelConfig: { model: 'gemini-2.0-flash-lite' },
params: { temperature: 0.7 }
};
this.state.sessions.unshift(newSession);
this.state.currentSessionId = newSessionId;
this._saveState();
}
/**
* Switches the current session to the one with the given ID.
* @param {string} sessionId The ID of the session to switch to.
*/
switchSession(sessionId) {
if (this.state.currentSessionId === sessionId) return;
this.state.currentSessionId = sessionId;
this._saveState();
}
/**
* Deletes a session by its ID.
* @param {string} sessionId The ID of the session to delete.
*/
deleteSession(sessionId) {
this.state.sessions = this.state.sessions.filter(s => s.id !== sessionId);
if (this.state.currentSessionId === sessionId) {
this.state.currentSessionId = this.state.sessions[0]?.id || null;
if (!this.state.currentSessionId) {
this._createInitialState(); // Create a new one if all are deleted
}
}
this._saveState();
}
/**
* [NEW] Clears all messages from the currently active session.
*/
clearCurrentSession() {
const currentSession = this.getCurrentSession();
if (currentSession) {
currentSession.messages = [];
this._saveState();
}
}
/**
* Adds a message to the current session and updates the session name if it's the first message.
* @param {object} message The message object to add.
* @returns {object} The session that was updated.
*/
addMessage(message) {
const currentSession = this.getCurrentSession();
if (currentSession) {
if (currentSession.messages.length === 0 && message.role === 'user') {
currentSession.name = message.content.substring(0, 30);
}
currentSession.messages.push(message);
this._saveState();
return currentSession;
}
return null;
}
deleteMessage(messageId) {
const currentSession = this.getCurrentSession();
if (currentSession) {
const messageIndex = currentSession.messages.findIndex(m => m.id === messageId);
if (messageIndex > -1) {
currentSession.messages.splice(messageIndex, 1);
this._saveState();
console.log(`Message ${messageId} deleted.`);
}
}
}
truncateMessagesAfter(messageId) {
const currentSession = this.getCurrentSession();
if (currentSession) {
const messageIndex = currentSession.messages.findIndex(m => m.id === messageId);
// Ensure the message exists and it's not already the last one
if (messageIndex > -1 && messageIndex < currentSession.messages.length - 1) {
currentSession.messages.splice(messageIndex + 1);
this._saveState();
console.log(`Truncated messages after ${messageId}.`);
}
}
}
// --- Private persistence methods ---
_saveState() {
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.state));
} catch (error) {
console.error("Failed to save session state:", error);
}
}
_loadState() {
try {
const stateString = localStorage.getItem(LOCAL_STORAGE_KEY);
if (stateString) {
this.state = JSON.parse(stateString);
} else {
this._createInitialState();
}
} catch (error) {
console.error("Failed to load session state, creating initial state:", error);
this._createInitialState();
}
}
_createInitialState() {
const initialSessionId = nanoid();
this.state = {
sessions: [{
id: initialSessionId,
name: '新会话',
systemPrompt: '',
messages: [],
modelConfig: { model: 'gemini-2.0-flash-lite' },
params: { temperature: 0.7 }
}],
currentSessionId: initialSessionId,
settings: {}
};
this._saveState();
}
}

View File

@@ -0,0 +1,113 @@
// Filename: frontend/js/pages/chat/chatSettings.js
export class ChatSettings {
constructor(elements) {
// [MODIFIED] Store the root elements passed from ChatPage
this.elements = {};
this.elements.root = elements; // Keep a reference to all elements
// [MODIFIED] Query for specific elements this class controls, relative to their panels
this._initScopedDOMElements();
// Initialize panel states to ensure they are collapsed on load
this.elements.quickSettingsPanel.style.gridTemplateRows = '0fr';
this.elements.sessionParamsPanel.style.gridTemplateRows = '0fr';
}
// [NEW] A dedicated method to find elements within their specific panels
_initScopedDOMElements() {
this.elements.quickSettingsPanel = this.elements.root.quickSettingsPanel;
this.elements.sessionParamsPanel = this.elements.root.sessionParamsPanel;
this.elements.toggleQuickSettingsBtn = this.elements.root.toggleQuickSettingsBtn;
this.elements.toggleSessionParamsBtn = this.elements.root.toggleSessionParamsBtn;
// Query elements within the quick settings panel
this.elements.btnGroups = this.elements.quickSettingsPanel.querySelectorAll('.btn-group');
this.elements.directRoutingOptions = this.elements.quickSettingsPanel.querySelector('#direct-routing-options');
// Query elements within the session params panel
this.elements.temperatureSlider = this.elements.sessionParamsPanel.querySelector('#temperature-slider');
this.elements.temperatureValue = this.elements.sessionParamsPanel.querySelector('#temperature-value');
this.elements.contextSlider = this.elements.sessionParamsPanel.querySelector('#context-slider');
this.elements.contextValue = this.elements.sessionParamsPanel.querySelector('#context-value');
}
init() {
if (!this.elements.toggleQuickSettingsBtn) {
console.warn("ChatSettings: Aborting initialization, required elements not found.");
return;
}
this._initPanelToggleListeners();
this._initButtonGroupListeners();
this._initSliderListeners();
}
_initPanelToggleListeners() {
this.elements.toggleQuickSettingsBtn.addEventListener('click', () =>
this._togglePanel(this.elements.quickSettingsPanel, this.elements.toggleQuickSettingsBtn)
);
this.elements.toggleSessionParamsBtn.addEventListener('click', () =>
this._togglePanel(this.elements.sessionParamsPanel, this.elements.toggleSessionParamsBtn)
);
}
_initButtonGroupListeners() {
// [FIXED] This logic is now guaranteed to work with the correctly scoped elements.
this.elements.btnGroups.forEach(group => {
group.addEventListener('click', (e) => {
const button = e.target.closest('.btn-group-item');
if (!button) return;
group.querySelectorAll('.btn-group-item').forEach(btn => btn.removeAttribute('data-active'));
button.setAttribute('data-active', 'true');
if (button.dataset.group === 'routing-mode') {
this._handleRoutingModeChange(button.dataset.value);
}
});
});
}
_initSliderListeners() {
// [FIXED] Add null checks for robustness, now that elements are queried scoped.
if (this.elements.temperatureSlider) {
this.elements.temperatureSlider.addEventListener('input', () => {
this.elements.temperatureValue.textContent = parseFloat(this.elements.temperatureSlider.value).toFixed(1);
});
}
if (this.elements.contextSlider) {
this.elements.contextSlider.addEventListener('input', () => {
this.elements.contextValue.textContent = `${this.elements.contextSlider.value}k`;
});
}
}
_handleRoutingModeChange(selectedValue) {
// [FIXED] This logic now correctly targets the scoped element.
if (this.elements.directRoutingOptions) {
if (selectedValue === 'direct') {
this.elements.directRoutingOptions.classList.remove('hidden');
} else {
this.elements.directRoutingOptions.classList.add('hidden');
}
}
}
_togglePanel(panel, button) {
const isExpanded = panel.hasAttribute('data-expanded');
this.elements.quickSettingsPanel.removeAttribute('data-expanded');
this.elements.sessionParamsPanel.removeAttribute('data-expanded');
this.elements.toggleQuickSettingsBtn.removeAttribute('data-active');
this.elements.toggleSessionParamsBtn.removeAttribute('data-active');
this.elements.quickSettingsPanel.style.gridTemplateRows = '0fr';
this.elements.sessionParamsPanel.style.gridTemplateRows = '0fr';
if (!isExpanded) {
panel.setAttribute('data-expanded', 'true');
button.setAttribute('data-active', 'true');
panel.style.gridTemplateRows = '1fr';
}
}
}

View File

@@ -0,0 +1,545 @@
// Filename: frontend/js/pages/chat/index.js
import { nanoid } from '../../vendor/nanoid.js';
import { uiPatterns } from '../../components/ui.js';
import { apiFetch } from '../../services/api.js';
import { marked } from '../../vendor/marked.min.js';
import { SessionManager } from './SessionManager.js';
import { ChatSettings } from './chatSettings.js';
import CustomSelectV2 from '../../components/customSelectV2.js';
marked.use({ breaks: true, gfm: true });
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
class ChatPage {
constructor() {
this.sessionManager = new SessionManager();
this.isStreaming = false;
this.elements = {};
this.initialized = false;
this.searchTerm = '';
this.settingsManager = null;
}
init() {
if (!document.querySelector('[data-page-id="chat"]')) { return; }
this.sessionManager.init();
this.initialized = true;
this._initDOMElements();
this._initComponents();
this._initEventListeners();
this._render();
console.log("ChatPage initialized. Session management is delegated.", this.sessionManager.state);
}
_initDOMElements() {
this.elements.chatScrollContainer = document.getElementById('chat-scroll-container');
this.elements.chatMessagesContainer = document.getElementById('chat-messages-container');
this.elements.messageForm = document.getElementById('message-form');
this.elements.messageInput = document.getElementById('message-input');
this.elements.sendBtn = document.getElementById('send-btn');
this.elements.newSessionBtn = document.getElementById('new-session-btn');
this.elements.sessionListContainer = document.getElementById('session-list-container');
this.elements.chatHeaderTitle = document.querySelector('.chat-header-title');
this.elements.clearSessionBtn = document.getElementById('clear-session-btn');
this.elements.sessionSearchInput = document.getElementById('session-search-input');
this.elements.toggleQuickSettingsBtn = document.getElementById('toggle-quick-settings');
this.elements.toggleSessionParamsBtn = document.getElementById('toggle-session-params');
this.elements.quickSettingsPanel = document.getElementById('quick-settings-panel');
this.elements.sessionParamsPanel = document.getElementById('session-params-panel');
this.elements.directRoutingOptions = document.getElementById('direct-routing-options');
this.elements.btnGroups = document.querySelectorAll('.btn-group');
this.elements.temperatureSlider = document.getElementById('temperature-slider');
this.elements.temperatureValue = document.getElementById('temperature-value');
this.elements.contextSlider = document.getElementById('context-slider');
this.elements.contextValue = document.getElementById('context-value');
this.elements.groupSelectContainer = document.getElementById('group-select-container');
}
// [NEW] A dedicated method for initializing complex UI components
_initComponents() {
if (this.elements.groupSelectContainer) {
new CustomSelectV2(this.elements.groupSelectContainer);
}
// In the future, we will initialize the model select component here as well
}
_initEventListeners() {
// --- Initialize Settings Manager First ---
this.settingsManager = new ChatSettings(this.elements);
this.settingsManager.init();
// --- Core Chat Event Listeners ---
this.elements.messageForm.addEventListener('submit', (e) => { e.preventDefault(); this._handleSendMessage(); });
this.elements.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._handleSendMessage(); } });
this.elements.messageInput.addEventListener('input', () => this._autoResizeTextarea());
this.elements.newSessionBtn.addEventListener('click', () => {
this.sessionManager.createSession();
this._render();
this.elements.messageInput.focus();
});
this.elements.sessionListContainer.addEventListener('click', (e) => {
const sessionItem = e.target.closest('[data-session-id]');
const deleteBtn = e.target.closest('.delete-session-btn');
if (deleteBtn) {
e.preventDefault();
const sessionId = deleteBtn.closest('[data-session-id]').dataset.sessionId;
this._handleDeleteSession(sessionId);
} else if (sessionItem) {
e.preventDefault();
const sessionId = sessionItem.dataset.sessionId;
this.sessionManager.switchSession(sessionId);
this._render();
this.elements.messageInput.focus();
}
});
this.elements.clearSessionBtn.addEventListener('click', () => this._handleClearSession());
this.elements.sessionSearchInput.addEventListener('input', (e) => {
this.searchTerm = e.target.value.trim();
this._renderSessionList();
});
this.elements.chatMessagesContainer.addEventListener('click', (e) => {
const messageElement = e.target.closest('[data-message-id]');
if (!messageElement) return;
const messageId = messageElement.dataset.messageId;
const copyBtn = e.target.closest('.action-copy');
const deleteBtn = e.target.closest('.action-delete');
const retryBtn = e.target.closest('.action-retry');
if (copyBtn) {
this._handleCopyMessage(messageId);
} else if (deleteBtn) {
this._handleDeleteMessage(messageId, e.target);
} else if (retryBtn) {
this._handleRetryMessage(messageId);
}
});
}
_handleCopyMessage(messageId) {
const currentSession = this.sessionManager.getCurrentSession();
if (!currentSession) return;
const message = currentSession.messages.find(m => m.id === messageId);
if (!message || !message.content) {
console.error("Message content not found for copying.");
return;
}
// Handle cases where content might be HTML (like error messages)
// by stripping tags to get plain text.
let textToCopy = message.content;
if (textToCopy.includes('<') && textToCopy.includes('>')) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = textToCopy;
textToCopy = tempDiv.textContent || tempDiv.innerText || '';
}
navigator.clipboard.writeText(textToCopy)
.then(() => {
Swal.fire({
toast: true,
position: 'top-end',
icon: 'success',
title: '已复制',
showConfirmButton: false,
timer: 1500,
customClass: {
popup: `${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}`
}
});
})
.catch(err => {
console.error('Failed to copy text: ', err);
Swal.fire({
toast: true,
position: 'top-end',
icon: 'error',
title: '复制失败',
showConfirmButton: false,
timer: 1500,
customClass: {
popup: `${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}`
}
});
});
}
_handleDeleteMessage(messageId, targetElement) {
// Remove any existing popover first to prevent duplicates
const existingPopover = document.getElementById('delete-confirmation-popover');
if (existingPopover) {
existingPopover.remove();
}
// Create the popover element with your specified dimensions.
const popover = document.createElement('div');
popover.id = 'delete-confirmation-popover';
// [MODIFIED] - Using your w-36, and adding flexbox classes for centering.
popover.className = 'absolute z-50 p-3 w-45 border border-border rounded-md shadow-lg bg-background text-popover-foreground flex flex-col items-center';
// [MODIFIED] - Added an icon and classes for horizontal centering.
popover.innerHTML = `
<div class="flex items-center gap-2 mb-2">
<i class="fas fa-exclamation-circle text-yellow-500"></i>
<p class="text-sm">确认删除此消息吗?</p>
</div>
<div class="flex translate-x-12 gap-2 w-full">
<button class="btn btn-secondary rounded-xs w-12 btn-xs popover-cancel">取消</button>
<button class="btn btn-destructive rounded-xs w-12 btn-xs popover-confirm">确认</button>
</div>
`;
document.body.appendChild(popover);
// Position the popover above the clicked icon
const iconRect = targetElement.closest('button').getBoundingClientRect();
const popoverRect = popover.getBoundingClientRect();
popover.style.top = `${window.scrollY + iconRect.top - popoverRect.height - 8}px`;
popover.style.left = `${window.scrollX + iconRect.left + (iconRect.width / 2) - (popoverRect.width / 2)}px`;
// Event listener to close the popover if clicked outside
const outsideClickListener = (event) => {
if (!popover.contains(event.target) && event.target !== targetElement) {
popover.remove();
document.removeEventListener('click', outsideClickListener);
}
};
setTimeout(() => document.addEventListener('click', outsideClickListener), 0);
// Event listeners for the buttons inside the popover
popover.querySelector('.popover-confirm').addEventListener('click', () => {
this.sessionManager.deleteMessage(messageId);
this._renderChatMessages();
this._renderSessionList();
popover.remove();
document.removeEventListener('click', outsideClickListener);
});
popover.querySelector('.popover-cancel').addEventListener('click', () => {
popover.remove();
document.removeEventListener('click', outsideClickListener);
});
}
_handleRetryMessage(messageId) {
if (this.isStreaming) return; // Prevent retrying while a response is already generating
const currentSession = this.sessionManager.getCurrentSession();
if (!currentSession) return;
const message = currentSession.messages.find(m => m.id === messageId);
if (!message) return;
if (message.role === 'user') {
// Logic for retrying from a user's prompt
this.sessionManager.truncateMessagesAfter(messageId);
} else if (message.role === 'assistant') {
// Logic for regenerating an assistant's response (must be the last one)
this.sessionManager.deleteMessage(messageId);
}
// After data manipulation, update the UI and trigger a new response
this._renderChatMessages();
this._renderSessionList();
this._getAssistantResponse();
}
_autoResizeTextarea() {
const el = this.elements.messageInput;
el.style.height = 'auto';
el.style.height = (el.scrollHeight) + 'px';
}
_handleSendMessage() {
if (this.isStreaming) return;
const content = this.elements.messageInput.value.trim();
if (!content) return;
const userMessage = { id: nanoid(), role: 'user', content: content };
this.sessionManager.addMessage(userMessage);
this._renderChatMessages();
this._renderSessionList();
this.elements.messageInput.value = '';
this._autoResizeTextarea();
this.elements.messageInput.focus();
this._getAssistantResponse();
}
async _getAssistantResponse() {
this.isStreaming = true;
this._setLoadingState(true);
const currentSession = this.sessionManager.getCurrentSession();
const assistantMessageId = nanoid();
let finalAssistantMessage = { id: assistantMessageId, role: 'assistant', content: '' };
// Step 1: Create and render a temporary UI placeholder for streaming.
// [MODIFIED] The placeholder now uses the three-dot animation.
const placeholderHtml = `
<div class="flex items-start gap-4" data-message-id="${assistantMessageId}">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<i class="fas fa-robot"></i>
</span>
<div class="flex-1 space-y-2">
<div class="relative group rounded-lg p-5 bg-primary/10 border/20">
<div class="prose prose-sm max-w-none text-foreground message-content">
<div class="flex items-center gap-1">
<span class="h-2 w-2 bg-foreground/50 rounded-full animate-bounce" style="animation-delay: 0s;"></span>
<span class="h-2 w-2 bg-foreground/50 rounded-full animate-bounce" style="animation-delay: 0.1s;"></span>
<span class="h-2 w-2 bg-foreground/50 rounded-full animate-bounce" style="animation-delay: 0.2s;"></span>
</div>
</div>
</div>
</div>
</div>`;
this.elements.chatMessagesContainer.insertAdjacentHTML('beforeend', placeholderHtml);
this._scrollToBottom();
const assistantMessageContentEl = this.elements.chatMessagesContainer.querySelector(`[data-message-id="${assistantMessageId}"] .message-content`);
try {
const token = getCookie('gemini_admin_session');
const headers = { 'Authorization': `Bearer ${token}` };
const response = await apiFetch('/v1/chat/completions', {
method: 'POST',
headers,
body: JSON.stringify({
model: currentSession.modelConfig.model,
messages: currentSession.messages.filter(m => m.content).map(({ role, content }) => ({ role, content })),
stream: true,
})
});
if (!response.body) throw new Error("Response body is null.");
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim().startsWith('data:'));
for (const line of lines) {
const dataStr = line.replace(/^data: /, '').trim();
if (dataStr !== '[DONE]') {
try {
const data = JSON.parse(dataStr);
const deltaContent = data.choices[0]?.delta?.content;
if (deltaContent) {
finalAssistantMessage.content += deltaContent;
assistantMessageContentEl.innerHTML = marked.parse(finalAssistantMessage.content);
this._scrollToBottom();
}
} catch (e) { /* ignore malformed JSON */ }
}
}
}
} catch (error) {
console.error('Fetch stream error:', error);
const errorMessage = error.rawMessageFromServer || error.message;
finalAssistantMessage.content = `<span class="text-red-500">请求失败: ${errorMessage}</span>`;
} finally {
this.sessionManager.addMessage(finalAssistantMessage);
this._renderChatMessages();
this._renderSessionList();
this.isStreaming = false;
this._setLoadingState(false);
this.elements.messageInput.focus();
}
}
_renderMessage(message, replace = false, isLastMessage = false) {
let contentHtml;
if (message.role === 'user') {
const escapedContent = message.content.replace(/</g, "&lt;").replace(/>/g, "&gt;");
contentHtml = `<p class="text-sm text-foreground message-content">${escapedContent.replace(/\n/g, '<br>')}</p>`;
} else {
// [FIXED] Simplified logic: if it's an assistant message, it either has real content or error HTML.
// The isStreamingPlaceholder case is now handled differently and removed from here.
const isErrorHtml = message.content && message.content.includes('<span class="text-red-500">');
contentHtml = isErrorHtml ?
`<div class="message-content">${message.content}</div>` :
`<div class="prose prose-sm max-w-none text-foreground message-content">${marked.parse(message.content || '')}</div>`;
}
// [FIXED] No special handling for streaming placeholders needed anymore.
// If a message has content, it gets actions. An error message has content, so it will get actions.
let actionsHtml = '';
let retryButton = '';
if (message.role === 'user') {
retryButton = `
<button class="btn btn-ghost btn-icon w-6 h-6 hover:text-sky-500 action-retry rounded-full" title="从此消息重新生成">
<i class="fas fa-redo text-xs"></i>
</button>`;
} else if (message.role === 'assistant' && isLastMessage) {
// This now correctly applies to final error messages too.
retryButton = `
<button class="btn btn-ghost btn-icon w-6 h-6 hover:text-sky-500 action-retry rounded-full" title="重新生成回答">
<i class="fas fa-redo text-xs"></i>
</button>`;
}
const toolbarBaseClasses = "message-actions flex items-center gap-1 transition-opacity duration-200";
const toolbarPositionClass = isLastMessage ? "mt-2" : "absolute bottom-2.5 right-2.5";
const visibilityClass = isLastMessage ? "" : "opacity-0 group-hover:opacity-100";
actionsHtml = `
<div class="${toolbarBaseClasses} ${toolbarPositionClass} ${visibilityClass}">
${retryButton}
<button class="btn btn-ghost btn-icon w-6 h-6 hover:text-sky-500 action-copy rounded-full" title="复制">
<i class="far fa-copy text-xs"></i>
</button>
<button class="btn btn-ghost btn-icon w-6 h-6 hover:text-sky-500 action-delete rounded-full" title="删除">
<i class="far fa-trash-alt text-xs"></i>
</button>
</div>
`;
const messageBubbleClasses = `relative group rounded-lg p-5 ${message.role === 'user' ? 'bg-muted' : 'bg-primary/10 border/20'}`;
const messageHtml = `
<div class="flex items-start gap-4" data-message-id="${message.id}">
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${message.role === 'user' ? 'bg-secondary text-secondary-foreground' : 'bg-primary text-primary-foreground'}">
<i class="fas ${message.role === 'user' ? 'fa-user' : 'fa-robot'}"></i>
</span>
<div class="flex-1 space-y-2">
<div class="${messageBubbleClasses}">
${contentHtml}
${actionsHtml}
</div>
</div>
</div>`;
const existingElement = this.elements.chatMessagesContainer.querySelector(`[data-message-id="${message.id}"]`);
if (replace && existingElement) {
existingElement.outerHTML = messageHtml;
} else if (!existingElement) {
this.elements.chatMessagesContainer.insertAdjacentHTML('beforeend', messageHtml);
}
if (!replace) { this._scrollToBottom(); }
}
_scrollToBottom() {
this.elements.chatScrollContainer.scrollTop = this.elements.chatScrollContainer.scrollHeight;
}
_render() {
this._renderSessionList();
this._renderChatMessages();
this._renderChatHeader();
}
_renderSessionList() {
let sessions = this.sessionManager.getSessions();
const currentSessionId = this.sessionManager.getCurrentSessionId();
if (this.searchTerm) {
const lowerCaseSearchTerm = this.searchTerm.toLowerCase();
sessions = sessions.filter(session => {
const titleMatch = session.name.toLowerCase().includes(lowerCaseSearchTerm);
const messageMatch = session.messages.some(message =>
message.content && message.content.toLowerCase().includes(lowerCaseSearchTerm)
);
return titleMatch || messageMatch;
});
}
this.elements.sessionListContainer.innerHTML = sessions.map(session => {
const isActive = session.id === currentSessionId;
const lastMessage = session.messages.length > 0 ? session.messages[session.messages.length - 1].content : '新会话';
return `
<div class="relative group flex items-center" data-session-id="${session.id}">
<a href="#" class="grow flex flex-col items-start gap-2 rounded-lg p-3 text-left text-sm transition-all hover:bg-accent ${isActive ? 'bg-accent' : ''} min-w-0">
<div class="w-full font-semibold truncate pr-2">${session.name}</div>
<div class="w-full text-xs text-muted-foreground line-clamp-2">${session.messages.length > 0 ? (lastMessage.includes('<span class="text-red-500">') ? '[请求失败]' : lastMessage) : '新会话'}</div>
</a>
<button class="delete-session-btn absolute top-1/2 -translate-y-1/2 right-2 w-6 h-6 flex items-center justify-center rounded-full bg-muted text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-destructive/80 hover:text-destructive-foreground transition-all" aria-label="删除会话">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).join('');
}
_renderChatMessages() {
this.elements.chatMessagesContainer.innerHTML = '';
const currentSession = this.sessionManager.getCurrentSession();
if (currentSession) {
const messages = currentSession.messages;
const lastMessageIndex = messages.length > 0 ? messages.length - 1 : -1;
messages.forEach((message, index) => {
const isLastMessage = (index === lastMessageIndex);
this._renderMessage(message, false, isLastMessage);
});
}
}
_renderChatHeader() {
const currentSession = this.sessionManager.getCurrentSession();
if (currentSession && this.elements.chatHeaderTitle) {
this.elements.chatHeaderTitle.textContent = currentSession.name;
}
}
_setLoadingState(isLoading) {
this.elements.messageInput.disabled = isLoading;
this.elements.sendBtn.disabled = isLoading;
if (isLoading) {
uiPatterns.setButtonLoading(this.elements.sendBtn);
} else {
uiPatterns.clearButtonLoading(this.elements.sendBtn);
}
}
_handleClearSession() {
Swal.fire({
width: '22rem',
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` },
title: '确定要清空会话吗?',
text: '当前会话的所有聊天记录将被删除,但会话本身会保留。',
showCancelButton: true,
confirmButtonText: '确认清空',
cancelButtonText: '取消',
reverseButtons: false,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#6b7280',
focusConfirm: false,
focusCancel: true,
}).then((result) => {
if (result.isConfirmed) {
this.sessionManager.clearCurrentSession(); // This method needs to be added to SessionManager
this._render();
}
});
}
_handleDeleteSession(sessionId) {
Swal.fire({
width: '22rem',
backdrop: `rgba(0,0,0,0.5)`,
heightAuto: false,
customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` },
title: '确定要删除吗?',
text: '此会话的所有记录将被永久删除,无法撤销。',
showCancelButton: true,
confirmButtonText: '确认删除',
cancelButtonText: '取消',
reverseButtons: false,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#6b7280',
focusConfirm: false,
focusCancel: true,
}).then((result) => {
if (result.isConfirmed) {
this.sessionManager.deleteSession(sessionId);
this._render();
}
});
}
}
export default function() {
const page = new ChatPage();
page.init();
}

72
frontend/js/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
frontend/js/vendor/nanoid.js vendored Normal file
View File

@@ -0,0 +1 @@
let a="useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";export let nanoid=(e=21)=>{let t="",r=crypto.getRandomValues(new Uint8Array(e));for(let n=0;n<e;n++)t+=a[63&r[n]];return t};