fix: stabilize show_html rendering and restore conversation mode state

This commit is contained in:
JOJO 2026-04-26 01:04:23 +08:00
parent 33d270ff63
commit 383e68188c
11 changed files with 163 additions and 77 deletions

View File

@ -229,6 +229,9 @@ class WebTerminal(MainTerminal):
"conversation_id": conversation_id,
"title": conversation_data.get("title", "未知对话"),
"messages_count": len(self.context_manager.conversation_history),
"run_mode": self.run_mode,
"thinking_mode": self.thinking_mode,
"model_key": getattr(self, "model_key", None),
"message": f"对话已加载: {conversation_id}"
}
else:

View File

@ -53,9 +53,18 @@
如需在界面直接渲染 HTML 预览,使用 `<show_html ratio="比例">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`
- 各比例对应的前端基准渲染尺寸(宽 x 高)如下,请按这些尺寸去设计元素大小与间距:
- `1:1` → `620x620`
- `16:9` → `620x349`
- `9:16` → `620x1102`
- `4:3` → `620x465`
- `3:4` → `620x827`
- 说明:以上为基准像素,最终仍会按窗口与容器可用空间自适应裁剪
- **禁止**输出 `width` / `height` 属性,最终像素尺寸由前端根据窗口自适应
- 实际展示给用户的渲染后内容长宽都很小,可能只有几百 px需主动使用合适的间距调整、合适的元素大小和距离如 `padding`/`gap`/`line-height`)保证内容可读、不过度留白
- 标签内容只写 **HTML + CSS**,不要写 JavaScript
- 禁止输出 `<script>`、`onclick` 等事件脚本;前端会禁用脚本执行
- 当用户说“给我画一个xxx”这类可视化诉求时优先使用 `<show_html>` 标签直接实现,而不是先写一个 html 文件(除非用户明确要求落盘)
简单示例(正确格式):
<show_html ratio="16:9">

View File

@ -53,9 +53,18 @@
如需在界面直接渲染 HTML 预览,使用 `<show_html ratio="比例">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`
- 各比例对应的前端基准渲染尺寸(宽 x 高)如下,请按这些尺寸去设计元素大小与间距:
- `1:1` → `620x620`
- `16:9` → `620x349`
- `9:16` → `620x1102`
- `4:3` → `620x465`
- `3:4` → `620x827`
- 说明:以上为基准像素,最终仍会按窗口与容器可用空间自适应裁剪
- **禁止**输出 `width` / `height` 属性,最终像素尺寸由前端根据窗口自适应
- 实际展示给用户的渲染后内容长宽都很小,可能只有几百 px需主动使用合适的间距调整、合适的元素大小和距离如 `padding`/`gap`/`line-height`)保证内容可读、不过度留白
- 标签内容只写 **HTML + CSS**,不要写 JavaScript
- 禁止输出 `<script>`、`onclick` 等事件脚本;前端会禁用脚本执行
- 当用户说“给我画一个xxx”这类可视化诉求时优先使用 `<show_html>` 标签直接实现,而不是先写一个 html 文件(除非用户明确要求落盘)
简单示例(正确格式):
<show_html ratio="16:9">

View File

@ -211,6 +211,7 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
if has_request_context():
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None)
if is_api_user:
session['workspace_id'] = getattr(workspace, "workspace_id", None)
else:
@ -424,6 +425,7 @@ def ensure_conversation_loaded(
if has_request_context():
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None)
except Exception:
pass
# 应用对话级自定义 prompt / personalization仅 API

View File

@ -356,6 +356,9 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor
result = terminal.load_conversation(conversation_id)
if result["success"]:
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None)
normalized_id = _normalize_conv_id(conversation_id)
if _is_host_mode_request(username):
try:

View File

@ -59,7 +59,9 @@ class TaskRecord:
self.updated_at = self.created_at
self.message = message
self.conversation_id = conversation_id
self.events: deque[Dict[str, Any]] = deque(maxlen=1000)
# 刷新恢复时前端会从事件流重建进行中的输出1000 在长流式回复下会过早截断,
# 导致“只恢复最后几个字符”。这里提高缓冲上限,优先保证重建完整性。
self.events: deque[Dict[str, Any]] = deque(maxlen=20000)
self.thread: Optional[threading.Thread] = None
self.error: Optional[str] = None
self.model_key = model_key

View File

@ -166,6 +166,14 @@ const SHOW_HTML_RATIO_VALUES: Record<string, number> = {
'4:3': 4 / 3,
'3:4': 3 / 4
};
// show_html 统一按比例对应预设基准像素渲染(前端仍会按可用空间自适应)
const SHOW_HTML_RATIO_BASE_SIZE: Record<string, { width: number; height: number }> = {
'1:1': { width: 620, height: 620 },
'16:9': { width: 620, height: 349 },
'9:16': { width: 620, height: 1102 },
'4:3': { width: 620, height: 465 },
'3:4': { width: 620, height: 827 }
};
function normalizeShowHtmlRatio(raw: string | null) {
if (!raw) return '1:1';
@ -203,6 +211,7 @@ function readShowHtmlRatio(node: Element) {
function computeAdaptiveShowHtmlSize(node: Element, ratioKey: string) {
const ratio = SHOW_HTML_RATIO_VALUES[ratioKey] || 1;
const base = SHOW_HTML_RATIO_BASE_SIZE[ratioKey] || SHOW_HTML_RATIO_BASE_SIZE['1:1'];
const windowW = Math.max(320, window.innerWidth || document.documentElement.clientWidth || 320);
const windowH = Math.max(360, window.innerHeight || document.documentElement.clientHeight || 360);
const parentRect = node.parentElement?.getBoundingClientRect();
@ -216,28 +225,16 @@ function computeAdaptiveShowHtmlSize(node: Element, ratioKey: string) {
220,
Math.floor(Math.min(flowRect?.width || windowW, parentRect?.width || windowW) - 8)
);
const maxWidth = Math.max(220, Math.min(availableWidth, Math.floor(windowW * 0.88), 920));
const minWidth = Math.min(maxWidth, Math.max(220, Math.floor(windowW * 0.34)));
let widthPx = Math.max(minWidth, maxWidth);
// 移除“二次约束”后:窗口/容器足够大时,严格使用 prompt 约定的基准像素;
// 仅在物理空间不足时按比例等比缩小(不再做额外的最小/最大高度钳制)。
// 最大卡片约束:上限按 1200x900窗口不足时仍按可用空间缩放
const limitWidth = Math.max(220, Math.min(availableWidth, Math.floor(windowW * 0.96), 1200));
const limitHeight = Math.max(180, Math.min(Math.floor(windowH * 0.9), 900));
const widthScale = limitWidth / base.width;
const heightScale = limitHeight / base.height;
const scale = Math.min(1, widthScale, heightScale);
let widthPx = Math.max(220, Math.round(base.width * scale));
let heightPx = Math.round(widthPx / ratio);
const maxHeight = Math.max(200, Math.min(900, Math.floor(windowH * 0.68)));
const minHeight = Math.max(180, Math.min(280, Math.floor(windowH * 0.32)));
if (heightPx > maxHeight) {
heightPx = maxHeight;
widthPx = Math.round(heightPx * ratio);
}
if (heightPx < minHeight) {
heightPx = minHeight;
widthPx = Math.round(heightPx * ratio);
}
if (widthPx > maxWidth) {
widthPx = maxWidth;
heightPx = Math.round(widthPx / ratio);
}
if (widthPx < minWidth) {
widthPx = minWidth;
heightPx = Math.round(widthPx / ratio);
}
return { widthPx, heightPx, ratio };
}
@ -260,11 +257,16 @@ function sanitizeInlineCss(css: string) {
function rewriteCssSelectorsForShadowDom(css: string) {
if (!css) return '';
// 注意:不能直接把 html/body 连续替换成 .show-html-root
// 否则后续 /\bhtml\b/ 会再次命中 ".show-html-root" 里的 "html",产生
// 类似 ".show-.show-html-root-root" 的错误选择器,导致布局样式失效(元素偏移)。
const placeholder = '__SHOW_HTML_ROOT_SELECTOR__';
return css
.replace(/\bhtml\s*,\s*body\b/gi, '.show-html-root')
.replace(/\bbody\s*,\s*html\b/gi, '.show-html-root')
.replace(/\bhtml\b/gi, '.show-html-root')
.replace(/\bbody\b/gi, '.show-html-root');
.replace(/\bhtml\s*,\s*body\b/gi, placeholder)
.replace(/\bbody\s*,\s*html\b/gi, placeholder)
.replace(/\bhtml\b/gi, placeholder)
.replace(/\bbody\b/gi, placeholder)
.replace(new RegExp(placeholder, 'g'), '.show-html-root');
}
function sanitizeShowHtmlContent(rawHtml: string) {
@ -437,15 +439,51 @@ function renderShowImages(root: ParentNode | null = document) {
if (!node.isConnected) return;
const inStreaming = !!node.closest('.streaming-text');
const pathKey = buildNodePathKey(node);
const isPartial = node.getAttribute('data-partial') === '1';
const encoded = node.getAttribute('data-encoded') || '';
const computedRatioKey = readShowHtmlRatio(node);
let ratioKey = computedRatioKey;
let { widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey);
// 流式阶段:一旦 show_html 已经出现完整闭合(非 partial冻结该 path 的已完成快照;
// 后续即使继续输出其他普通文本,也复用该快照,避免已完成 show_html 反复刷新。
if (inStreaming && !isPartial && encoded) {
const frozen = showHtmlCompletedSnapshotByPath.get(pathKey);
if (!frozen || frozen.encoded !== encoded || frozen.ratioKey !== ratioKey) {
showHtmlCompletedSnapshotByPath.set(pathKey, {
encoded,
ratioKey
});
}
} else if (!inStreaming) {
showHtmlCompletedSnapshotByPath.delete(pathKey);
}
const frozenSnapshot = inStreaming ? showHtmlCompletedSnapshotByPath.get(pathKey) : null;
const effectiveEncoded = frozenSnapshot?.encoded || encoded;
if (frozenSnapshot?.ratioKey) {
ratioKey = frozenSnapshot.ratioKey;
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
}
if (inStreaming) {
const locked = showHtmlStreamingSizeLockByPath.get(pathKey);
if (locked) {
ratioKey = locked.ratioKey;
widthPx = locked.widthPx;
heightPx = locked.heightPx;
// 修复:流式初期若 ratio 解析不完整会先落到 1:1后续必须允许升级到真实比例
const shouldUpgradeRatio =
ratioKey !== locked.ratioKey && (locked.ratioKey === '1:1' || ratioKey !== '1:1');
if (shouldUpgradeRatio) {
ratioKey = computedRatioKey;
({ widthPx, heightPx } = computeAdaptiveShowHtmlSize(node, ratioKey));
showHtmlStreamingSizeLockByPath.set(pathKey, {
ratioKey,
widthPx,
heightPx
});
} else {
ratioKey = locked.ratioKey;
widthPx = locked.widthPx;
heightPx = locked.heightPx;
}
} else {
showHtmlStreamingSizeLockByPath.set(pathKey, {
ratioKey,
@ -474,7 +512,8 @@ function renderShowImages(root: ParentNode | null = document) {
ratioAttr: node.getAttribute('ratio') || '',
ratioKey,
partial: node.getAttribute('data-partial') || '',
encodedLength: (node.getAttribute('data-encoded') || '').length,
encodedLength: encoded.length,
effectiveEncodedLength: effectiveEncoded.length,
innerLength: (node.innerHTML || '').length,
reservedWidth: `${widthPx}px`,
reservedMinHeight: `${heightPx}px`
@ -511,13 +550,13 @@ function renderShowImages(root: ParentNode | null = document) {
showHtmlStreamingRenderTsByPath.delete(pathKey);
}
const encoded = node.getAttribute('data-encoded') || '';
const rawHtml = encoded ? decodeBase64Utf8(encoded) : node.innerHTML || '';
const rawHtml = effectiveEncoded ? decodeBase64Utf8(effectiveEncoded) : node.innerHTML || '';
const safeHtml = sanitizeShowHtmlContent(rawHtml);
debugShowHtmlLog('render:node-content', {
renderId,
pathKey,
encodedLength: encoded.length,
effectiveEncodedLength: effectiveEncoded.length,
rawLength: rawHtml.length,
safeLength: safeHtml.length,
ratioKey,
@ -539,7 +578,7 @@ function renderShowImages(root: ParentNode | null = document) {
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: hidden;
overflow: auto;
background: transparent;
color: inherit;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@ -548,13 +587,12 @@ function renderShowImages(root: ParentNode | null = document) {
}
*, *::before, *::after {
box-sizing: border-box;
max-width: 100%;
}
.show-html-root {
width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
overflow: auto;
background: transparent;
}
`;
@ -578,20 +616,20 @@ function renderShowImages(root: ParentNode | null = document) {
persistent.wrapper.style.width = `${widthPx}px`;
persistent.host.style.height = `${heightPx}px`;
if (
persistent.encoded !== encoded ||
persistent.encoded !== effectiveEncoded ||
persistent.widthPx !== widthPx ||
persistent.heightPx !== heightPx ||
persistent.ratioKey !== ratioKey
) {
persistent.root.innerHTML = safeHtml;
persistent.encoded = encoded;
persistent.encoded = effectiveEncoded;
persistent.widthPx = widthPx;
persistent.heightPx = heightPx;
persistent.ratioKey = ratioKey;
debugShowHtmlLog('render:node-persistent-update', {
renderId,
pathKey,
encodedLength: encoded.length,
encodedLength: effectiveEncoded.length,
ratioKey,
widthPx,
heightPx
@ -600,7 +638,7 @@ function renderShowImages(root: ParentNode | null = document) {
debugShowHtmlLog('render:node-persistent-reuse', {
renderId,
pathKey,
encodedLength: encoded.length
encodedLength: effectiveEncoded.length
});
}
@ -608,12 +646,12 @@ function renderShowImages(root: ParentNode | null = document) {
node.setAttribute('data-rendered', '1');
const rect = persistent.wrapper.getBoundingClientRect();
debugShowHtmlLog('render:node-done', {
renderId,
pathKey,
ratioKey,
widthPx,
heightPx,
rect: {
renderId,
pathKey,
ratioKey,
widthPx,
heightPx,
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y),
w: Math.round(rect.width),
@ -647,6 +685,13 @@ let showTagBindRetryTimer: number | null = null;
let showTagObservedContainer: Element | null = null;
const showHtmlStreamingRenderTsByPath = new Map<string, number>();
const SHOW_HTML_STREAMING_RENDER_INTERVAL_MS = 180;
const showHtmlCompletedSnapshotByPath = new Map<
string,
{
encoded: string,
ratioKey: string
}
>();
const showHtmlStreamingSizeLockByPath = new Map<
string,
{
@ -963,6 +1008,7 @@ export function teardownShowImageObserver() {
layoutDebugCount = 0;
layoutDebugLastTsByKey.clear();
showHtmlStreamingRenderTsByPath.clear();
showHtmlCompletedSnapshotByPath.clear();
showHtmlStreamingSizeLockByPath.clear();
showHtmlPersistentRenderByPath.clear();
showTagObservedContainer = null;

View File

@ -261,34 +261,7 @@ export const conversationMethods = {
}
}
// 应用个性化设置中的默认模型和思考模式
try {
const personalizationStore = usePersonalizationStore();
if (personalizationStore.loaded) {
const defaultRunMode = personalizationStore.form.default_run_mode;
const defaultModel = personalizationStore.form.default_model;
if (defaultRunMode) {
this.runMode = defaultRunMode;
debugLog('应用默认运行模式:', defaultRunMode);
}
if (defaultModel) {
this.currentModelKey = defaultModel;
debugLog('应用默认模型:', defaultModel);
}
// 根据默认运行模式设置思考模式
if (defaultRunMode === 'thinking') {
this.thinkingMode = true;
} else if (defaultRunMode === 'fast') {
this.thinkingMode = false;
}
}
} catch (error) {
console.warn('应用个性化默认设置失败:', error);
}
// 注意:加载已有对话时必须保留该对话自身的模型/模式,不能套用用户默认值。
// 有任务或后台子智能体运行时,提示用户确认切换
try {
@ -343,6 +316,19 @@ export const conversationMethods = {
if (result.success) {
debugLog('对话加载API成功:', result);
traceLog('loadConversation:api-success', { conversationId, title: result.title });
if (typeof result.run_mode === 'string') {
this.runMode = result.run_mode;
this.thinkingMode =
typeof result.thinking_mode === 'boolean'
? result.thinking_mode
: result.run_mode !== 'fast';
} else if (typeof result.thinking_mode === 'boolean') {
this.thinkingMode = result.thinking_mode;
this.runMode = result.thinking_mode ? 'thinking' : 'fast';
}
if (typeof result.model_key === 'string' && result.model_key) {
this.modelSet(result.model_key);
}
// 2. 更新当前对话信息
this.skipConversationHistoryReload = true;

View File

@ -1601,6 +1601,19 @@ export const uiMethods = {
const resp = await fetch(`/api/conversations/${convId}/load`, { method: 'PUT' });
const result = await resp.json();
if (result.success) {
if (typeof result.run_mode === 'string') {
this.runMode = result.run_mode;
this.thinkingMode =
typeof result.thinking_mode === 'boolean'
? result.thinking_mode
: result.run_mode !== 'fast';
} else if (typeof result.thinking_mode === 'boolean') {
this.thinkingMode = result.thinking_mode;
this.runMode = result.thinking_mode ? 'thinking' : 'fast';
}
if (typeof result.model_key === 'string' && result.model_key) {
this.modelSet(result.model_key);
}
this.currentConversationId = convId;
this.currentConversationTitle = result.title || '';
this.titleReady = true;
@ -1858,8 +1871,14 @@ export const uiMethods = {
});
}
// 应用个性化设置中的默认模型和思考模式
if (personalizationStore.loaded) {
// 仅在“无当前对话”的新会话入口应用默认模型/模式;
// 对于已存在对话(含刷新恢复/URL指定对话必须保留对话自身设置。
const statusConversationIdForDefaults = statusData?.conversation?.current_id;
const hasActiveConversation =
typeof statusConversationIdForDefaults === 'string' &&
statusConversationIdForDefaults.length > 0 &&
!statusConversationIdForDefaults.startsWith('temp_');
if (personalizationStore.loaded && !hasActiveConversation && !this.currentConversationId) {
const defaultRunMode = personalizationStore.form.default_run_mode;
const defaultModel = personalizationStore.form.default_model;

View File

@ -281,6 +281,9 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
let settleCount = 0;
let lastHeight = -1;
const settleStartTs = Date.now();
const settleMaxCount = drawingActive ? 30 : 12;
const settleMaxDurationMs = drawingActive ? 3200 : 1200;
const settleScroll = () => {
settleCount += 1;
messagesArea.scrollTop =
@ -290,11 +293,15 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
const remain = currentHeight - messagesArea.scrollTop - messagesArea.clientHeight;
const heightStable = Math.abs(currentHeight - lastHeight) <= 2;
const atBottom = Math.abs(remain) <= 2;
const settleElapsed = Date.now() - settleStartTs;
const shouldContinue =
!atBottom && settleCount < settleMaxCount && settleElapsed < settleMaxDurationMs;
scrollDebugLog(
'scrollToBottom:settle',
{
traceId,
settleCount,
settleElapsed,
currentHeight,
lastHeight,
remain,
@ -307,7 +314,7 @@ export function scrollToBottom(ctx: ScrollContext, options?: ScrollOptions) {
80
);
lastHeight = currentHeight;
if (settleCount < 7 && (!heightStable || !atBottom)) {
if (shouldContinue) {
if (
(drawingActive || options?.force) &&
(settleCount === 1 || settleCount === 4 || settleCount === 6)

View File

@ -937,7 +937,7 @@ body[data-theme='dark'] .more-icon {
margin: 14px auto;
border: 1px solid var(--claude-border);
border-radius: 12px;
overflow: hidden;
overflow: auto;
background: transparent;
box-shadow: var(--claude-shadow);
}