diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index bd822f1f..8fbafd91 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -1080,7 +1080,14 @@ class MainTerminalToolsExecutionMixin: self.pending_image_view = { "path": str(path) } - result = {"success": True, "message": "图片已附加到工具结果中,将随 tool 返回。", "path": path} + # size 供前端美化展示(工具结果 meta 区); + # 原图由前端按 path 走 /api/file/content 加载,不在结果里内联 + result = { + "success": True, + "message": "图片已附加到工具结果中,将随 tool 返回。", + "path": path, + "size": abs_path.stat().st_size, + } elif tool_name == "view_video": path = (arguments.get("path") or "").strip() if not path: diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index 28cff758..447d8e4a 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -28,6 +28,9 @@ export async function mounted() { console.warn('CSRF token 初始化失败:', err); }); } + // 移动端视口判断必须在路由/对话加载前生效, + // 否则加载期间移动端会按桌面布局渲染(QuickDock 挤压页面)。 + this.setupMobileViewportWatcher(); // 并行启动路由解析与初始化数据,网页端不再依赖 WebSocket 初始化 const routePromise = this.bootstrapRoute(); await routePromise; @@ -75,7 +78,6 @@ export async function mounted() { window.addEventListener('popstate', this.handlePopState); window.addEventListener('keydown', this.handleMobileOverlayEscape); window.addEventListener('beforeunload', this.handleBeforeUnloadDraftPersist); - this.setupMobileViewportWatcher(); this.subAgentFetch(); this.backgroundCommandFetch(); diff --git a/static/src/app/methods/ui/scroll.ts b/static/src/app/methods/ui/scroll.ts index cfcc9bee..90ed54a8 100644 --- a/static/src/app/methods/ui/scroll.ts +++ b/static/src/app/methods/ui/scroll.ts @@ -81,7 +81,6 @@ export const scrollMethods = { const escaped = !!payload?.escapedFromLock; const isAtBottom = !!payload?.isAtBottom; const isNearBottom = !!payload?.isNearBottom; - const nearBottom = isAtBottom || isNearBottom; const userEscaped = !!this._escapedByUserScroll; uiBounceTrace( 'stick-state-change', @@ -99,9 +98,19 @@ export const scrollMethods = { this.stickIsAtBottom = isAtBottom; this.stickIsNearBottom = isNearBottom; - // 用户主动脱离锁定:只要没回到底部/近底部,就保持“手动滚动中”状态 + // 用户主动脱离锁定:只要没真正回到底部,就保持“手动滚动中”状态。 + // 注意不能用 nearBottom(70px 容差)判定“已回底”:移动端浅滚动脱锁常停在 + // 70px 以内,若据此清除脱锁标志会立刻重新上锁,表现为「第一次滚动必被拽回底部」。 + // 另外脱锁意图后的冷却期(_manualScrollSuppressUntil)内禁止重置:移动端触摸滚动 + // 由合成器驱动,scrollTop 更新滞后于状态事件,此时 scrollTop 可能仍贴着底部, + // 若立即按位置判定“已回底”会误清脱锁标志。 if (userEscaped) { - if (nearBottom) { + const inIntentCooldown = Date.now() <= (this._manualScrollSuppressUntil || 0); + const area = this.getMessagesAreaElement(); + const strictAtBottom = area + ? area.scrollHeight - area.scrollTop - area.clientHeight <= 4 + : isAtBottom; + if (strictAtBottom && !inIntentCooldown) { this._escapedByUserScroll = false; this.chatSetScrollState({ userScrolling: false }); } else { diff --git a/static/src/components/chat/ChatArea.vue b/static/src/components/chat/ChatArea.vue index 43cf5658..c2c97280 100644 --- a/static/src/components/chat/ChatArea.vue +++ b/static/src/components/chat/ChatArea.vue @@ -1203,6 +1203,8 @@ const keepMountedIndexes = computed(() => { // 公式渲染后如果已经在底部附近,主动追底,避免 KaTeX 渲染导致的高度跳变让 stick-to-bottom 锁不住 provide('mathRenderedCallback', () => { + // 快捷导航补间进行中不追底,避免取消跳转动画 + if (quickNavJumpActive) return; if (isNearBottom.value && stickScrollToBottom) { requestAnimationFrame(() => { stickScrollToBottom({ animation: 'instant', preserveScrollPosition: false }); @@ -1230,7 +1232,8 @@ function liveStickLockTick() { liveStickLockRaf = null; if (!props.streamingMessage) return; const el = scrollRef.value; - if (el && isAtBottom.value && !escapedFromLock.value) { + // 快捷导航补间进行中不贴底,避免与补间覆写打架 + if (el && !quickNavJumpActive && isAtBottom.value && !escapedFromLock.value) { const maxTop = el.scrollHeight - el.clientHeight; if (maxTop - el.scrollTop > 1) { markProgrammaticHint('ChatArea.liveStickLock'); @@ -1276,8 +1279,37 @@ let suppressUserIntentUntil = 0; let isHandlingLargeGrowth = false; let scrollListener: ((event: Event) => void) | null = null; let wheelListener: ((event: WheelEvent) => void) | null = null; +let touchStartListener: ((event: TouchEvent) => void) | null = null; +let touchMoveListener: ((event: TouchEvent) => void) | null = null; +let touchEndListener: ((event: TouchEvent) => void) | null = null; let traceAttachLogged = false; +// —— 移动端触摸脱锁 —— +// stick 库的「用户上滚脱锁」只认 wheel 事件;移动端触摸滚动只能靠 scroll 事件推断, +// 但运行期间 resizeDifference 几乎恒非零,库内推断被跳过;同时运行期逐帧贴底锁 +// 每帧都在打 programmatic 标记,scroll 意图识别也被压制。两者叠加导致移动端运行期 +// 第一次上滑百分百脱不了锁、被拽回底部。这里直接监听触摸手势:手指下拉(查看更早 +// 消息)超过阈值即视为脱锁意图,与桌面端 wheel-up 走同一条路径。 +let touchScrollStartY = 0; +let touchEscapeFired = false; + +function isNestedScrollableTarget(target: EventTarget | null): boolean { + // 触摸发生在嵌套滚动容器(代码块/表格横向滚动壳等)内部时,不解除外层锁定 + const container = scrollRef.value; + if (!container || !(target instanceof HTMLElement)) return false; + let el: HTMLElement | null = target; + while (el && el !== container) { + if (el.scrollHeight > el.clientHeight + 2) { + const overflowY = getComputedStyle(el).overflowY; + if (overflowY === 'auto' || overflowY === 'scroll') { + return true; + } + } + el = el.parentElement; + } + return false; +} + function isScrollBounceTraceEnabled() { // 默认关闭滚动追踪日志(逐 scroll 事件的高频日志本身就是性能开销), // 排障时通过 window.__SCROLL_BOUNCE_TRACE__ = true 或 localStorage.scrollBounceTrace = '1' 显式打开 @@ -1343,8 +1375,21 @@ function detachBounceListener() { if (el && wheelListener) { el.removeEventListener('wheel', wheelListener as EventListener, true); } + if (el && touchStartListener) { + el.removeEventListener('touchstart', touchStartListener as EventListener, true); + } + if (el && touchMoveListener) { + el.removeEventListener('touchmove', touchMoveListener as EventListener, true); + } + if (el && touchEndListener) { + el.removeEventListener('touchend', touchEndListener as EventListener, true); + el.removeEventListener('touchcancel', touchEndListener as EventListener, true); + } scrollListener = null; wheelListener = null; + touchStartListener = null; + touchMoveListener = null; + touchEndListener = null; } function attachBounceListener() { @@ -1356,8 +1401,64 @@ function attachBounceListener() { if (wheelListener) { el.removeEventListener('wheel', wheelListener as EventListener, true); } + if (touchStartListener) { + el.removeEventListener('touchstart', touchStartListener as EventListener, true); + } + if (touchMoveListener) { + el.removeEventListener('touchmove', touchMoveListener as EventListener, true); + } + if (touchEndListener) { + el.removeEventListener('touchend', touchEndListener as EventListener, true); + el.removeEventListener('touchcancel', touchEndListener as EventListener, true); + } lastObservedTop = el.scrollTop || 0; lastObservedHeight = el.scrollHeight || 0; + touchScrollStartY = 0; + touchEscapeFired = false; + touchStartListener = (event: TouchEvent) => { + if (event.touches.length !== 1) { + touchScrollStartY = 0; + touchEscapeFired = false; + return; + } + touchScrollStartY = event.touches[0].clientY; + touchEscapeFired = false; + }; + touchMoveListener = (event: TouchEvent) => { + if (touchEscapeFired || !touchScrollStartY || event.touches.length !== 1) return; + const target = scrollRef.value; + if (!target) return; + // 手指下拉 = 内容跟随下移 = scrollTop 减小 = 查看更早消息 = 脱锁意图 + const deltaY = event.touches[0].clientY - touchScrollStartY; + if (deltaY <= 8) return; + const now = Date.now(); + if (now <= suppressUserIntentUntil) return; + if (isNestedScrollableTarget(event.target)) return; + touchEscapeFired = true; + lastUserWheelUpTs = now; + stopScroll(); + stopBlockExpansionAnchors(); + cancelQuickNavJump(); + emit('user-scroll-intent', { + ts: now, + delta: -deltaY, + top: target.scrollTop || 0 + }); + bounceTraceLog( + 'user-scroll-intent:touch-drag', + { deltaY, top: target.scrollTop || 0, ...getScrollMetrics(target) }, + 'user-scroll-intent:touch-drag', + 80 + ); + }; + touchEndListener = () => { + touchScrollStartY = 0; + touchEscapeFired = false; + }; + el.addEventListener('touchstart', touchStartListener, { passive: true, capture: true }); + el.addEventListener('touchmove', touchMoveListener, { passive: true, capture: true }); + el.addEventListener('touchend', touchEndListener, { passive: true, capture: true }); + el.addEventListener('touchcancel', touchEndListener, { passive: true, capture: true }); wheelListener = (event: WheelEvent) => { const target = scrollRef.value; if (!target) return; @@ -1370,6 +1471,7 @@ function attachBounceListener() { lastUserWheelUpTs = now; stopScroll(); stopBlockExpansionAnchors(); + cancelQuickNavJump(); emit('user-scroll-intent', { ts: now, delta: deltaY, @@ -1571,6 +1673,24 @@ function escapeBlockSelector(value: string) { const prevExpandedBlocks = ref(new Set()); +// 运行期间是否跳过展开锚定(保持 f43b5ea7 的旧行为): +// 仅当「贴底锁定中」且目标块是运行态块(自动展开/收起的 thinking/tool)时, +// 才交给 stick-to-bottom / liveStickLock 逐帧追底,避免锚定与贴底防输入栏遮挡打架。 +// 其余情况——用户已上滑脱锁,或操作的是历史/已结束块(手动展开)——一律走锚定, +// 否则运行期间手动展开块没有任何逐帧滚动补偿,文字与外框移动会不同步。 +function shouldSkipExpansionAnchor(element?: HTMLElement | null): boolean { + if (!props.streamingMessage) return false; + // 快捷导航补间进行中不启动新锚定,避免覆写 scrollTop 取消跳转 + if (quickNavJumpActive) return true; + const gluedToBottom = !escapedFromLock.value && !!isAtBottom.value; + if (!gluedToBottom || !element) return false; + return ( + element.classList.contains('processing') || + element.classList.contains('running') || + !!element.querySelector('.processing, .running, .thinking-animation') + ); +} + watch( () => props.expandedBlocks, (newSet, oldSet) => { @@ -1586,9 +1706,6 @@ watch( if (changed.size === 0) return; - // 保守策略:运行期间保持旧逻辑,由 stick-to-bottom 固定向下展开并向上滚动 - if (props.streamingMessage) return; - const container = scrollRef.value; if (!container) return; @@ -1596,6 +1713,9 @@ watch( const el = container.querySelector( `[data-block-id="${escapeBlockSelector(id)}"]` ) as HTMLElement | null; + // 运行期贴底时的运行态块交给 liveStickLock 逐帧追底; + // 手动展开(含运行期上滑后操作历史块)走锚定 + if (shouldSkipExpansionAnchor(el)) continue; if (el) { anchorBlockElement(el, id, { direction: 'auto', @@ -1613,8 +1733,8 @@ function handleStackedMoreToggle(payload: { expanded: boolean; stackKey: string; }) { - // 保守策略:运行期间保持旧逻辑 - if (props.streamingMessage) return; + // 「更多」是用户手动操作,除运行期贴底的运行态块外一律锚定 + if (shouldSkipExpansionAnchor(payload.element)) return; anchorBlockElement(payload.element, `more-${payload.stackKey}`, { direction: 'auto', @@ -1624,15 +1744,14 @@ function handleStackedMoreToggle(payload: { } function handleMinimalGroupToggle(payload: { groupId: string; expanded: boolean }) { - // 保守策略:运行期间保持旧逻辑 - if (props.streamingMessage) return; - const { groupId, expanded } = payload; const container = scrollRef.value; if (!container) return; const el = container.querySelector( `[data-group-id="${escapeBlockSelector(groupId)}"]` ) as HTMLElement | null; + // 手动展开/收起摘要组,除运行期贴底的运行态块外一律锚定 + if (shouldSkipExpansionAnchor(el)) return; if (el) { anchorBlockElement(el, groupId, { direction: 'auto', @@ -2128,6 +2247,7 @@ watch(scrollRef, () => { onBeforeUnmount(() => { detachBounceListener(); stopBlockExpansionAnchors(); + cancelQuickNavJump(); stopLiveStickLock(); if (scrollbarResizeObserver) { scrollbarResizeObserver.disconnect(); @@ -2457,8 +2577,73 @@ function teardownQuickNavLayoutObserver() { } } +// ===== 快捷导航跳转(自驱 rAF 补间)===== +// 不用 virtua scrollToIndex 的 smooth:其内部是原生 scrollTo({behavior:'smooth'}), +// 会被任何一帧外部 scrollTop 覆写(liveStickLock 追底/块过渡锚定/KaTeX 回调)取消, +// 且平滑滚动前等待未测量条目仅有 150ms 引信,超时静默放弃(运行期表现为 +// 「点第一下不动、第二下才跳」)。这里改为每帧自写 scrollTop 的补间: +// 目标偏移每帧经 getItemOffset 重新解析(随条目测量/流式增长自我校正), +// 外部覆写最多抢走一帧、下一帧即被补间夺回,保证平滑且必达。 +let quickNavJumpRaf: number | null = null; +let quickNavJumpActive = false; + +function cancelQuickNavJump() { + if (quickNavJumpRaf !== null) { + cancelAnimationFrame(quickNavJumpRaf); + quickNavJumpRaf = null; + } + quickNavJumpActive = false; +} + +function startQuickNavJump(msgIndex: number) { + const el = scrollRef.value; + const v: any = virtualizerRef.value; + if (!el || !v || typeof v.getItemOffset !== 'function') { + // 兜底:拿不到偏移解析能力时退回 virtua 自带跳转 + v?.scrollToIndex?.(msgIndex, { align: 'start', smooth: true }); + return; + } + cancelQuickNavJump(); + quickNavJumpActive = true; + const startTop = el.scrollTop; + const startTs = performance.now(); + const duration = 380; + const tick = () => { + quickNavJumpRaf = null; + if (!quickNavJumpActive) return; + const maxTop = Math.max(0, el.scrollHeight - el.clientHeight); + const targetTop = Math.max(0, Math.min(Number(v.getItemOffset(msgIndex)) || 0, maxTop)); + const t = Math.min(1, (performance.now() - startTs) / duration); + const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic + markProgrammaticHint('ChatArea.quickNavJump'); + el.scrollTop = startTop + (targetTop - startTop) * eased; + if (t < 1) { + quickNavJumpRaf = requestAnimationFrame(tick); + } else { + // 末帧精确落定,避免测量精化残留误差 + markProgrammaticHint('ChatArea.quickNavJump'); + el.scrollTop = targetTop; + quickNavJumpActive = false; + } + }; + quickNavJumpRaf = requestAnimationFrame(tick); +} + function onQuickNavLineClick(item: any) { - virtualizerRef.value?.scrollToIndex?.(item.msgIndex, { align: 'start', smooth: true }); + // 点击横线跳转 = 用户主动脱离底部锁定,与 wheel-up / 触摸下拉同路径: + // 先 stopScroll() 解除 stick 锁定(库内置 escapedFromLock=true / isAtBottom=false, + // 运行期 liveStickLock 随之停止逐帧拽回),再同步 emit 用户滚动意图让父级 + // 抑制自动追底,最后由自驱补间平滑滚动到目标轮次。 + const target = scrollRef.value; + const now = Date.now(); + stopScroll(); + stopBlockExpansionAnchors(); + emit('user-scroll-intent', { + ts: now, + delta: -1, + top: target ? target.scrollTop || 0 : 0 + }); + startQuickNavJump(item.msgIndex); } // ===== 悬浮预览卡 ===== diff --git a/static/src/components/chat/MarkdownRenderer.vue b/static/src/components/chat/MarkdownRenderer.vue index 5e0b724b..6f5edfb7 100644 --- a/static/src/components/chat/MarkdownRenderer.vue +++ b/static/src/components/chat/MarkdownRenderer.vue @@ -1,11 +1,17 @@