diff --git a/CHANGELOG.md b/CHANGELOG.md index 644ec5a17..0526927c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ - 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 +## 2026.1.4-1 + +### Fixes +- Control UI: render Markdown in chat messages (sanitized). + ## 2026.1.4 diff --git a/package.json b/package.json index a432fe812..94a2b7952 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.4", + "version": "2026.1.4-1", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9303e34d2..2eb154c0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,9 +200,15 @@ importers: ui: dependencies: + dompurify: + specifier: ^3.3.1 + version: 3.3.1 lit: specifier: ^3.3.2 version: 3.3.2 + marked: + specifier: ^17.0.1 + version: 17.0.1 devDependencies: '@vitest/browser-playwright': specifier: 4.0.16 @@ -1627,6 +1633,9 @@ packages: dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -2200,6 +2209,11 @@ packages: engines: {node: '>= 20'} hasBin: true + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -4418,6 +4432,10 @@ snapshots: dom-accessibility-api@0.5.16: optional: true + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@17.2.3: {} dunder-proto@1.0.1: @@ -5032,6 +5050,8 @@ snapshots: marked@16.4.2: {} + marked@17.0.1: {} + math-intrinsics@1.1.0: {} mdurl@2.0.0: {} diff --git a/ui/package.json b/ui/package.json index 0a72c92e9..a9300bd12 100644 --- a/ui/package.json +++ b/ui/package.json @@ -9,7 +9,9 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "lit": "^3.3.2" + "dompurify": "^3.3.1", + "lit": "^3.3.2", + "marked": "^17.0.1" }, "devDependencies": { "@vitest/browser-playwright": "4.0.16", diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 98586e949..7665f661f 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -569,10 +569,106 @@ } .chat-text { - white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; 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 { diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts new file mode 100644 index 000000000..da2c4aca0 --- /dev/null +++ b/ui/src/ui/markdown.test.ts @@ -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("world"); + }); + + it("strips scripts and unsafe links", () => { + const html = toSanitizedMarkdownHtml( + [ + "", + "", + "[x](javascript:alert(1))", + "", + "[ok](https://example.com)", + ].join("\n"), + ); + expect(html).not.toContain(" { + const html = toSanitizedMarkdownHtml(["```ts", "console.log(1)", "```"].join("\n")); + expect(html).toContain("
");
+    expect(html).toContain(" {
+    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,
+  });
+}
+
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index 2003dc3df..830cccf58 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -1,6 +1,8 @@
 import { html, nothing } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
 
 import type { SessionsListResult } from "../types";
+import { toSanitizedMarkdownHtml } from "../markdown";
 import { resolveToolDisplay, formatToolDetail } from "../tool-display";
 
 export type ChatProps = {
@@ -178,13 +180,19 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
   const extractedText = extractText(message);
   const contentText = typeof m.content === "string" ? m.content : null;
   const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
-  const text = !isToolResult
-    ? extractedText?.trim()
-      ? extractedText
-      : contentText?.trim()
-        ? contentText
-        : fallback
-    : null;
+
+  const display =
+    !isToolResult && extractedText?.trim()
+      ? { kind: "text" as const, value: extractedText }
+      : !isToolResult && contentText?.trim()
+        ? { kind: "text" as const, value: contentText }
+        : !isToolResult && fallback
+          ? { kind: "json" as const, value: fallback }
+          : null;
+  const markdown =
+    display?.kind === "json"
+      ? ["```json", display.value, "```"].join("\n")
+      : display?.value ?? null;
 
   const timestamp =
     typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
@@ -194,7 +202,9 @@ function renderMessage(message: unknown, opts?: { streaming?: boolean }) {
     
- ${text ? html`
${text}
` : nothing} + ${markdown + ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` + : nothing} ${toolCards.map((card) => renderToolCard(card))}