diff --git a/static/src/composables/useMarkdownRenderer.ts b/static/src/composables/useMarkdownRenderer.ts
index b38d1bf..055832a 100644
--- a/static/src/composables/useMarkdownRenderer.ts
+++ b/static/src/composables/useMarkdownRenderer.ts
@@ -89,6 +89,34 @@ function extractShowHtmlRatio(tagSource: string) {
return '1:1';
}
+function isShowHtmlTagInsideMarkdownCode(raw: string, tagIndex: number) {
+ if (!raw || tagIndex <= 0) return false;
+ let i = 0;
+ let inFence = false;
+ let inInlineCode = false;
+
+ while (i < tagIndex) {
+ // fenced code block: ```
+ if (!inInlineCode && raw.startsWith('```', i)) {
+ inFence = !inFence;
+ i += 3;
+ continue;
+ }
+ if (!inFence && raw[i] === '`') {
+ // 处理转义反引号 \`
+ const escaped = i > 0 && raw[i - 1] === '\\';
+ if (!escaped) {
+ inInlineCode = !inInlineCode;
+ }
+ i += 1;
+ continue;
+ }
+ i += 1;
+ }
+
+ return inFence || inInlineCode;
+}
+
function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
if (!raw || raw.indexOf(' 仅作为文本展示,不参与渲染替换
+ output += raw.slice(cursor, start + 1);
+ cursor = start + 1;
+ continue;
+ }
blockCount += 1;
output += raw.slice(cursor, start);
diff --git a/static/src/composables/useScrollControl.ts b/static/src/composables/useScrollControl.ts
index bb8c702..15f30cc 100644
--- a/static/src/composables/useScrollControl.ts
+++ b/static/src/composables/useScrollControl.ts
@@ -40,6 +40,19 @@ let lastKnownMessagesArea: HTMLElement | null = null;
let scrollDebugCount = 0;
const SCROLL_DEBUG_MAX = 1200;
const scrollDebugLastTsByKey = new Map();
+let showHtmlTraceCount = 0;
+const SHOW_HTML_TRACE_MAX = 160;
+const showHtmlTraceLastTsByKey = new Map();
+
+function isShowTagDrawingActive() {
+ if (typeof window === 'undefined') return false;
+ try {
+ const until = Number((window as any).__SHOW_TAG_DRAWING_UNTIL__ || 0);
+ return Date.now() <= until;
+ } catch {
+ return false;
+ }
+}
function isScrollDebugEnabled() {
if (typeof window === 'undefined') return false;
@@ -63,7 +76,76 @@ function isScrollDebugEnabled() {
}
}
-function scrollDebugLog(event: string, payload: Record = {}, key = event, throttleMs = 120) {
+function isShowHtmlScrollTraceEnabled() {
+ if (typeof window === 'undefined') return true;
+ try {
+ const explicit = (window as any).__SHOW_HTML_SCROLL_TRACE__;
+ if (explicit === false || explicit === '0') return false;
+ if (explicit === true || explicit === '1') return true;
+ const localFlag = window.localStorage?.getItem('showHtmlScrollTrace');
+ if (localFlag === '0' || localFlag === 'false') return false;
+ if (localFlag === '1' || localFlag === 'true') return true;
+ return true;
+ } catch {
+ return true;
+ }
+}
+
+function getMessagesAreaMetrics(messagesArea: HTMLElement) {
+ const top = messagesArea.scrollTop;
+ const height = messagesArea.scrollHeight;
+ const client = messagesArea.clientHeight;
+ const maxTop = Math.max(0, height - client);
+ const remain = height - top - client;
+ return { top, height, client, maxTop, remain };
+}
+
+function getLastShowHtmlMetrics(messagesArea: HTMLElement) {
+ const list = messagesArea.querySelectorAll('.chat-inline-html');
+ const last = (list[list.length - 1] as HTMLElement) || null;
+ if (!last) return null;
+ const areaRect = messagesArea.getBoundingClientRect();
+ const rect = last.getBoundingClientRect();
+ return {
+ count: list.length,
+ y: Math.round(rect.y),
+ h: Math.round(rect.height),
+ bottomInArea: Math.round(rect.bottom - areaRect.top),
+ areaClient: Math.round(messagesArea.clientHeight),
+ belowViewportPx: Math.round(Math.max(0, rect.bottom - areaRect.bottom))
+ };
+}
+
+function hasShowHtmlNodeInArea(messagesArea: HTMLElement) {
+ return !!messagesArea.querySelector('show_html, .chat-inline-html');
+}
+
+function showHtmlTraceLog(
+ event: string,
+ payload: Record = {},
+ key = event,
+ throttleMs = 280
+) {
+ if (!isShowHtmlScrollTraceEnabled()) return;
+ if (showHtmlTraceCount >= SHOW_HTML_TRACE_MAX) return;
+ const now = Date.now();
+ const last = showHtmlTraceLastTsByKey.get(key) || 0;
+ if (throttleMs > 0 && now - last < throttleMs) return;
+ showHtmlTraceLastTsByKey.set(key, now);
+ showHtmlTraceCount += 1;
+ if (showHtmlTraceCount === SHOW_HTML_TRACE_MAX) {
+ console.warn('[SHOW_HTML_SCROLL_TRACE]', 'log-limit-reached', { max: SHOW_HTML_TRACE_MAX });
+ return;
+ }
+ console.log('[SHOW_HTML_SCROLL_TRACE]', event, payload);
+}
+
+function scrollDebugLog(
+ event: string,
+ payload: Record = {},
+ key = event,
+ throttleMs = 120
+) {
if (!isScrollDebugEnabled()) return;
if (scrollDebugCount >= SCROLL_DEBUG_MAX) return;
const now = Date.now();
@@ -84,14 +166,16 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
scrollDebugLog('scrollToBottom:skip:no-area');
return;
}
+ const drawingActive = isShowTagDrawingActive() && hasShowHtmlNodeInArea(messagesArea);
const hasOverflow = messagesArea.scrollHeight > messagesArea.clientHeight + 2;
- if (!hasOverflow && !options?.force) {
+ if (!hasOverflow && !options?.force && !drawingActive) {
scrollDebugLog(
'scrollToBottom:skip:no-overflow',
{
top: messagesArea.scrollTop,
height: messagesArea.scrollHeight,
- client: messagesArea.clientHeight
+ client: messagesArea.clientHeight,
+ drawingActive
},
'scrollToBottom:skip:no-overflow',
120
@@ -105,20 +189,41 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
return;
}
const traceId = `t${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
+ const beforeMetrics = getMessagesAreaMetrics(messagesArea);
+ if (drawingActive || options?.force) {
+ showHtmlTraceLog(
+ 'scrollToBottom:start',
+ {
+ traceId,
+ drawingActive,
+ force: !!options?.force,
+ ...beforeMetrics,
+ lastShowHtml: getLastShowHtmlMetrics(messagesArea)
+ },
+ 'scrollToBottom:start',
+ 120
+ );
+ }
const useSmooth =
options?.behavior === 'smooth' && typeof (messagesArea as HTMLElement).scrollTo === 'function';
- scrollDebugLog('scrollToBottom:start', {
- traceId,
- useSmooth,
- ignoreUserScrolling: !!options?.ignoreUserScrolling,
- resetUserScrolling: !!options?.resetUserScrolling,
- autoScrollEnabled: ctx.autoScrollEnabled,
- userScrolling: ctx.userScrolling,
- top: messagesArea.scrollTop,
- height: messagesArea.scrollHeight,
- client: messagesArea.clientHeight
- }, `scrollToBottom:start:${traceId}`, 0);
+ scrollDebugLog(
+ 'scrollToBottom:start',
+ {
+ traceId,
+ useSmooth,
+ ignoreUserScrolling: !!options?.ignoreUserScrolling,
+ resetUserScrolling: !!options?.resetUserScrolling,
+ autoScrollEnabled: ctx.autoScrollEnabled,
+ userScrolling: ctx.userScrolling,
+ drawingActive,
+ top: messagesArea.scrollTop,
+ height: messagesArea.scrollHeight,
+ client: messagesArea.clientHeight
+ },
+ `scrollToBottom:start:${traceId}`,
+ 0
+ );
let task = pendingScrollTasks.get(messagesArea);
if (!task) {
@@ -136,7 +241,12 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
const perform = () => {
if (ctx.userScrolling && !options?.ignoreUserScrolling && !options?.force) {
- scrollDebugLog('scrollToBottom:skip:user-scrolling', { traceId, userScrolling: ctx.userScrolling }, 'scrollToBottom:skip:user-scrolling', 0);
+ scrollDebugLog(
+ 'scrollToBottom:skip:user-scrolling',
+ { traceId, userScrolling: ctx.userScrolling },
+ 'scrollToBottom:skip:user-scrolling',
+ 0
+ );
if (typeof ctx._setScrollingFlag === 'function') {
ctx._setScrollingFlag(false);
}
@@ -145,35 +255,78 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
if (useSmooth) {
(messagesArea as HTMLElement).scrollTo({
- top: messagesArea.scrollHeight,
+ top:
+ messagesArea.scrollHeight +
+ (drawingActive ? Math.max(24, Math.floor(messagesArea.clientHeight * 0.12)) : 0),
behavior: 'smooth'
});
} else {
- messagesArea.scrollTop = messagesArea.scrollHeight;
+ messagesArea.scrollTop =
+ messagesArea.scrollHeight +
+ (drawingActive ? Math.max(24, Math.floor(messagesArea.clientHeight * 0.12)) : 0);
+ }
+ if (drawingActive || options?.force) {
+ showHtmlTraceLog(
+ 'scrollToBottom:after-write',
+ {
+ traceId,
+ drawingActive,
+ ...getMessagesAreaMetrics(messagesArea),
+ lastShowHtml: getLastShowHtmlMetrics(messagesArea)
+ },
+ 'scrollToBottom:after-write',
+ 120
+ );
}
let settleCount = 0;
let lastHeight = -1;
const settleScroll = () => {
settleCount += 1;
- messagesArea.scrollTop = messagesArea.scrollHeight;
+ messagesArea.scrollTop =
+ messagesArea.scrollHeight +
+ (drawingActive ? Math.max(24, Math.floor(messagesArea.clientHeight * 0.12)) : 0);
const currentHeight = messagesArea.scrollHeight;
const remain = currentHeight - messagesArea.scrollTop - messagesArea.clientHeight;
const heightStable = Math.abs(currentHeight - lastHeight) <= 2;
const atBottom = Math.abs(remain) <= 2;
- scrollDebugLog('scrollToBottom:settle', {
- traceId,
- settleCount,
- currentHeight,
- lastHeight,
- remain,
- atBottom,
- heightStable,
- top: messagesArea.scrollTop,
- client: messagesArea.clientHeight
- }, 'scrollToBottom:settle', 80);
+ scrollDebugLog(
+ 'scrollToBottom:settle',
+ {
+ traceId,
+ settleCount,
+ currentHeight,
+ lastHeight,
+ remain,
+ atBottom,
+ heightStable,
+ top: messagesArea.scrollTop,
+ client: messagesArea.clientHeight
+ },
+ 'scrollToBottom:settle',
+ 80
+ );
lastHeight = currentHeight;
if (settleCount < 7 && (!heightStable || !atBottom)) {
+ if (
+ (drawingActive || options?.force) &&
+ (settleCount === 1 || settleCount === 4 || settleCount === 6)
+ ) {
+ showHtmlTraceLog(
+ 'scrollToBottom:settle-progress',
+ {
+ traceId,
+ settleCount,
+ drawingActive,
+ currentHeight,
+ top: messagesArea.scrollTop,
+ client: messagesArea.clientHeight,
+ remain
+ },
+ 'scrollToBottom:settle-progress',
+ 140
+ );
+ }
task!.settleTimer = setTimeout(settleScroll, 80);
return;
}
@@ -188,13 +341,32 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
}
}
task!.settleTimer = null;
- scrollDebugLog('scrollToBottom:done', {
- traceId,
- settleCount,
- top: messagesArea.scrollTop,
- height: messagesArea.scrollHeight,
- client: messagesArea.clientHeight
- }, `scrollToBottom:done:${traceId}`, 0);
+ if (drawingActive || options?.force || Math.abs(remain) > 2) {
+ showHtmlTraceLog(
+ 'scrollToBottom:done',
+ {
+ traceId,
+ settleCount,
+ drawingActive,
+ ...getMessagesAreaMetrics(messagesArea),
+ lastShowHtml: getLastShowHtmlMetrics(messagesArea)
+ },
+ 'scrollToBottom:done',
+ 120
+ );
+ }
+ scrollDebugLog(
+ 'scrollToBottom:done',
+ {
+ traceId,
+ settleCount,
+ top: messagesArea.scrollTop,
+ height: messagesArea.scrollHeight,
+ client: messagesArea.clientHeight
+ },
+ `scrollToBottom:done:${traceId}`,
+ 0
+ );
};
task!.settleTimer = setTimeout(settleScroll, 120);
};
@@ -215,13 +387,32 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
export function conditionalScrollToBottom(ctx: ScrollContext) {
const active = typeof ctx.isOutputActive === 'function' ? ctx.isOutputActive() : true;
- scrollDebugLog('conditional:start', {
- active,
- autoScrollEnabled: ctx.autoScrollEnabled,
- userScrolling: ctx.userScrolling
- }, 'conditional:start', 120);
+ const messagesArea = ctx.getMessagesAreaElement?.();
+ const drawingActive =
+ !!messagesArea && isShowTagDrawingActive() && hasShowHtmlNodeInArea(messagesArea);
+ if (!drawingActive && isShowTagDrawingActive() && messagesArea) {
+ showHtmlTraceLog(
+ 'conditional:skip-stale-drawing-window',
+ {
+ ...getMessagesAreaMetrics(messagesArea),
+ lastShowHtml: getLastShowHtmlMetrics(messagesArea)
+ },
+ 'conditional:skip-stale-drawing-window',
+ 260
+ );
+ }
+ scrollDebugLog(
+ 'conditional:start',
+ {
+ active,
+ autoScrollEnabled: ctx.autoScrollEnabled,
+ userScrolling: ctx.userScrolling,
+ drawingActive
+ },
+ 'conditional:start',
+ 120
+ );
if (ctx.autoScrollEnabled === true && active) {
- const messagesArea = ctx.getMessagesAreaElement?.();
if (!messagesArea) {
scrollDebugLog('conditional:skip:no-area');
return;
@@ -244,7 +435,7 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
const client = messagesArea.clientHeight;
const remain = height - top - client;
// 高度尚未溢出时,跳过跟底,避免“0高度/刚重建”阶段反复触发滚动链路
- if (height <= client + 2) {
+ if (height <= client + 2 && !drawingActive) {
ctx.chatSetScrollState?.({ userScrolling: false });
scrollDebugLog(
'conditional:skip:no-overflow',
@@ -254,6 +445,14 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
);
return;
}
+ if (height <= client + 2 && drawingActive) {
+ showHtmlTraceLog(
+ 'conditional:drawing-no-overflow-but-continue',
+ { top, height, client, remain, drawingActive },
+ 'conditional:drawing-no-overflow-but-continue',
+ 200
+ );
+ }
const pending = pendingScrollTasks.get(messagesArea);
if (pending?.rafId !== null || !!pending?.settleTimer) {
scrollDebugLog(
@@ -287,12 +486,18 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
autoScrollRafId = null;
const latestArea = ctx.getMessagesAreaElement?.();
if (!latestArea || latestArea !== messagesArea) {
- scrollDebugLog('conditional:skip:area-changed-before-raf', {}, 'conditional:skip:area-changed-before-raf', 0);
+ scrollDebugLog(
+ 'conditional:skip:area-changed-before-raf',
+ {},
+ 'conditional:skip:area-changed-before-raf',
+ 0
+ );
return;
}
const latestHeight = latestArea.scrollHeight;
const latestClient = latestArea.clientHeight;
- if (latestHeight <= latestClient + 2) {
+ const latestDrawingActive = isShowTagDrawingActive() && hasShowHtmlNodeInArea(latestArea);
+ if (latestHeight <= latestClient + 2 && !latestDrawingActive) {
scrollDebugLog(
'conditional:skip:no-overflow-before-raf',
{ top: latestArea.scrollTop, height: latestHeight, client: latestClient },
@@ -301,14 +506,34 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
);
return;
}
- scrollDebugLog('conditional:raf-scroll', {
- top: latestArea.scrollTop,
- height: latestHeight,
- client: latestClient
- }, 'conditional:raf-scroll', 80);
+ if (latestDrawingActive) {
+ showHtmlTraceLog(
+ 'conditional:raf-scroll',
+ {
+ top: latestArea.scrollTop,
+ height: latestHeight,
+ client: latestClient,
+ remain: latestHeight - latestArea.scrollTop - latestClient,
+ lastShowHtml: getLastShowHtmlMetrics(latestArea)
+ },
+ 'conditional:raf-scroll',
+ 140
+ );
+ }
+ scrollDebugLog(
+ 'conditional:raf-scroll',
+ {
+ top: latestArea.scrollTop,
+ height: latestHeight,
+ client: latestClient
+ },
+ 'conditional:raf-scroll',
+ 80
+ );
scrollToBottom(ctx, {
ignoreUserScrolling: true,
- resetUserScrolling: true
+ resetUserScrolling: true,
+ force: latestDrawingActive
});
});
}
@@ -316,11 +541,16 @@ export function conditionalScrollToBottom(ctx: ScrollContext) {
export function toggleScrollLock(ctx: ScrollContext) {
const active = typeof ctx.isOutputActive === 'function' ? ctx.isOutputActive() : true;
- scrollDebugLog('toggle:start', {
- active,
- autoScrollEnabled: ctx.autoScrollEnabled,
- userScrolling: ctx.userScrolling
- }, 'toggle:start', 0);
+ scrollDebugLog(
+ 'toggle:start',
+ {
+ active,
+ autoScrollEnabled: ctx.autoScrollEnabled,
+ userScrolling: ctx.userScrolling
+ },
+ 'toggle:start',
+ 0
+ );
// 没有模型输出时:允许点击,但不切换锁定,仅单次滚动到底部
if (!active) {
@@ -341,11 +571,16 @@ export function toggleScrollLock(ctx: ScrollContext) {
force: true
});
}
- scrollDebugLog('toggle:end', {
- nextState,
- autoScrollEnabled: ctx.autoScrollEnabled,
- userScrolling: ctx.userScrolling
- }, 'toggle:end', 0);
+ scrollDebugLog(
+ 'toggle:end',
+ {
+ nextState,
+ autoScrollEnabled: ctx.autoScrollEnabled,
+ userScrolling: ctx.userScrolling
+ },
+ 'toggle:end',
+ 0
+ );
return nextState;
}
diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss
index 2243fb6..6f733f6 100644
--- a/static/src/styles/components/chat/_chat-area.scss
+++ b/static/src/styles/components/chat/_chat-area.scss
@@ -72,6 +72,7 @@
.messages-area {
flex: 1;
overflow-y: auto;
+ overflow-anchor: none;
padding: 24px;
padding-top: 30px;
padding-bottom: calc(20px + var(--app-bottom-inset, 0px));
@@ -79,6 +80,10 @@
position: relative;
}
+.messages-area.messages-area--stick {
+ overflow-anchor: none;
+}
+
.messages-flow {
width: min(960px, 100%);
margin: 0 auto;
@@ -122,6 +127,17 @@
justify-content: flex-end;
}
+.scroll-to-bottom-fade-enter-active,
+.scroll-to-bottom-fade-leave-active {
+ transition: opacity 0.2s ease, transform 0.2s ease;
+}
+
+.scroll-to-bottom-fade-enter-from,
+.scroll-to-bottom-fade-leave-to {
+ opacity: 0;
+ transform: translateY(8px);
+}
+
.chat-container--immersive .messages-flow {
width: min(900px, 100%);
}
@@ -933,6 +949,27 @@ body[data-theme='dark'] .more-icon {
background: transparent;
}
+/* show_html 在 streaming 重建期间会短暂回到原始标签,需提供稳定占位避免 scrollHeight 抖动 */
+show_html:not([data-rendered="1"]) {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ min-height: 220px;
+ margin: 14px auto;
+ box-sizing: border-box;
+ overflow: hidden;
+ border: 1px solid var(--claude-border);
+ border-radius: 12px;
+ background: transparent;
+ box-shadow: var(--claude-shadow);
+}
+
+show_html:not([data-rendered="1"])[ratio="1:1"] { aspect-ratio: 1 / 1; }
+show_html:not([data-rendered="1"])[ratio="16:9"] { aspect-ratio: 16 / 9; }
+show_html:not([data-rendered="1"])[ratio="9:16"] { aspect-ratio: 9 / 16; }
+show_html:not([data-rendered="1"])[ratio="4:3"] { aspect-ratio: 4 / 3; }
+show_html:not([data-rendered="1"])[ratio="3:4"] { aspect-ratio: 3 / 4; }
+
.text-output .text-content {
padding: 0 20px 0 15px;
}