fix(chat): 修复移动端运行期滚动脱锁失效,流式渲染分块防重建

- ChatArea: 新增触摸手势脱锁(手指下拉>8px 视为脱锁意图),与桌面端 wheel-up 同路径;嵌套滚动容器内触摸不解除外层锁定
- ChatArea: 展开锚定改为按条件跳过——仅贴底锁定中的运行态块交给逐帧追底,手动展开(含脱锁后历史块)走锚定,修复外框与内容移动不同步
- scroll: 脱锁标志重置改为严格贴底判定(<=4px),nearBottom 70px 容差与意图冷却期内不再误清脱锁标志,修复移动端第一次上滑必被拽回底部
- lifecycle: 移动端视口 watcher 提前到路由/对话加载前,避免加载期按桌面布局渲染
- MarkdownRenderer: 文本段按顶层块分片渲染+分段级缓存,流式期间已完成前缀块 DOM 不再重设,保护 show_file 卡片/表格/KaTeX 不被反复重建
- view_image: 工具结果携带 size,前端按 path 走 /api/file/content 加载原图,图片限高 480px 防长截图占满对话区
This commit is contained in:
JOJO 2026-08-07 11:32:20 +08:00
parent d2e4422c80
commit d2dd097248
9 changed files with 332 additions and 24 deletions

View File

@ -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:

View File

@ -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();

View File

@ -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;
// 用户主动脱离锁定:只要没回到底部/近底部,就保持“手动滚动中”状态
// 用户主动脱离锁定:只要没真正回到底部,就保持“手动滚动中”状态。
// 注意不能用 nearBottom70px 容差)判定“已回底”:移动端浅滚动脱锁常停在
// 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 {

View File

@ -1203,6 +1203,8 @@ const keepMountedIndexes = computed<number[]>(() => {
// 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<string>());
// 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);
}
// ===== =====

View File

@ -1,11 +1,17 @@
<template>
<div ref="containerRef" class="markdown-renderer">
<template v-for="segment in segments" :key="segment.key">
<div
v-if="segment.type === 'text'"
class="markdown-text-segment"
v-html="renderText(segment.content)"
></div>
<!-- 文本段按顶层块分片渲染流式期间只有末尾块字符串变化
已完成的前缀块 v-html 字符串不变DOM 不被重设
保护 show_file 卡片/表格/KaTeX/图片等已增强内容不被反复重建 -->
<div v-if="segment.type === 'text'" class="markdown-text-segment">
<div
v-for="chunk in chunksForSegment(segment)"
:key="chunk.key"
class="md-html-chunk"
v-html="chunk.html"
></div>
</div>
<CodeBlock
v-else
:content="segment.content"
@ -22,8 +28,10 @@ import CodeBlock from './CodeBlock.vue';
import {
parseMarkdownSegments,
renderMarkdownText,
renderMathBlocks
renderMathBlocks,
type MarkdownSegment
} from '@/composables/useMarkdownRenderer';
import { chunkRenderedHtml, type HtmlChunk } from '@/utils/htmlChunks';
defineOptions({ name: 'MarkdownRenderer' });
@ -42,6 +50,27 @@ function renderText(text: string) {
return renderMarkdownText(text, props.isStreaming);
}
// segments token key
// HTML /
const segmentChunkCache = new Map<string, { content: string; chunks: HtmlChunk[] }>();
const SEGMENT_CHUNK_CACHE_LIMIT = 40;
function chunksForSegment(segment: MarkdownSegment): HtmlChunk[] {
const cached = segmentChunkCache.get(segment.key);
if (cached && cached.content === segment.content) {
return cached.chunks;
}
const chunks = chunkRenderedHtml(renderText(segment.content));
segmentChunkCache.set(segment.key, { content: segment.content, chunks });
if (segmentChunkCache.size > SEGMENT_CHUNK_CACHE_LIMIT) {
const firstKey = segmentChunkCache.keys().next().value;
if (firstKey !== undefined) {
segmentChunkCache.delete(firstKey);
}
}
return chunks;
}
function renderMath() {
nextTick(() => {
if (containerRef.value) {
@ -65,6 +94,12 @@ watch(() => props.content, renderMath, { immediate: true });
display: block;
}
/* v-html
保证分块不改变原有排版内外边距/相邻选择器表现与单 v-html 一致 */
.md-html-chunk {
display: contents;
}
.markdown-text-segment > *:first-child {
margin-top: 0;
}

View File

@ -292,11 +292,17 @@ function renderToolResult(): string {
.tool-result-image img {
max-width: 100%;
/* 限高防止长截图占满对话区;点击可经外层链接打开原图 */
max-height: 480px;
height: auto;
border-radius: 6px;
border: 1px solid var(--border-default);
}
.tool-result-image a {
cursor: zoom-in;
}
/* 代码块 */
.code-block {
margin-top: 12px;

View File

@ -726,7 +726,12 @@ function renderViewImage(result: any, args: any): string {
const path = args.image_path || args.path || result.path || '';
const status = formatToolStatusLabel(result, '✓ 成功');
const size = result.size || result.file_size || 0;
const imageUrl = result.image_url || result.url || '';
// 优先用结果自带 URL否则按 path 走文件内容接口加载原图
// host 模式支持绝对路径docker/web 模式限工作区相对路径,与工具本身的路径约束一致)
let imageUrl = result.image_url || result.url || '';
if (!imageUrl && path && result.success !== false) {
imageUrl = `/api/file/content?path=${encodeURIComponent(path)}`;
}
let html = '<div class="tool-result-meta">';
html += `<div><strong>路径:</strong>${escapeHtml(path)}</div>`;
@ -737,7 +742,8 @@ function renderViewImage(result: any, args: any): string {
html += '</div>';
if (imageUrl) {
html += `<div class="tool-result-image"><img src="${escapeHtml(imageUrl)}" alt="查看图片" style="max-width: 100%; height: auto;" /></div>`;
const safeUrl = escapeHtml(imageUrl);
html += `<div class="tool-result-image"><a href="${safeUrl}" target="_blank" rel="noopener" title="在新窗口查看原图"><img src="${safeUrl}" alt="查看图片" loading="lazy" /></a></div>`;
}
return html;

View File

@ -72,6 +72,13 @@ interface UiState {
activeMobileOverlay: MobileOverlayTarget;
}
// 首帧即判定移动端视口:初始值不能等 mounted 里的 matchMedia 监听,
// 否则移动端在对话加载完成前会被当作桌面端QuickDock 等桌面 UI 会短暂渲染挤压页面。
const initialIsMobileViewport =
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia('(max-width: 768px)').matches
: false;
export const useUiStore = defineStore('ui', {
state: (): UiState => ({
sidebarCollapsed: true,
@ -98,7 +105,7 @@ export const useUiStore = defineStore('ui', {
destroying: false,
destroyPromise: null
},
isMobileViewport: false,
isMobileViewport: initialIsMobileViewport,
mobileOverlayMenuOpen: false,
activeMobileOverlay: null
}),

View File

@ -0,0 +1,51 @@
/**
* HTML
*
* MarkdownRenderer token HTML
* v-html innerHTML DOM
* - show_file Vue app fetch
* - //KaTeX /
*
* HTML v-for key v-html
* Markdown /
* HTML Vue v-html
* DOM appKaTeX
*
* DOM JS DOM
* data-renderedKaTeX
*/
export interface HtmlChunk {
key: string;
html: string;
}
/**
* HTML key append-only
* token
*/
export function chunkRenderedHtml(html: string): HtmlChunk[] {
if (!html) return [];
if (typeof document === 'undefined') {
return [{ key: 'c0', html }];
}
const template = document.createElement('template');
template.innerHTML = html;
const chunks: HtmlChunk[] = [];
for (const node of Array.from(template.content.childNodes)) {
if (node.nodeType === Node.TEXT_NODE) {
// 块间纯空白节点丢弃markdown 输出的块间换行符),避免无意义分块
const text = node.textContent || '';
if (!text.trim()) continue;
// textContent 是解码后的文本,回写 v-html 前必须重新转义
chunks.push({
key: `c${chunks.length}`,
html: text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
});
continue;
}
if (node.nodeType !== Node.ELEMENT_NODE) continue;
chunks.push({ key: `c${chunks.length}`, html: (node as Element).outerHTML });
}
return chunks;
}