Update: Basic Functions of chat.html 75% maybe
This commit is contained in:
178
frontend/js/pages/chat/SessionManager.js
Normal file
178
frontend/js/pages/chat/SessionManager.js
Normal 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();
|
||||
}
|
||||
}
|
||||
113
frontend/js/pages/chat/chatSettings.js
Normal file
113
frontend/js/pages/chat/chatSettings.js
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
545
frontend/js/pages/chat/index.js
Normal file
545
frontend/js/pages/chat/index.js
Normal 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, "<").replace(/>/g, ">");
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user