chat: render markdown inline (2.5.0)

Chat messages render markdown via marked (code blocks, lists, tables,
links) with a DOMParser-based sanitizer that strips script/style/iframe/
event-handlers and javascript:/data: URLs; links open in a new tab. The
agent owns its own links (netdisk, showcase, anywhere) as the user asked.
Verified: formatted agent reply renders, injected <script> stays inert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
artheru 2026-06-11 13:41:46 +08:00
parent e4dba9d264
commit 8f00e7668a
5 changed files with 46 additions and 4 deletions

View File

@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
</style>
<meta name="description" content="amerc is the agent mercenary tavern — hire, run, publish, and remotely deliver AI agents across software boundaries. No infrastructure, just skills." />
<meta name="version" content="2.4.0-quartermaster" />
<meta name="version" content="2.5.0-chatmd" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

View File

@ -1,6 +1,6 @@
{
"name": "amerc-site",
"version": "2.4.0-quartermaster",
"version": "2.5.0-chatmd",
"private": true,
"type": "module",
"scripts": {

View File

@ -1,9 +1,31 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { marked } from 'marked';
import { api } from './api.js';
// Native amerc chat speaks plain HTTP to amerc-api (long-poll + streamed
// file relay). No iframe, no external relay: works whenever the API is up.
// Render chat text as markdown. The agent (or user) owns its links we only
// make them safe and open them in a new tab; the agent decides whether a link
// points at the netdisk, a showcase, or anywhere else.
marked.setOptions({ breaks: true, gfm: true });
function mdHtml(text) {
let raw;
try { raw = marked.parse(String(text || '')); } catch { return escapeText(text); }
if (typeof window === 'undefined' || !window.DOMParser) return escapeText(text);
const doc = new DOMParser().parseFromString(raw, 'text/html');
doc.querySelectorAll('script,style,iframe,object,embed,link,meta,form,input,button').forEach((n) => n.remove());
doc.querySelectorAll('*').forEach((el) => {
[...el.attributes].forEach((a) => {
if (/^on/i.test(a.name) || a.name === 'style') el.removeAttribute(a.name);
if ((a.name === 'href' || a.name === 'src') && /^\s*(javascript|data):/i.test(a.value)) el.removeAttribute(a.name);
});
if (el.tagName === 'A') { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); }
});
return doc.body.innerHTML;
}
function escapeText(s) { return String(s || '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c])); }
const FILE_TTL_MS = 5 * 60 * 1000;
const fmtBytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(0)} KB` : `${n} B`);
const fmtTime = (t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@ -136,7 +158,7 @@ export default function ChatPanel({ instance, accesskey, sessionId: resumeId, on
const mine = m.role === 'user';
return (
<div key={m.seq} className={`ch-msg ${mine ? 'mine' : 'theirs'}`}>
{m.kind === 'file' ? <FileMsg m={m} mine={mine} /> : <p>{m.content}</p>}
{m.kind === 'file' ? <FileMsg m={m} mine={mine} /> : <div className="ch-md" dangerouslySetInnerHTML={{ __html: mdHtml(m.content) }} />}
<span className="ch-when">{fmtTime(m.created_at)}</span>
</div>
);

View File

@ -12,7 +12,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>);
const X = ({ size = 20 }) => (<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>);
const RELEASE = '2.4.0-quartermaster';
const RELEASE = '2.5.0-chatmd';
const NAV = [
{ id: 'home', label: 'Tavern' },
@ -477,6 +477,7 @@ function StatusPage() {
}
const CHANGELOG = [
{ v: '2.5', date: 'Jun 2026', title: 'Chat speaks markdown', notes: ['Chat messages now render markdown — code blocks, lists, tables and links — so an agent can answer with formatted, clickable replies (it picks its own links, to the netdisk or anywhere)'] },
{ v: '2.4', date: 'Jun 2026', title: 'The Quartermaster command post', notes: ['The admin Quartermaster is now amercs internal management station — members, agents, companies & products, whiteboards and netdisk all in one panel', 'A command-post dashboard with live counts and quick links to git, docs, the PM workspace and the webagent relay'] },
{ v: '2.3', date: 'Jun 2026', title: 'My Mansion gets its villa', notes: ['My Mansion is now a moonlit estate — your villa with glowing windows, and the services (Showcase, File-mapper, Net-disk) are chambers within it'] },
{ v: '2.2', date: 'Jun 2026', title: 'My Legion becomes a war camp', notes: ['Your classes are banners and each instance is a soldier — alive and bobbing when online, standing by when pending, fallen when down', 'Tap a soldier to command it: chat with your own running agent, watch its live terminal, or stand it down', 'Statuses refresh on their own so the camp stays current'] },

View File

@ -1856,3 +1856,22 @@ button {
.qm-link-go { color: #46c8e0; font-size: 15px; }
.qm-body .px-admin { position: static; transform: none; width: 100%; margin: 0; }
.qm-body .pm-disk { padding: 0; }
/* markdown inside chat bubbles (2.5) */
.ch-md { color: #e8eef8; font-size: 13px; line-height: 1.5; word-break: break-word; }
.ch-md > :first-child { margin-top: 0; }
.ch-md > :last-child { margin-bottom: 0; }
.ch-md p { margin: 0 0 7px; }
.ch-md a { color: #7fd9ea; text-decoration: underline; }
.ch-md a:hover { color: #cfeff7; }
.ch-md code { background: rgba(0,0,0,0.35); border: 1px solid #2a3550; border-radius: 4px; padding: 1px 5px; font-family: ui-monospace, Menlo, monospace; font-size: 11.5px; color: #cfe2f2; }
.ch-md pre { background: #05070c; border: 1px solid #1c2438; border-radius: 8px; padding: 10px; overflow-x: auto; margin: 7px 0; }
.ch-md pre code { background: none; border: none; padding: 0; color: #9fe6c0; }
.ch-md ul, .ch-md ol { margin: 6px 0; padding-left: 20px; }
.ch-md li { margin: 2px 0; }
.ch-md h1, .ch-md h2, .ch-md h3, .ch-md h4 { margin: 8px 0 4px; font-size: 14px; color: #f3e9d2; font-family: Georgia, serif; }
.ch-md blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid #443a7e; color: #b6a7d4; }
.ch-md table { border-collapse: collapse; margin: 7px 0; font-size: 12px; }
.ch-md th, .ch-md td { border: 1px solid #2a3550; padding: 4px 8px; }
.ch-md img { max-width: 100%; border-radius: 6px; }
.ch-msg.mine .ch-md code { background: rgba(0,0,0,0.3); }