fix: unify show cards rendering and prompt constraints

This commit is contained in:
JOJO 2026-04-26 18:13:24 +08:00
parent 24c702c0af
commit 9199f5ca7c
9 changed files with 1830 additions and 114 deletions

1505
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,13 +13,22 @@
"dependencies": {
"@types/html2canvas": "^0.5.35",
"diff": "^8.0.3",
"dompurify": "^3.2.7",
"enquirer": "^2.4.1",
"html2canvas": "^1.4.1",
"katex": "^0.16.9",
"marked": "^11.1.0",
"pinia": "^3.0.4",
"prismjs": "^1.29.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"socket.io-client": "^4.7.5",
"unified": "^11.0.5",
"vue": "^3.4.15",
"vue-stick-to-bottom": "^1.0.0"
},

View File

@ -38,20 +38,20 @@
## 3. 具体功能细节
### 3.1 图片展示
### 3.1 卡片展示(图片卡片 / HTML 卡片)
`<show_image>` 与 `<show_html>` 是**文本输出标签**不是工具tool
`<show_image>` 与 `<show_html>` 统一称为“卡片标签”,是**文本输出标签**不是工具tool
**严禁**把它们当作工具调用;只允许在最终回复正文中按规定格式直接输出标签。
输出规则(必须遵守):
卡片输出规则(必须遵守):
- 标签本体不要放在代码块(```)里
- 标签本体前后不要再写“`<show_html>` / `<show_image>`”字样说明,避免误解析为嵌套或普通文本
- 一次展示优先只输出一个完整标签块(开标签 + 内容 + 闭标签),不要拆段输出
- 若还需要解释说明,把说明写在标签之外,且不要再次写标签字面量
如需在界面直接展示图片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。
如需在界面直接展示图片卡片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。
如需在界面直接渲染 HTML 预览,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
如需在界面直接渲染 HTML 卡片,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`
- `js` 必填:
- `js="off"`:默认模式(建议优先使用),仅 HTML + CSS支持流式实时渲染
@ -67,7 +67,11 @@
- 实际展示给用户的渲染后内容长宽都很小,可能只有几百 px需主动使用合适的间距调整、合适的元素大小和距离如 `padding`/`gap`/`line-height`)保证内容可读、不过度留白
- 当 `js="off"` 时:标签内容只写 **HTML + CSS**,不要写 JavaScript禁止输出 `<script>`、`onclick` 等事件脚本
- 当 `js="on"` 时:允许输出 JavaScript含 `<script>`),但应只包含与当前展示直接相关的最小脚本逻辑
- 当用户说“给我画一个xxx”这类可视化诉求时优先使用 `<show_html>` 标签直接实现,而不是先写一个 html 文件(除非用户明确要求落盘)
- **禁止写页面级样式**:不要在 HTML 卡片里写 `html` / `body` 选择器(例如 `html, body {{ ... }}`
- **禁止使用视口高度布局**:不要写 `height: 100vh`、`min-height: 100vh`、`100dvh` 等整页高度写法
- **禁止双滚动容器**:不要同时给多层容器设置 `overflow: auto/scroll`;默认只让卡片外层负责滚动
- 需要铺满卡片时,请只给内部根容器(如 `.container`)使用 `min-height: 100%`,不要用 `100vh`
- 当用户说“给我画一个xxx”这类可视化诉求时优先使用 HTML 卡片(`<show_html>`)直接实现,而不是先写一个 html 文件(除非用户明确要求落盘)
简单示例(正确格式):
<show_html ratio="16:9" js="off">
@ -75,7 +79,7 @@
<style>.card{{padding:16px;border:1px solid #ddd;border-radius:10px}}</style>
</show_html>
**图片检索流程**Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → `<show_image>` 展示
**图片检索流程**Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → `<show_image>` 作为图片卡片展示
### 3.2 文件操作

View File

@ -38,20 +38,20 @@
## 3. 具体功能细节
### 3.1 图片展示
### 3.1 卡片展示(图片卡片 / HTML 卡片)
`<show_image>` 与 `<show_html>` 是**文本输出标签**不是工具tool
`<show_image>` 与 `<show_html>` 统一称为“卡片标签”,是**文本输出标签**不是工具tool
**严禁**把它们当作工具调用;只允许在最终回复正文中按规定格式直接输出标签。
输出规则(必须遵守):
卡片输出规则(必须遵守):
- 标签本体不要放在代码块(```)里
- 标签本体前后不要再写“`<show_html>` / `<show_image>`”字样说明,避免误解析为嵌套或普通文本
- 一次展示优先只输出一个完整标签块(开标签 + 内容 + 闭标签),不要拆段输出
- 若还需要解释说明,把说明写在标签之外,且不要再次写标签字面量
如需在界面直接展示图片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。
如需在界面直接展示图片卡片,使用 `<show_image src="路径" alt="描述" />`,支持本地路径或网络链接。禁止使用 Markdown 图片语法。
如需在界面直接渲染 HTML 预览,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
如需在界面直接渲染 HTML 卡片,使用 `<show_html ratio="比例" js="off|on">...</show_html>`。
- `ratio` 只能从以下五种里选:`1:1`、`16:9`、`9:16`、`4:3`、`3:4`
- `js` 必填:
- `js="off"`:默认模式(建议优先使用),仅 HTML + CSS支持流式实时渲染
@ -67,7 +67,11 @@
- 实际展示给用户的渲染后内容长宽都很小,可能只有几百 px需主动使用合适的间距调整、合适的元素大小和距离如 `padding`/`gap`/`line-height`)保证内容可读、不过度留白
- 当 `js="off"` 时:标签内容只写 **HTML + CSS**,不要写 JavaScript禁止输出 `<script>`、`onclick` 等事件脚本
- 当 `js="on"` 时:允许输出 JavaScript含 `<script>`),但应只包含与当前展示直接相关的最小脚本逻辑
- 当用户说“给我画一个xxx”这类可视化诉求时优先使用 `<show_html>` 标签直接实现,而不是先写一个 html 文件(除非用户明确要求落盘)
- **禁止写页面级样式**:不要在 HTML 卡片里写 `html` / `body` 选择器(例如 `html, body {{ ... }}`
- **禁止使用视口高度布局**:不要写 `height: 100vh`、`min-height: 100vh`、`100dvh` 等整页高度写法
- **禁止双滚动容器**:不要同时给多层容器设置 `overflow: auto/scroll`;默认只让卡片外层负责滚动
- 需要铺满卡片时,请只给内部根容器(如 `.container`)使用 `min-height: 100%`,不要用 `100vh`
- 当用户说“给我画一个xxx”这类可视化诉求时优先使用 HTML 卡片(`<show_html>`)直接实现,而不是先写一个 html 文件(除非用户明确要求落盘)
简单示例(正确格式):
<show_html ratio="16:9" js="off">
@ -75,7 +79,7 @@
<style>.card{{padding:16px;border:1px solid #ddd;border-radius:10px}}</style>
</show_html>
**图片检索流程**Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → `<show_image>` 展示
**图片检索流程**Wikimedia Commons 优先 → `web_search` 全网搜索 → 校验匹配 → `<show_image>` 作为图片卡片展示
### 3.2 文件操作
@ -107,7 +111,7 @@
#### 实操建议(你要主动想到并执行)
- **裁切策略**:优先裁"目标本体 + 周边上下文"而不是只裁最小块;读文字时再裁更紧
- **输出路径**:建议输出到 `/workspace/cache/` 或项目内临时目录(如 `cache/`),文件名带序号(例如 `crop_01.png`
- **复核展示**:需要让用户/自己确认时,可用 `<show_image src="..." />` 展示裁切结果;或将裁切图作为本地文件再用 `view_image` 查看
- **复核展示**:需要让用户/自己确认时,可用 `<show_image src="..." />` 作为图片卡片展示裁切结果;或将裁切图作为本地文件再用 `view_image` 查看
- **多图对比**:同一部位若存在多张版本(原裁切/增强后),按顺序展示并说明"哪张更利于读字/看细节"
### 3.4 终端操作

View File

@ -1,5 +1,6 @@
// @ts-nocheck
import katex from 'katex';
import DOMPurify from 'dompurify';
let showImageRenderSeq = 0;
let showImageDebugLogCount = 0;
@ -104,7 +105,7 @@ function summarizeNodeForDebug(node: Node | null) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as Element;
const tag = el.tagName.toLowerCase();
if (tag === 'show_image') {
if (tag === 'show_image' || tag === 'show-image') {
return `SHOW_IMAGE(src=${el.getAttribute('src') || ''})`;
}
return `${tag.toUpperCase()}`;
@ -124,9 +125,9 @@ function collectShowImageOrder(root: ParentNode | null = document) {
} else if (current.nodeType === Node.ELEMENT_NODE) {
const el = current as Element;
const tag = el.tagName.toLowerCase();
if (tag === 'show_image') {
if (tag === 'show_image' || tag === 'show-image') {
tokens.push(`SHOW_IMAGE(${el.getAttribute('src') || ''})`);
} else if (tag === 'show_html') {
} else if (tag === 'show_html' || tag === 'show-html') {
tokens.push(`SHOW_HTML(ratio=${readShowHtmlRatio(el)},js=${readShowHtmlJsMode(el)})`);
}
}
@ -246,8 +247,8 @@ function computeAdaptiveShowHtmlSize(node: Element, ratioKey: string) {
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 widthPx = Math.max(220, Math.round(base.width * scale));
const heightPx = Math.round(widthPx / ratio);
return { widthPx, heightPx, ratio };
}
@ -283,20 +284,15 @@ function rewriteCssSelectorsForShadowDom(css: string) {
}
function sanitizeShowHtmlContent(rawHtml: string) {
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${rawHtml || ''}</body>`, 'text/html');
const purified = DOMPurify.sanitize(rawHtml || '', {
USE_PROFILES: { html: true, svg: true, svgFilters: true, mathMl: true },
FORBID_TAGS: ['script', 'noscript', 'iframe', 'object', 'embed', 'link'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: true
}) as string;
const blockedSelectors = [
'script',
'noscript',
'iframe',
'object',
'embed',
'link[rel="stylesheet"]'
];
blockedSelectors.forEach((selector) => {
doc.querySelectorAll(selector).forEach((el) => el.remove());
});
const parser = new DOMParser();
const doc = parser.parseFromString(`<body>${purified}</body>`, 'text/html');
const elements = Array.from(doc.body.querySelectorAll('*'));
elements.forEach((el) => {
@ -352,6 +348,22 @@ function buildShowHtmlIframeSrcdoc(rawHtml: string) {
width: 100%;
min-height: 100%;
background: transparent;
scrollbar-width: thin;
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
}
html::-webkit-scrollbar, body::-webkit-scrollbar {
width: 8px;
height: 8px;
}
html::-webkit-scrollbar-track, body::-webkit-scrollbar-track {
background: transparent;
}
html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb {
background: rgba(121, 109, 94, 0.48);
border-radius: 8px;
}
html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover {
background: rgba(121, 109, 94, 0.62);
}
</style>
</head>
@ -373,8 +385,12 @@ function renderShowImages(root: ParentNode | null = document) {
const debugMode = getShowImageDebugMode();
const verbose = debugMode === 'verbose';
const renderId = ++showImageRenderSeq;
const imageNodes = Array.from(root.querySelectorAll('show_image:not([data-rendered])'));
const htmlNodes = Array.from(root.querySelectorAll('show_html:not([data-rendered])'));
const imageNodes = Array.from(
root.querySelectorAll('show_image:not([data-rendered]), show-image:not([data-rendered])')
);
const htmlNodes = Array.from(
root.querySelectorAll('show_html:not([data-rendered]), show-html:not([data-rendered])')
);
if (htmlNodes.length > 0) {
markShowTagDrawingActive();
}
@ -444,16 +460,16 @@ function renderShowImages(root: ParentNode | null = document) {
const alt = node.getAttribute('alt') || '';
const safeAlt = escapeHtml(alt.trim());
const figure = document.createElement('figure');
figure.className = 'chat-inline-image';
figure.className = 'chat-inline-card chat-inline-card--image chat-inline-image';
const img = document.createElement('img');
img.loading = 'lazy';
img.src = mappedSrc;
img.alt = safeAlt;
img.onerror = () => {
figure.classList.add('chat-inline-image--error');
figure.classList.add('chat-inline-card--error', 'chat-inline-image--error');
const tip = document.createElement('div');
tip.className = 'chat-inline-image__error';
tip.className = 'chat-inline-card__error chat-inline-image__error';
tip.textContent = '图片加载失败';
figure.appendChild(tip);
};
@ -461,6 +477,7 @@ function renderShowImages(root: ParentNode | null = document) {
if (safeAlt) {
const caption = document.createElement('figcaption');
caption.className = 'chat-inline-card__caption';
caption.innerHTML = safeAlt;
figure.appendChild(caption);
}
@ -569,11 +586,12 @@ function renderShowImages(root: ParentNode | null = document) {
let pending = showHtmlJsPendingRenderByPath.get(pathKey);
if (!pending) {
const wrapper = document.createElement('div');
wrapper.className = 'chat-inline-html chat-inline-html--pending';
wrapper.className =
'chat-inline-card chat-inline-card--html chat-inline-card--pending chat-inline-html chat-inline-html--pending';
const host = document.createElement('div');
host.className = 'chat-inline-html__host chat-inline-html__host--pending';
host.className = 'chat-inline-card__body chat-inline-html__host chat-inline-html__host--pending';
const tip = document.createElement('div');
tip.className = 'chat-inline-html__pending-tip';
tip.className = 'chat-inline-card__pending-tip chat-inline-html__pending-tip';
tip.textContent = '正在渲染内容...';
host.appendChild(tip);
wrapper.appendChild(host);
@ -611,7 +629,8 @@ function renderShowImages(root: ParentNode | null = document) {
let persistent = showHtmlJsIframeRenderByPath.get(pathKey);
if (!persistent) {
const wrapper = document.createElement('div');
wrapper.className = 'chat-inline-html chat-inline-html--iframe';
wrapper.className =
'chat-inline-card chat-inline-card--html chat-inline-card--iframe chat-inline-html chat-inline-html--iframe';
const iframe = document.createElement('iframe');
iframe.className = 'chat-inline-html__iframe';
iframe.setAttribute('sandbox', 'allow-scripts');
@ -730,9 +749,9 @@ function renderShowImages(root: ParentNode | null = document) {
}
if (!persistent) {
const wrapper = document.createElement('div');
wrapper.className = 'chat-inline-html';
wrapper.className = 'chat-inline-card chat-inline-card--html chat-inline-html';
const host = document.createElement('div');
host.className = 'chat-inline-html__host';
host.className = 'chat-inline-card__body chat-inline-html__host';
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
@ -757,6 +776,22 @@ function renderShowImages(root: ParentNode | null = document) {
min-height: 100%;
overflow: auto;
background: transparent;
scrollbar-width: thin;
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
}
.show-html-root::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.show-html-root::-webkit-scrollbar-track {
background: transparent;
}
.show-html-root::-webkit-scrollbar-thumb {
background: rgba(121, 109, 94, 0.48);
border-radius: 8px;
}
.show-html-root::-webkit-scrollbar-thumb:hover {
background: rgba(121, 109, 94, 0.62);
}
`;
const root = document.createElement('div');
@ -1138,12 +1173,15 @@ export function setupShowImageObserver() {
const hasShowImageRelatedMutation = mutations.some((mutation) => {
const targetIsShowImage =
mutation.target instanceof Element &&
['show_image', 'show_html'].includes(mutation.target.tagName.toLowerCase());
['show_image', 'show_html', 'show-image', 'show-html'].includes(
mutation.target.tagName.toLowerCase()
);
const addedHasShowTag = Array.from(mutation.addedNodes).some(
(n) =>
n instanceof Element &&
(['show_image', 'show_html'].includes(n.tagName.toLowerCase()) ||
!!n.querySelector?.('show_image,show_html'))
(['show_image', 'show_html', 'show-image', 'show-html'].includes(
n.tagName.toLowerCase()
) || !!n.querySelector?.('show_image,show_html,show-image,show-html'))
);
return targetIsShowImage || addedHasShowTag;
});

View File

@ -764,7 +764,7 @@ function getScrollMetrics(el: HTMLElement) {
height,
client,
remain: height - top - client,
hasShowHtml: !!el.querySelector('show_html, .chat-inline-html')
hasShowHtml: !!el.querySelector('show_html, show-html, .chat-inline-html, .chat-inline-card--html')
};
}

View File

@ -1,6 +1,14 @@
import { marked } from 'marked';
import Prism from 'prismjs';
import renderMathInElement from 'katex/contrib/auto-render';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import remarkRehype from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
import { visit } from 'unist-util-visit';
let latexRenderTimer: number | null = null;
const markdownCache = new Map<string, string>();
@ -144,6 +152,7 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
let cursor = 0;
let blockCount = 0;
const closeTag = '</show_html>';
const closeTagForMarkdown = '</show-html>';
while (cursor < raw.length) {
const start = raw.indexOf('<show_html', cursor);
@ -178,14 +187,14 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
jsMode,
partialLength: partialOpen.length
});
output += `<show_html${ratioAttr}${jsAttr} data-partial="1">${closeTag}`;
output += `<show-html${ratioAttr}${jsAttr} data-partial="1">${closeTagForMarkdown}`;
break;
}
const openTag = raw.slice(start, openEnd + 1);
const ratio = extractShowHtmlRatio(openTag);
const jsMode = extractShowHtmlJsMode(openTag);
const canonicalOpenTag = `<show_html ratio="${ratio}" js="${jsMode}">`;
const canonicalOpenTag = `<show-html ratio="${ratio}" js="${jsMode}">`;
const closeStart = raw.indexOf(closeTag, openEnd + 1);
if (closeStart === -1) {
if (!isStreaming) {
@ -206,7 +215,7 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
/>$/,
encoded ? ` data-encoded="${encoded}" data-partial="1">` : ' data-partial="1">'
);
output += `${safeOpen}${closeTag}`;
output += `${safeOpen}${closeTagForMarkdown}`;
break;
}
@ -221,7 +230,7 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
encodedLength: encoded.length
});
const safeOpen = canonicalOpenTag.replace(/>$/, encoded ? ` data-encoded="${encoded}">` : '>');
output += `${safeOpen}${closeTag}`;
output += `${safeOpen}${closeTagForMarkdown}`;
cursor = closeStart + closeTag.length;
}
showHtmlDebugLog('md:transform:end', {
@ -233,6 +242,124 @@ function transformShowHtmlBlocks(raw: string, isStreaming: boolean) {
return output;
}
function transformShowImageBlocks(raw: string) {
if (!raw || raw.indexOf('show_image') === -1) return raw;
let output = '';
let cursor = 0;
const openToken = '<show_image';
const closeToken = '</show_image>';
while (cursor < raw.length) {
const openIdx = raw.indexOf(openToken, cursor);
const closeIdx = raw.indexOf(closeToken, cursor);
if (openIdx === -1 && closeIdx === -1) {
output += raw.slice(cursor);
break;
}
const hasOpen = openIdx !== -1;
const hasClose = closeIdx !== -1;
const useOpen = hasOpen && (!hasClose || openIdx < closeIdx);
const hitIdx = useOpen ? openIdx : closeIdx;
if (hitIdx < 0) {
output += raw.slice(cursor);
break;
}
if (isShowHtmlTagInsideMarkdownCode(raw, hitIdx)) {
output += raw.slice(cursor, hitIdx + 1);
cursor = hitIdx + 1;
continue;
}
output += raw.slice(cursor, hitIdx);
if (useOpen) {
output += '<show-image';
cursor = hitIdx + openToken.length;
continue;
}
output += '</show-image>';
cursor = hitIdx + closeToken.length;
}
return output;
}
type RehypeProps = Record<string, unknown>;
function readAttr(props: RehypeProps | undefined, name: string): string {
if (!props) return '';
const direct = props[name];
if (typeof direct === 'string') return direct;
const camel = name.replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
const camelVal = props[camel];
if (typeof camelVal === 'string') return camelVal;
return '';
}
function writeAttr(props: RehypeProps, name: string, value: string | null) {
const camel = name.replace(/-([a-z])/g, (_, ch: string) => ch.toUpperCase());
if (value == null || value === '') {
delete props[name];
delete props[camel];
return;
}
props[name] = value;
}
function normalizeShowTagsPlugin() {
return (tree: any) => {
visit(tree, 'element', (node: any) => {
if (!node || typeof node.tagName !== 'string') return;
if (node.tagName === 'show_html' || node.tagName === 'show-html') {
node.tagName = 'show_html';
const props = (node.properties || {}) as RehypeProps;
const ratio = normalizeShowHtmlRatio(readAttr(props, 'ratio'));
const jsMode = normalizeShowHtmlJsMode(readAttr(props, 'js'));
const encoded = readAttr(props, 'data-encoded');
const partial = readAttr(props, 'data-partial');
writeAttr(props, 'ratio', ratio);
writeAttr(props, 'js', jsMode);
writeAttr(props, 'data-encoded', encoded);
writeAttr(props, 'data-partial', partial === '1' ? '1' : null);
node.properties = props;
}
if (node.tagName === 'show_image' || node.tagName === 'show-image') {
node.tagName = 'show_image';
const props = (node.properties || {}) as RehypeProps;
const src = readAttr(props, 'src').trim();
const alt = readAttr(props, 'alt').trim();
const title = readAttr(props, 'title').trim();
writeAttr(props, 'src', src);
writeAttr(props, 'alt', alt || title || '');
if (!title) {
writeAttr(props, 'title', null);
}
node.properties = props;
}
});
};
}
const sanitizedSchema: Record<string, any> = {
...defaultSchema,
tagNames: [...(defaultSchema.tagNames || []), 'show_html', 'show_image', 'show-html', 'show-image'],
attributes: {
...(defaultSchema.attributes || {}),
show_html: ['ratio', 'js', 'data-encoded', 'data-partial'],
show_image: ['src', 'alt', 'title', 'width', 'height'],
'show-html': ['ratio', 'js', 'data-encoded', 'data-partial'],
'show-image': ['src', 'alt', 'title', 'width', 'height']
}
};
const markdownProcessor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(normalizeShowTagsPlugin)
.use(rehypeSanitize, sanitizedSchema)
.use(rehypeStringify, { allowDangerousHtml: false });
function stableHash(input: string): string {
let hash = 5381;
for (let i = 0; i < input.length; i += 1) {
@ -275,20 +402,25 @@ function wrapCodeBlocks(html: string, isStreaming = false) {
);
}
function renderMarkdownToHtml(text: string): string {
const file = markdownProcessor.processSync(text);
return String(file);
}
function escapeHtml(input: string) {
return input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function renderMarkdown(text: string, isStreaming = false) {
if (!text) {
return '';
}
if (typeof marked === 'undefined') {
return text;
}
marked.setOptions({
breaks: true,
gfm: true
});
if (!isStreaming) {
const cacheKey = buildCacheKey(text);
if (markdownCache.has(cacheKey)) {
@ -296,9 +428,14 @@ export function renderMarkdown(text: string, isStreaming = false) {
}
}
const safeText = transformShowHtmlBlocks(text, isStreaming);
const parsed = marked.parse(safeText) as string;
let html = parsed;
const safeText = transformShowImageBlocks(transformShowHtmlBlocks(text, isStreaming));
let html = '';
try {
html = renderMarkdownToHtml(safeText);
} catch (error) {
console.warn('Markdown 渲染失败,降级为纯文本渲染:', error);
html = escapeHtml(safeText).replace(/\n/g, '<br>');
}
html = wrapCodeBlocks(html, isStreaming);
if (!isStreaming && text.length < 10000) {

View File

@ -101,7 +101,7 @@ function getMessagesAreaMetrics(messagesArea: HTMLElement) {
}
function getLastShowHtmlMetrics(messagesArea: HTMLElement) {
const list = messagesArea.querySelectorAll('.chat-inline-html');
const list = messagesArea.querySelectorAll('.chat-inline-card--html, .chat-inline-html');
const last = (list[list.length - 1] as HTMLElement) || null;
if (!last) return null;
const areaRect = messagesArea.getBoundingClientRect();
@ -117,7 +117,9 @@ function getLastShowHtmlMetrics(messagesArea: HTMLElement) {
}
function hasShowHtmlNodeInArea(messagesArea: HTMLElement) {
return !!messagesArea.querySelector('show_html, .chat-inline-html');
return !!messagesArea.querySelector(
'show_html, show-html, .chat-inline-html, .chat-inline-card--html'
);
}
function showHtmlTraceLog(

View File

@ -889,15 +889,61 @@ body[data-theme='dark'] .more-icon {
line-height: 1.7;
}
.chat-inline-image {
.chat-inline-card {
display: block;
max-width: 100%;
margin: 14px auto;
border: 1px solid var(--claude-border);
border-radius: 12px;
overflow: hidden;
background: transparent;
box-shadow: var(--claude-shadow);
}
.chat-inline-card__body {
display: block;
width: 100%;
overflow: hidden;
border: 0;
background: transparent;
}
.chat-inline-card--pending {
display: flex;
align-items: center;
justify-content: center;
}
.chat-inline-card__pending-tip {
font-size: 13px;
color: var(--claude-text-secondary);
}
.chat-inline-card__error {
font-size: 12px;
color: var(--claude-text-secondary);
text-align: center;
}
.chat-inline-card__caption {
font-size: 12px;
color: var(--claude-text-secondary);
text-align: center;
line-height: 1.4;
padding: 0 8px;
}
.chat-inline-card--image {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin: 14px 0;
border: 0;
box-shadow: none;
}
.chat-inline-image img {
.chat-inline-card--image img {
display: block;
width: auto;
min-width: 160px;
@ -910,50 +956,15 @@ body[data-theme='dark'] .more-icon {
background: var(--claude-card);
}
.chat-inline-image--error {
.chat-inline-card--error {
border: 1px dashed var(--claude-accent);
padding: 6px 10px;
border-radius: 12px;
background: var(--claude-highlight);
}
.chat-inline-image__error {
font-size: 12px;
color: var(--claude-text-secondary);
text-align: center;
}
.chat-inline-image figcaption {
font-size: 12px;
color: var(--claude-text-secondary);
text-align: center;
line-height: 1.4;
padding: 0 8px;
}
.chat-inline-html {
.chat-inline-card--html {
display: block;
max-width: 100%;
margin: 14px auto;
border: 1px solid var(--claude-border);
border-radius: 12px;
overflow: hidden;
background: transparent;
box-shadow: var(--claude-shadow);
}
.chat-inline-html__host {
display: block;
width: 100%;
overflow: hidden;
border: 0;
background: transparent;
}
.chat-inline-html--pending {
display: flex;
align-items: center;
justify-content: center;
}
.chat-inline-html__host--pending {
@ -977,8 +988,13 @@ body[data-theme='dark'] .more-icon {
background: transparent;
}
/* 兼容旧类名(可逐步移除) */
.chat-inline-image__error { @extend .chat-inline-card__error; }
.chat-inline-image figcaption { @extend .chat-inline-card__caption; }
/* show_html 在 streaming 重建期间会短暂回到原始标签,需提供稳定占位避免 scrollHeight 抖动 */
show_html:not([data-rendered="1"]) {
show_html:not([data-rendered="1"]),
show-html:not([data-rendered="1"]) {
display: block;
width: 100%;
max-width: 100%;
@ -992,11 +1008,16 @@ show_html:not([data-rendered="1"]) {
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; }
show_html:not([data-rendered="1"])[ratio="1:1"],
show-html:not([data-rendered="1"])[ratio="1:1"] { aspect-ratio: 1 / 1; }
show_html:not([data-rendered="1"])[ratio="16:9"],
show-html:not([data-rendered="1"])[ratio="16:9"] { aspect-ratio: 16 / 9; }
show_html:not([data-rendered="1"])[ratio="9:16"],
show-html:not([data-rendered="1"])[ratio="9:16"] { aspect-ratio: 9 / 16; }
show_html:not([data-rendered="1"])[ratio="4:3"],
show-html:not([data-rendered="1"])[ratio="4:3"] { aspect-ratio: 4 / 3; }
show_html:not([data-rendered="1"])[ratio="3:4"],
show-html:not([data-rendered="1"])[ratio="3:4"] { aspect-ratio: 3 / 4; }
.text-output .text-content {
padding: 0 20px 0 15px;