fix(ui): render markdown in chat
This commit is contained in:
@@ -13,6 +13,11 @@
|
|||||||
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
|
- Cron tool passes `id` to the gateway for update/remove/run/runs (keeps `jobId` input). (#180) — thanks @adamgall
|
||||||
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
|
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
|
||||||
|
|
||||||
|
## 2026.1.4-1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Control UI: render Markdown in chat messages (sanitized).
|
||||||
|
|
||||||
|
|
||||||
## 2026.1.4
|
## 2026.1.4
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.4",
|
"version": "2026.1.4-1",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -200,9 +200,15 @@ importers:
|
|||||||
|
|
||||||
ui:
|
ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
dompurify:
|
||||||
|
specifier: ^3.3.1
|
||||||
|
version: 3.3.1
|
||||||
lit:
|
lit:
|
||||||
specifier: ^3.3.2
|
specifier: ^3.3.2
|
||||||
version: 3.3.2
|
version: 3.3.2
|
||||||
|
marked:
|
||||||
|
specifier: ^17.0.1
|
||||||
|
version: 17.0.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@vitest/browser-playwright':
|
'@vitest/browser-playwright':
|
||||||
specifier: 4.0.16
|
specifier: 4.0.16
|
||||||
@@ -1627,6 +1633,9 @@ packages:
|
|||||||
dom-accessibility-api@0.5.16:
|
dom-accessibility-api@0.5.16:
|
||||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||||
|
|
||||||
|
dompurify@3.3.1:
|
||||||
|
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
|
||||||
|
|
||||||
dotenv@17.2.3:
|
dotenv@17.2.3:
|
||||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2200,6 +2209,11 @@ packages:
|
|||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
marked@17.0.1:
|
||||||
|
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4418,6 +4432,10 @@ snapshots:
|
|||||||
dom-accessibility-api@0.5.16:
|
dom-accessibility-api@0.5.16:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
dompurify@3.3.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
dotenv@17.2.3: {}
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
@@ -5032,6 +5050,8 @@ snapshots:
|
|||||||
|
|
||||||
marked@16.4.2: {}
|
marked@16.4.2: {}
|
||||||
|
|
||||||
|
marked@17.0.1: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
mdurl@2.0.0: {}
|
mdurl@2.0.0: {}
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"test": "vitest run --config vitest.config.ts"
|
"test": "vitest run --config vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lit": "^3.3.2"
|
"dompurify": "^3.3.1",
|
||||||
|
"lit": "^3.3.2",
|
||||||
|
"marked": "^17.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/browser-playwright": "4.0.16",
|
"@vitest/browser-playwright": "4.0.16",
|
||||||
|
|||||||
@@ -569,10 +569,106 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-text {
|
.chat-text {
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
color: var(--chat-text);
|
color: var(--chat-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(p, ul, ol, pre, blockquote, table) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(p + p, p + ul, p + ol, p + pre, p + blockquote, p + table) {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(ul, ol) {
|
||||||
|
padding-left: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(li + li) {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(a) {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(a:hover) {
|
||||||
|
text-decoration-thickness: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(blockquote) {
|
||||||
|
border-left: 2px solid rgba(255, 255, 255, 0.14);
|
||||||
|
padding-left: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .chat-text :where(blockquote) {
|
||||||
|
border-left-color: rgba(16, 24, 40, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(hr) {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 0.9em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(code) {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(:not(pre) > code) {
|
||||||
|
padding: 0.15em 0.35em;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .chat-text :where(:not(pre) > code) {
|
||||||
|
background: rgba(16, 24, 40, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(pre) {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .chat-text :where(pre) {
|
||||||
|
background: rgba(16, 24, 40, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(pre code) {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(table) {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(th, td) {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-text :where(th) {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-card {
|
.chat-tool-card {
|
||||||
|
|||||||
33
ui/src/ui/markdown.test.ts
Normal file
33
ui/src/ui/markdown.test.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { toSanitizedMarkdownHtml } from "./markdown";
|
||||||
|
|
||||||
|
describe("toSanitizedMarkdownHtml", () => {
|
||||||
|
it("renders basic markdown", () => {
|
||||||
|
const html = toSanitizedMarkdownHtml("Hello **world**");
|
||||||
|
expect(html).toContain("<strong>world</strong>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips scripts and unsafe links", () => {
|
||||||
|
const html = toSanitizedMarkdownHtml(
|
||||||
|
[
|
||||||
|
"<script>alert(1)</script>",
|
||||||
|
"",
|
||||||
|
"[x](javascript:alert(1))",
|
||||||
|
"",
|
||||||
|
"[ok](https://example.com)",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("<script");
|
||||||
|
expect(html).not.toContain("javascript:");
|
||||||
|
expect(html).toContain("https://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fenced code blocks", () => {
|
||||||
|
const html = toSanitizedMarkdownHtml(["```ts", "console.log(1)", "```"].join("\n"));
|
||||||
|
expect(html).toContain("<pre>");
|
||||||
|
expect(html).toContain("<code");
|
||||||
|
expect(html).toContain("console.log(1)");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
66
ui/src/ui/markdown.ts
Normal file
66
ui/src/ui/markdown.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
headerIds: false,
|
||||||
|
mangle: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedTags = [
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"blockquote",
|
||||||
|
"br",
|
||||||
|
"code",
|
||||||
|
"del",
|
||||||
|
"em",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"hr",
|
||||||
|
"i",
|
||||||
|
"li",
|
||||||
|
"ol",
|
||||||
|
"p",
|
||||||
|
"pre",
|
||||||
|
"strong",
|
||||||
|
"table",
|
||||||
|
"tbody",
|
||||||
|
"td",
|
||||||
|
"th",
|
||||||
|
"thead",
|
||||||
|
"tr",
|
||||||
|
"ul",
|
||||||
|
];
|
||||||
|
|
||||||
|
const allowedAttrs = ["class", "href", "rel", "target", "title"];
|
||||||
|
|
||||||
|
let hooksInstalled = false;
|
||||||
|
|
||||||
|
function installHooks() {
|
||||||
|
if (hooksInstalled) return;
|
||||||
|
hooksInstalled = true;
|
||||||
|
|
||||||
|
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||||
|
if (!(node instanceof HTMLAnchorElement)) return;
|
||||||
|
const href = node.getAttribute("href");
|
||||||
|
if (!href) return;
|
||||||
|
node.setAttribute("rel", "noreferrer noopener");
|
||||||
|
node.setAttribute("target", "_blank");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSanitizedMarkdownHtml(markdown: string): string {
|
||||||
|
const input = markdown.trim();
|
||||||
|
if (!input) return "";
|
||||||
|
installHooks();
|
||||||
|
const rendered = marked.parse(input) as string;
|
||||||
|
return DOMPurify.sanitize(rendered, {
|
||||||
|
ALLOWED_TAGS: allowedTags,
|
||||||
|
ALLOWED_ATTR: allowedAttrs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
|
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||||
|
|
||||||
import type { SessionsListResult } from "../types";
|
import type { SessionsListResult } from "../types";
|
||||||
|
import { toSanitizedMarkdownHtml } from "../markdown";
|
||||||
import { resolveToolDisplay, formatToolDetail } from "../tool-display";
|
import { resolveToolDisplay, formatToolDetail } from "../tool-display";
|
||||||
|
|
||||||
export type ChatProps = {
|
export type ChatProps = {
|
||||||
@@ -178,13 +180,19 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
|
|||||||
const extractedText = extractText(message);
|
const extractedText = extractText(message);
|
||||||
const contentText = typeof m.content === "string" ? m.content : null;
|
const contentText = typeof m.content === "string" ? m.content : null;
|
||||||
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
|
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
|
||||||
const text = !isToolResult
|
|
||||||
? extractedText?.trim()
|
const display =
|
||||||
? extractedText
|
!isToolResult && extractedText?.trim()
|
||||||
: contentText?.trim()
|
? { kind: "text" as const, value: extractedText }
|
||||||
? contentText
|
: !isToolResult && contentText?.trim()
|
||||||
: fallback
|
? { kind: "text" as const, value: contentText }
|
||||||
: null;
|
: !isToolResult && fallback
|
||||||
|
? { kind: "json" as const, value: fallback }
|
||||||
|
: null;
|
||||||
|
const markdown =
|
||||||
|
display?.kind === "json"
|
||||||
|
? ["```json", display.value, "```"].join("\n")
|
||||||
|
: display?.value ?? null;
|
||||||
|
|
||||||
const timestamp =
|
const timestamp =
|
||||||
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
|
||||||
@@ -194,7 +202,9 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
|
|||||||
<div class="chat-line ${klass}">
|
<div class="chat-line ${klass}">
|
||||||
<div class="chat-msg">
|
<div class="chat-msg">
|
||||||
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
|
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
|
||||||
${text ? html`<div class="chat-text">${text}</div>` : nothing}
|
${markdown
|
||||||
|
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
|
||||||
|
: nothing}
|
||||||
${toolCards.map((card) => renderToolCard(card))}
|
${toolCards.map((card) => renderToolCard(card))}
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-stamp mono">
|
<div class="chat-stamp mono">
|
||||||
|
|||||||
Reference in New Issue
Block a user