feat: 智能滚动 - 流式回复时可向上查看历史,浮动按钮回到底部
- 流式输出时不再强制锁定底部,用户可自由滚动查看历史 - 滚动离开底部时显示「回到底部」浮动按钮(sticky 定位) - 点击按钮平滑滚动并恢复自动跟踪 - 用户手动滚回底部时自动恢复跟踪 - 发送消息/新建会话时自动重置为跟踪状态 - 首次渲染(加载历史)强制滚到底部
This commit is contained in:
@@ -498,6 +498,58 @@ body::before {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 回到底部浮动按钮 ── */
|
||||||
|
.scroll-to-bottom {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 16px;
|
||||||
|
float: right;
|
||||||
|
margin-right: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid rgba(0, 245, 212, 0.3);
|
||||||
|
background: rgba(10, 10, 26, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-radius: 24px;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 5;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4), 0 0 12px rgba(0, 245, 212, 0.15);
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
animation: scrollBtnIn 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-bottom:hover {
|
||||||
|
background: rgba(0, 245, 212, 0.12);
|
||||||
|
border-color: var(--accent-cyan);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 245, 212, 0.25);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-bottom:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-to-bottom svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
animation: bounceDown 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scrollBtnIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounceDown {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 空状态 ── */
|
/* ── 空状态 ── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -84,6 +84,12 @@
|
|||||||
<p>选择一个模型,输入消息开始聊天</p>
|
<p>选择一个模型,输入消息开始聊天</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="messages" id="messagesContainer" style="display:none;"></div>
|
<div class="messages" id="messagesContainer" style="display:none;"></div>
|
||||||
|
<button class="scroll-to-bottom" id="scrollToBottom" style="display:none;" title="回到最新">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
<span>回到底部</span>
|
||||||
|
</button>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ═══════════════ 历史只读提示栏 ═══════════════ -->
|
<!-- ═══════════════ 历史只读提示栏 ═══════════════ -->
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { initToast, showToast } from './components/toast.js';
|
|||||||
import { initLightbox, closeLightbox } from './components/lightbox.js';
|
import { initLightbox, closeLightbox } from './components/lightbox.js';
|
||||||
import { initHeader, checkConnection } from './components/header.js';
|
import { initHeader, checkConnection } from './components/header.js';
|
||||||
import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js';
|
import { initModelBar, loadModels, setSelectedModel } from './components/model-bar.js';
|
||||||
import { initChatArea, renderMessages, clearMessages } from './components/chat-area.js';
|
import { initChatArea, renderMessages, clearMessages, enableAutoScroll } from './components/chat-area.js';
|
||||||
import { initInputArea } from './components/input-area.js';
|
import { initInputArea } from './components/input-area.js';
|
||||||
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
|
import { initSettingsModal, closeSettingsModal } from './components/settings-modal.js';
|
||||||
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
|
import { initHistoryModal, closeHistoryModal } from './components/history-modal.js';
|
||||||
@@ -50,6 +50,7 @@ async function startNewSession() {
|
|||||||
document.querySelector('#inputArea').style.display = '';
|
document.querySelector('#inputArea').style.display = '';
|
||||||
|
|
||||||
clearMessages();
|
clearMessages();
|
||||||
|
enableAutoScroll();
|
||||||
renderMessages();
|
renderMessages();
|
||||||
document.querySelector('#chatInput')?.focus();
|
document.querySelector('#chatInput')?.focus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,38 @@ function getFileIcon(filename) {
|
|||||||
return map[ext] || '📄';
|
return map[ext] || '📄';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let chatAreaEl, messagesContainerEl, emptyStateEl, scrollBtnEl;
|
||||||
|
let autoScroll = true; // 是否自动滚动到底部
|
||||||
|
|
||||||
export function initChatArea() {
|
export function initChatArea() {
|
||||||
chatAreaEl = document.querySelector('#chatArea');
|
chatAreaEl = document.querySelector('#chatArea');
|
||||||
messagesContainerEl = document.querySelector('#messagesContainer');
|
messagesContainerEl = document.querySelector('#messagesContainer');
|
||||||
emptyStateEl = document.querySelector('#emptyState');
|
emptyStateEl = document.querySelector('#emptyState');
|
||||||
|
scrollBtnEl = document.querySelector('#scrollToBottom');
|
||||||
|
|
||||||
|
// 监听用户手动滚动
|
||||||
|
chatAreaEl.addEventListener('scroll', () => {
|
||||||
|
const atBottom = isAtBottom();
|
||||||
|
if (atBottom) {
|
||||||
|
autoScroll = true;
|
||||||
|
scrollBtnEl.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
autoScroll = false;
|
||||||
|
scrollBtnEl.style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点击滚动到底部按钮
|
||||||
|
scrollBtnEl.addEventListener('click', () => {
|
||||||
|
autoScroll = true;
|
||||||
|
scrollBtnEl.style.display = 'none';
|
||||||
|
chatAreaEl.scrollTo({ top: chatAreaEl.scrollHeight, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断是否在底部附近(60px 容差) */
|
||||||
|
function isAtBottom() {
|
||||||
|
return chatAreaEl.scrollHeight - chatAreaEl.scrollTop - chatAreaEl.clientHeight < 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 安全地将 Markdown 转为 HTML */
|
/** 安全地将 Markdown 转为 HTML */
|
||||||
@@ -58,8 +86,9 @@ export function safeMarkdown(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 滚动到底部 */
|
/** 滚动到底部(仅当 autoScroll 开启时) */
|
||||||
export function scrollToBottom() {
|
export function scrollToBottom() {
|
||||||
|
if (!autoScroll) return;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
|
chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
|
||||||
});
|
});
|
||||||
@@ -80,11 +109,17 @@ export function renderMessages() {
|
|||||||
messagesContainerEl.style.display = '';
|
messagesContainerEl.style.display = '';
|
||||||
|
|
||||||
const existingCount = messagesContainerEl.children.length;
|
const existingCount = messagesContainerEl.children.length;
|
||||||
|
const isNewMessage = existingCount > 0 && existingCount < msgs.length;
|
||||||
for (let i = existingCount; i < msgs.length; i++) {
|
for (let i = existingCount; i < msgs.length; i++) {
|
||||||
appendMessageDOM(msgs[i], i);
|
appendMessageDOM(msgs[i], i);
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom();
|
// 新消息按 autoScroll 决定;首次渲染(加载历史)强制滚到底部
|
||||||
|
if (isNewMessage) {
|
||||||
|
scrollToBottom();
|
||||||
|
} else if (msgs.length > 0) {
|
||||||
|
chatAreaEl.scrollTop = chatAreaEl.scrollHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 追加单条消息 DOM */
|
/** 追加单条消息 DOM */
|
||||||
@@ -274,6 +309,12 @@ export function clearMessages() {
|
|||||||
messagesContainerEl.innerHTML = '';
|
messagesContainerEl.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 重置自动滚动(用户发送消息、新建会话时调用) */
|
||||||
|
export function enableAutoScroll() {
|
||||||
|
autoScroll = true;
|
||||||
|
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// ── 导出功能 ──
|
// ── 导出功能 ──
|
||||||
|
|
||||||
/** 导出为 Markdown */
|
/** 导出为 Markdown */
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { fileToBase64, fileToText, detectLanguage, debounce, truncate, escapeHtm
|
|||||||
import { getSelectedModel, isThinkEnabled, isVisionAvailable } from './model-bar.js';
|
import { getSelectedModel, isThinkEnabled, isVisionAvailable } from './model-bar.js';
|
||||||
import {
|
import {
|
||||||
renderMessages, appendAssistantPlaceholder, appendSystemMessage,
|
renderMessages, appendAssistantPlaceholder, appendSystemMessage,
|
||||||
updateLastAssistantMessage, clearMessages, safeMarkdown
|
updateLastAssistantMessage, clearMessages, safeMarkdown, enableAutoScroll
|
||||||
} from './chat-area.js';
|
} from './chat-area.js';
|
||||||
import { showToast } from './toast.js';
|
import { showToast } from './toast.js';
|
||||||
import { checkConnection } from './header.js';
|
import { checkConnection } from './header.js';
|
||||||
@@ -353,6 +353,7 @@ export async function sendMessage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
msgsToAdd.forEach(m => currentSession.messages.push(m));
|
msgsToAdd.forEach(m => currentSession.messages.push(m));
|
||||||
|
enableAutoScroll();
|
||||||
renderMessages();
|
renderMessages();
|
||||||
|
|
||||||
// 立即保存
|
// 立即保存
|
||||||
|
|||||||
Reference in New Issue
Block a user