fix: unify show cards rendering and prompt constraints
This commit is contained in:
parent
24c702c0af
commit
9199f5ca7c
1505
package-lock.json
generated
1505
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
},
|
||||
|
||||
@ -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 文件操作
|
||||
|
||||
|
||||
@ -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 终端操作
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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')
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user