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:
parent
e4dba9d264
commit
8f00e7668a
@ -17,7 +17,7 @@
|
|||||||
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
|
||||||
</style>
|
</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="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="/favicon.svg" type="image/svg+xml" />
|
||||||
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
<link rel="icon" href="/app-192.png" type="image/png" sizes="192x192" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "amerc-site",
|
"name": "amerc-site",
|
||||||
"version": "2.4.0-quartermaster",
|
"version": "2.5.0-chatmd",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
24
src/Chat.jsx
24
src/Chat.jsx
@ -1,9 +1,31 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { marked } from 'marked';
|
||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
|
|
||||||
// Native amerc chat — speaks plain HTTP to amerc-api (long-poll + streamed
|
// 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.
|
// 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) => ({ '&': '&', '<': '<', '>': '>' }[c])); }
|
||||||
|
|
||||||
const FILE_TTL_MS = 5 * 60 * 1000;
|
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 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' });
|
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';
|
const mine = m.role === 'user';
|
||||||
return (
|
return (
|
||||||
<div key={m.seq} className={`ch-msg ${mine ? 'mine' : 'theirs'}`}>
|
<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>
|
<span className="ch-when">{fmtTime(m.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 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 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 = [
|
const NAV = [
|
||||||
{ id: 'home', label: 'Tavern' },
|
{ id: 'home', label: 'Tavern' },
|
||||||
@ -477,6 +477,7 @@ function StatusPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CHANGELOG = [
|
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 amerc’s 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.4', date: 'Jun 2026', title: 'The Quartermaster command post', notes: ['The admin Quartermaster is now amerc’s 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.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'] },
|
{ 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'] },
|
||||||
|
|||||||
@ -1856,3 +1856,22 @@ button {
|
|||||||
.qm-link-go { color: #46c8e0; font-size: 15px; }
|
.qm-link-go { color: #46c8e0; font-size: 15px; }
|
||||||
.qm-body .px-admin { position: static; transform: none; width: 100%; margin: 0; }
|
.qm-body .px-admin { position: static; transform: none; width: 100%; margin: 0; }
|
||||||
.qm-body .pm-disk { padding: 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); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user