Files
gemini-banlancer/frontend/js/pages/chat/index.js

546 lines
26 KiB
JavaScript

// 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();
}