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:
parent
d2e4422c80
commit
d2dd097248
@ -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:
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// ===== 悬浮预览卡 =====
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}),
|
||||
|
||||
51
static/src/utils/htmlChunks.ts
Normal file
51
static/src/utils/htmlChunks.ts
Normal 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——挂载的卡片 app、表格滚动位置、KaTeX 渲染结果自然存活。
|
||||
*
|
||||
* 注意:比较的是「两次渲染产出的字符串」,而不是 DOM 本身,因此后续 JS 对 DOM 的
|
||||
* 增强(data-rendered、KaTeX 填充、事件绑定)不会干扰分块稳定性。
|
||||
*/
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
chunks.push({ key: `c${chunks.length}`, html: (node as Element).outerHTML });
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user