diff --git a/static/src/composables/useScrollControl.ts b/static/src/composables/useScrollControl.ts index 07da91a..8820d5e 100644 --- a/static/src/composables/useScrollControl.ts +++ b/static/src/composables/useScrollControl.ts @@ -4,6 +4,7 @@ type ScrollContext = { getMessagesAreaElement?: () => HTMLElement | null; getThinkingContentElement?: (blockId: string) => HTMLElement | null; chatToggleScrollLockState?: () => boolean; + chatSetScrollState?: (payload: { autoScrollEnabled?: boolean; userScrolling?: boolean }) => void; thinkingScrollLocks?: Map; _setScrollingFlag?: (value: boolean) => void; autoScrollEnabled?: boolean; @@ -11,7 +12,20 @@ type ScrollContext = { isOutputActive?: () => boolean; }; -export function scrollToBottom(ctx: ScrollContext) { +type ScrollOptions = { + ignoreUserScrolling?: boolean; + /** + * When true, force userScrolling=false after the programmatic scroll so that + * later自动滚动不会被“用户滚动中”状态卡住。 + */ + resetUserScrolling?: boolean; + /** + * Control scroll behavior; 'smooth' 用于点击滚动锁按钮时的动画滚动。 + */ + behavior?: ScrollBehavior; +}; + +export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) { const messagesArea = ctx.getMessagesAreaElement?.(); if (!messagesArea) { return; @@ -19,10 +33,13 @@ export function scrollToBottom(ctx: ScrollContext) { const attempts = [0, 16, 60, 150, 320, 520]; // 多次尝试覆盖布局抖动/异步伸缩 let cancelled = false; + const useSmooth = + options?.behavior === 'smooth' && + typeof (messagesArea as HTMLElement).scrollTo === 'function'; const perform = (idx: number) => { if (cancelled) return; - if (ctx.userScrolling) { + if (ctx.userScrolling && !options?.ignoreUserScrolling) { cancelled = true; if (typeof ctx._setScrollingFlag === 'function') { ctx._setScrollingFlag(false); @@ -34,12 +51,32 @@ export function scrollToBottom(ctx: ScrollContext) { ctx._setScrollingFlag(true); } - messagesArea.scrollTop = messagesArea.scrollHeight; + if (useSmooth) { + (messagesArea as HTMLElement).scrollTo({ + top: messagesArea.scrollHeight, + behavior: 'smooth' + }); + } else { + messagesArea.scrollTop = messagesArea.scrollHeight; + } if (idx < attempts.length - 1) { setTimeout(() => perform(idx + 1), attempts[idx + 1] - attempts[idx]); } else if (typeof ctx._setScrollingFlag === 'function') { setTimeout(() => ctx._setScrollingFlag && ctx._setScrollingFlag(false), 40); + if (options?.resetUserScrolling) { + if (typeof ctx.chatSetScrollState === 'function') { + ctx.chatSetScrollState({ userScrolling: false }); + } else if ('userScrolling' in ctx) { + ctx.userScrolling = false; + } + } + } else if (options?.resetUserScrolling) { + if (typeof ctx.chatSetScrollState === 'function') { + ctx.chatSetScrollState({ userScrolling: false }); + } else if ('userScrolling' in ctx) { + ctx.userScrolling = false; + } } }; @@ -58,7 +95,7 @@ export function toggleScrollLock(ctx: ScrollContext) { // 没有模型输出时:允许点击,但不切换锁定,仅单次滚动到底部 if (!active) { - scrollToBottom(ctx); + scrollToBottom(ctx, { ignoreUserScrolling: true, resetUserScrolling: true, behavior: 'smooth' }); return ctx.autoScrollEnabled ?? false; }