diff --git a/index.html b/index.html
index a47b0d8..a25b9ff 100644
--- a/index.html
+++ b/index.html
@@ -17,7 +17,7 @@
@media (prefers-reduced-motion: reduce) { .app-loading-gem { animation: none; transform: rotate(45deg); } }
-
+
diff --git a/package.json b/package.json
index 7ccad16..818d8a8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "amerc-site",
- "version": "2.4.0-quartermaster",
+ "version": "2.5.0-chatmd",
"private": true,
"type": "module",
"scripts": {
diff --git a/src/Chat.jsx b/src/Chat.jsx
index 32be991..ca0d00f 100644
--- a/src/Chat.jsx
+++ b/src/Chat.jsx
@@ -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) => ({ '&': '&', '<': '<', '>': '>' }[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 (
- {m.kind === 'file' ?
:
{m.content}
}
+ {m.kind === 'file' ?
:
}
{fmtTime(m.created_at)}
);
diff --git a/src/Scene2D.jsx b/src/Scene2D.jsx
index 41ab2d5..9c4fb27 100644
--- a/src/Scene2D.jsx
+++ b/src/Scene2D.jsx
@@ -12,7 +12,7 @@ import { api } from './api.js';
const Menu = ({ size = 20 }) => ();
const X = ({ size = 20 }) => ();
-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 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.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'] },
diff --git a/src/styles.css b/src/styles.css
index affee03..ef3ee16 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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); }