diff --git a/server/app.py b/server/app.py index 71e68a5..f723ee6 100644 --- a/server/app.py +++ b/server/app.py @@ -1,5 +1,9 @@ """统一入口:复用 app_legacy,便于逐步替换/收敛。""" import argparse +import os +import json +from datetime import datetime +from flask import request from .app_legacy import ( app, diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 895d187..0eea756 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -805,6 +805,7 @@ let lastUserWheelUpTs = 0; let lastProgrammaticHintTs = 0; let lastProgrammaticHintSource = ''; let suppressUserIntentUntil = 0; +let lastLayoutChangeTs = 0; let scrollListener: ((event: Event) => void) | null = null; let wheelListener: ((event: WheelEvent) => void) | null = null; let traceAttachLogged = false; @@ -924,6 +925,21 @@ function attachBounceListener() { const trusted = (event as any).isTrusted === true; const closeToProgrammaticHint = now - lastProgrammaticHintTs <= 140; const likelyLayoutDriven = Math.abs(heightDelta) > 2; + // 记录最近一次布局驱动的滚动,用于在 CSS transition 结束 / runSync 收尾 + // 后继续抑制一小段时间,防止 stick-to-bottom 的 spring 收尾被误判为用户手动滚动 + if (Math.abs(heightDelta) > 1) { + lastLayoutChangeTs = now; + } + const recentlyLayoutDriven = now - lastLayoutChangeTs <= 500; + // ── 鬼畜根因修复 ── + // 当 scrollHeight 发生任何变化(即使只是 1-2px 的 sub-pixel 差异),说明即将或正在 + // 发生由内容高度变化驱动的自动追底滚动(stick-to-bottom spring)。如果不延长抑制窗口, + // 该滚动事件的 delta 可能较小(<5px)且 heightDelta 不足以触发 likelyLayoutDriven, + // 导致被误判为用户手动滚动 → stopScroll() → spring 中断 → 再次追底 → 振荡鬼畜。 + // 每次检测到高度变化时,将抑制窗口延长至 400ms,完整覆盖 spring 动画周期。 + if (Math.abs(heightDelta) > 0.5) { + suppressUserIntentUntil = Math.max(suppressUserIntentUntil, now + 400); + } const manualUpByWheel = delta < -3 && now - lastUserWheelUpTs <= 240; const strongManualUp = delta < -18 && now > suppressUserIntentUntil && !closeToProgrammaticHint; const shouldTreatAsUserIntent = @@ -931,7 +947,10 @@ function attachBounceListener() { Math.abs(delta) > 5 && now > suppressUserIntentUntil && (!closeToProgrammaticHint || strongManualUp) && - (delta < 0 ? strongManualUp || manualUpByWheel || !likelyLayoutDriven : !likelyLayoutDriven); + (delta < 0 + ? strongManualUp || manualUpByWheel || !likelyLayoutDriven + : !likelyLayoutDriven && !recentlyLayoutDriven); + if (shouldTreatAsUserIntent) { // 用户手动滚动时,立即中断 stick 引擎中可能仍在运行的动画滚动 stopScroll(); @@ -1079,7 +1098,9 @@ async function stickScrollToBottom( // 点击“滚动到底部”按钮时会触发 smooth 动画滚动;期间会产生 trusted scroll 事件, // 若不短暂抑制会被误判为“用户手动滚动”从而中断动画,表现为只滚动一点点。 if (options.force || options.behavior === 'smooth') { - suppressUserIntentUntil = Date.now() + 900; + suppressUserIntentUntil = Date.now() + 1200; + } else { + suppressUserIntentUntil = Date.now() + 600; } return await scrollToBottom({ animation: options.behavior === 'smooth' ? 'smooth' : 'auto', diff --git a/static/src/components/chat/StackedBlocks.vue b/static/src/components/chat/StackedBlocks.vue index 8912eb7..4202e7e 100644 --- a/static/src/components/chat/StackedBlocks.vue +++ b/static/src/components/chat/StackedBlocks.vue @@ -2,6 +2,7 @@