Skip to content

Commit 6ce6e5c

Browse files
authored
feat(export): embed the final map state in the saved chat log (#324) (#326)
The 💾 export dropped the map entirely. Capture the map as it stood at Save time — one map per log — as an interactive MapLibre embed (live, pannable) rather than a raster screenshot, so the transcript shows the layers/styling/filters that were on screen and stays inspectable. - MapManager.getExportState(): serializable {style, camera, projection}; terrain (keyed MapTiler DEM) stripped so its token can't leak. - chat-ui buildMapEmbedHtml(): pure helper that re-hydrates a map from the captured state via the same pinned CDN builds the app uses (maplibre-gl@5.22.0, pmtiles@3.0.7). Runs the state through the existing credential scrub + redacts MapTiler key= params; escapes < so a </script> in a source name/URL can't break out of the JSON block. - Wire into exportHtml() (guarded — a ChatUI without a map still exports) and pass mapManager into ChatUI from main.js. - Tests for both helpers; docs updated (Chat export § Embedded map).
1 parent c76da27 commit 6ce6e5c

6 files changed

Lines changed: 288 additions & 4 deletions

File tree

app/chat-ui.js

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,88 @@ export function scrubCredentials(text) {
6767
*/
6868
export const REDACTED_KEYS = ['s3_key', 's3_secret', 's3_endpoint', 's3_scope', 'catalog_token'];
6969

70+
/*
71+
* CDN builds used to re-hydrate the map in an exported transcript. Pinned to
72+
* match what the app itself loads (see app/index.html and
73+
* docs/guide/quickstart.md) so the embedded style renders under the same
74+
* MapLibre/PMTiles version that produced it. Bump alongside the app's pins.
75+
*/
76+
export const EXPORT_MAP_MAPLIBRE_VERSION = '5.22.0';
77+
export const EXPORT_MAP_PMTILES_VERSION = '3.0.7';
78+
79+
/**
80+
* Build a self-rendering, interactive MapLibre map from a captured map state,
81+
* as HTML for embedding in the exported transcript. The map re-hydrates from
82+
* the embedded style + camera in the recipient's browser — it is live
83+
* (pan/zoom), not a raster snapshot, so it needs network access to the same
84+
* public tile sources that produced it.
85+
*
86+
* The serialized state is credential-scrubbed: AWS signatures / bearer tokens
87+
* (via {@link scrubCredentials}) and MapTiler `key=` params must never ride
88+
* along into a shared file. `<` is escaped to `<` so a source name or URL
89+
* containing `</script>` can't break out of the embedded JSON block.
90+
*
91+
* @param {object|null} state - from MapManager.getExportState(); null/empty → no map
92+
* @returns {{ headTags: string, body: string }} empty strings when there is no map
93+
*/
94+
export function buildMapEmbedHtml(state) {
95+
if (!state || !state.style) return { headTags: '', body: '' };
96+
97+
let stateJson = JSON.stringify(state);
98+
stateJson = scrubCredentials(stateJson);
99+
// Redact MapTiler-style API keys in any surviving source URL.
100+
stateJson = stateJson.replace(/([?&]key=)[^&"'\\]+/g, '$1[REDACTED]');
101+
// Neutralize a </script> breakout hiding in the embedded JSON.
102+
const safeJson = stateJson.replace(/</g, '\\u003c');
103+
104+
const ml = EXPORT_MAP_MAPLIBRE_VERSION;
105+
const pm = EXPORT_MAP_PMTILES_VERSION;
106+
107+
const headTags =
108+
`<link href="https://unpkg.com/maplibre-gl@${ml}/dist/maplibre-gl.css" rel="stylesheet" crossorigin="anonymous">
109+
<script src="https://unpkg.com/maplibre-gl@${ml}/dist/maplibre-gl.js" crossorigin="anonymous"></script>
110+
<script src="https://unpkg.com/pmtiles@${pm}/dist/pmtiles.js" crossorigin="anonymous"></script>`;
111+
112+
const body =
113+
`<section class="export-map-section">
114+
<h2 class="export-map-title">Map at time of export</h2>
115+
<div id="export-map" class="export-map"></div>
116+
<p class="export-map-note">Interactive map re-rendered from the saved state. Needs a network
117+
connection to the original public tile sources; private or signed layers may not appear.</p>
118+
<script type="application/json" id="export-map-state">${safeJson}</script>
119+
<script>
120+
(function () {
121+
var el = document.getElementById('export-map');
122+
try {
123+
if (typeof maplibregl === 'undefined') throw new Error('MapLibre GL JS did not load');
124+
var state = JSON.parse(document.getElementById('export-map-state').textContent);
125+
if (window.pmtiles && maplibregl.addProtocol) {
126+
maplibregl.addProtocol('pmtiles', new pmtiles.Protocol().tile);
127+
}
128+
var map = new maplibregl.Map({
129+
container: 'export-map',
130+
style: state.style,
131+
center: state.center,
132+
zoom: state.zoom,
133+
bearing: state.bearing,
134+
pitch: state.pitch,
135+
renderWorldCopies: false,
136+
});
137+
map.addControl(new maplibregl.NavigationControl(), 'top-left');
138+
if (state.projection === 'globe') {
139+
map.on('load', function () { map.setProjection({ type: 'globe' }); });
140+
}
141+
} catch (e) {
142+
if (el) el.innerHTML = '<p class="export-map-error">Could not render the saved map: ' +
143+
(e && e.message ? e.message : e) + '</p>';
144+
}
145+
})();
146+
</script>
147+
</section>`;
148+
149+
return { headTags, body };
150+
}
151+
70152
/**
71153
* Render LLM-derived text to HTML for innerHTML insertion. The model can be
72154
* steered by anything it reads (dataset values, STAC descriptions), so its
@@ -104,10 +186,14 @@ export class ChatUI {
104186
* {
105187
* container, messages, input, send, mic, header, footer, footerRight,
106188
* }
189+
* @param {import('./map-manager.js').MapManager} [mapManager] - used by the
190+
* HTML export to embed the final map state; optional so tests and
191+
* headless harnesses can construct a ChatUI without a live map.
107192
*/
108-
constructor(agent, config, mount) {
193+
constructor(agent, config, mount, mapManager = null) {
109194
this.agent = agent;
110195
this.config = config;
196+
this.mapManager = mapManager;
111197
this.busy = false;
112198

113199
// Cache DOM refs from layout-manager (no getElementById here).
@@ -1087,13 +1173,24 @@ export class ChatUI {
10871173
const appTitle = document.title || 'GLEN';
10881174
const exportedAt = new Date().toLocaleString();
10891175

1176+
// Capture the final map state (one map per saved log). Guarded so a
1177+
// ChatUI built without a map still exports the transcript.
1178+
let mapState = null;
1179+
try {
1180+
mapState = this.mapManager?.getExportState?.() ?? null;
1181+
} catch (err) {
1182+
console.warn('[ChatUI] map capture for export failed:', err);
1183+
}
1184+
const mapEmbed = buildMapEmbedHtml(mapState);
1185+
10901186
const html =
10911187
`<!doctype html>
10921188
<html lang="en">
10931189
<head>
10941190
<meta charset="utf-8">
10951191
<title>${this.escapeHtml(title)}</title>
10961192
<style>${css}</style>
1193+
${mapEmbed.headTags}
10971194
</head>
10981195
<body>
10991196
<header class="export-header">
@@ -1103,6 +1200,7 @@ export class ChatUI {
11031200
(<code>https://s3-west.nrp-nautilus.io/</code>) so they can be re-run from any DuckDB
11041201
with the <code>httpfs</code> extension loaded.</p>
11051202
</header>
1203+
${mapEmbed.body}
11061204
<main id="chat-messages">${clone.innerHTML}</main>
11071205
</body>
11081206
</html>`;
@@ -1233,6 +1331,11 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-
12331331
.welcome-message { background: #f9fafb; padding: 8px 10px; border-radius: 6px;
12341332
font-size: 13px; color: #6b7280; }
12351333
.welcome-examples { display: none; }
1334+
.export-map-section { margin: 0 0 1rem; }
1335+
.export-map-title { font-size: 1rem; margin: 0 0 0.5rem; }
1336+
.export-map { width: 100%; height: 480px; border: 1px solid #ddd; border-radius: 6px; }
1337+
.export-map-note { font-size: 12px; color: #6b7280; margin: 6px 0 0; }
1338+
.export-map-error { padding: 1rem; color: #991b1b; font-size: 13px; }
12361339
`;
12371340
}
12381341

app/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ async function main() {
386386
console.log('[main] Agent ready');
387387

388388
/* ── 8. Create UI ─────────────────────────────────────────────────── */
389-
const ui = new ChatUI(agent, appConfig, layoutRefs.chatMount);
389+
const ui = new ChatUI(agent, appConfig, layoutRefs.chatMount, mapManager);
390390

391391
// Draw event → chat notifications.
392392
// Replace (not append) synthetic draw messages so repeated draw/clear

app/map-manager.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,36 @@ export class MapManager {
147147
document.body.appendChild(this._tooltip);
148148
}
149149

150+
/**
151+
* Snapshot the current map for embedding in an exported chat transcript.
152+
* Returns a plain, JSON-serializable object: the full MapLibre style
153+
* (sources + layers, with current paint / filter / visibility baked in)
154+
* plus the camera. A consumer can re-hydrate a live map from this alone.
155+
*
156+
* Terrain is stripped: its DEM source is keyed to a private MapTiler
157+
* token that must not leak into a shared export, and a flat map is fine
158+
* for a transcript. Remaining credential scrubbing happens at embed time.
159+
*
160+
* @returns {{center:[number,number], zoom:number, bearing:number,
161+
* pitch:number, projection:string, style:object}}
162+
*/
163+
getExportState() {
164+
const style = this.map.getStyle();
165+
if (style.terrain) delete style.terrain;
166+
if (style.sources && style.sources['terrain-dem']) {
167+
delete style.sources['terrain-dem'];
168+
}
169+
const c = this.map.getCenter();
170+
return {
171+
center: [c.lng, c.lat],
172+
zoom: this.map.getZoom(),
173+
bearing: this.map.getBearing(),
174+
pitch: this.map.getPitch(),
175+
projection: this._globeEnabled ? 'globe' : 'mercator',
176+
style,
177+
};
178+
}
179+
150180
/**
151181
* Register and add layers to the map from catalog configs.
152182
* @param {Array} layerConfigs - From DatasetCatalog.getMapLayerConfigs()

docs/guide/configuration.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,12 +719,21 @@ The checkpoint is the only per-turn cap on tool use. Setting a value to `0` remo
719719

720720
A 💾 save button in the chat footer saves the current conversation as a self-contained HTML document you can share or print. The button is disabled until the first user message and enables automatically after. No configuration — it's always present.
721721

722-
The saved file mirrors what the user sees in the live chat: user prompts, assistant prose, and tool-call rows with collapsible SQL and result blocks. Everything is in a single `.html` with inlined CSS — no external assets, no JavaScript required to view it.
722+
The saved file mirrors what the user sees in the live chat: user prompts, assistant prose, and tool-call rows with collapsible SQL and result blocks, plus the **map as it stood when Save was clicked** (see below).
723723

724724
Two guarantees apply to the export:
725725

726726
- **Reproducible SQL.** Every `s3://bucket/...` URL inside a SQL block is rewritten to `https://s3-west.nrp-nautilus.io/bucket/...`. Pasting the SQL into any DuckDB with `INSTALL httpfs; LOAD httpfs;` will run it against the public endpoint without secret configuration (public buckets only).
727-
- **Credential scrubbing.** On top of the live-chat redaction described in the agent-loop docs, the export pass replaces credential-shaped tokens with `[REDACTED]` — DuckDB `CREATE SECRET` key/value pairs, AWS access keys (`aws_access_key_id`, `aws_secret_access_key`), `Authorization: Bearer …` tokens, and pre-signed-URL `X-Amz-Signature` / `X-Amz-Credential` / `X-Amz-Security-Token` query parameters.
727+
- **Credential scrubbing.** On top of the live-chat redaction described in the agent-loop docs, the export pass replaces credential-shaped tokens with `[REDACTED]` — DuckDB `CREATE SECRET` key/value pairs, AWS access keys (`aws_access_key_id`, `aws_secret_access_key`), `Authorization: Bearer …` tokens, and pre-signed-URL `X-Amz-Signature` / `X-Amz-Credential` / `X-Amz-Security-Token` query parameters. This scrubbing also covers the embedded map state (below).
728+
729+
### Embedded map
730+
731+
The export captures the final map state — one map per saved log — as an **interactive MapLibre map**, not a static image. It serializes `map.getStyle()` (all sources, layers, and their current paint / filter / visibility) plus the camera (center, zoom, bearing, pitch, and globe-vs-mercator projection), embeds it in the HTML, and re-renders a live, pannable map when the file is opened. Because it's the real style rather than a screenshot, the recipient sees exactly the layers and styling that were on screen and can zoom and inspect them.
732+
733+
Trade-offs, by design:
734+
735+
- **Not fully offline.** The embedded map loads MapLibre GL JS and PMTiles from the same pinned CDN builds the app uses (`maplibre-gl@5.22.0`, `pmtiles@3.0.7`) and fetches tiles from the original public sources at view time. Without a network connection, the map area shows an error message; the rest of the transcript still renders.
736+
- **Public layers only.** Terrain (whose DEM source is keyed to a private MapTiler token) is stripped, and any signed or private tile URL is redacted by the credential scrub — so such layers may not appear for a recipient. The transcript text remains complete.
728737

729738
## Finding STAC asset IDs
730739

test/chat-export.test.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,84 @@ describe('scrubCredentials', () => {
115115
expect(scrubCredentials(null)).toBe(null);
116116
});
117117
});
118+
119+
import {
120+
buildMapEmbedHtml,
121+
EXPORT_MAP_MAPLIBRE_VERSION,
122+
EXPORT_MAP_PMTILES_VERSION,
123+
} from '../app/chat-ui.js';
124+
125+
describe('buildMapEmbedHtml', () => {
126+
const sampleState = () => ({
127+
center: [-119.4, 36.8],
128+
zoom: 6.5,
129+
bearing: 0,
130+
pitch: 0,
131+
projection: 'mercator',
132+
style: {
133+
version: 8,
134+
sources: { natgeo: { type: 'raster', tiles: ['https://example/{z}/{x}/{y}.png'] } },
135+
layers: [{ id: 'natgeo-base', type: 'raster', source: 'natgeo' }],
136+
},
137+
});
138+
139+
// Pull the JSON out of the <script type="application/json"> block and
140+
// reverse the < escaping so it can be JSON.parsed back.
141+
const extractState = (body) => {
142+
const m = body.match(
143+
/<script type="application\/json" id="export-map-state">([\s\S]*?)<\/script>/
144+
);
145+
expect(m).toBeTruthy();
146+
return JSON.parse(m[1].replace(/\\u003c/g, '<'));
147+
};
148+
149+
it('returns empty strings when there is no map state', () => {
150+
expect(buildMapEmbedHtml(null)).toEqual({ headTags: '', body: '' });
151+
expect(buildMapEmbedHtml(undefined)).toEqual({ headTags: '', body: '' });
152+
expect(buildMapEmbedHtml({})).toEqual({ headTags: '', body: '' });
153+
});
154+
155+
it('pins the CDN builds to the versions the app loads', () => {
156+
const { headTags } = buildMapEmbedHtml(sampleState());
157+
expect(headTags).toContain(`maplibre-gl@${EXPORT_MAP_MAPLIBRE_VERSION}/dist/maplibre-gl.js`);
158+
expect(headTags).toContain(`maplibre-gl@${EXPORT_MAP_MAPLIBRE_VERSION}/dist/maplibre-gl.css`);
159+
expect(headTags).toContain(`pmtiles@${EXPORT_MAP_PMTILES_VERSION}/dist/pmtiles.js`);
160+
});
161+
162+
it('embeds a parseable state and a container the init script targets', () => {
163+
const { body } = buildMapEmbedHtml(sampleState());
164+
expect(body).toContain('id="export-map"');
165+
expect(body).toContain("maplibregl.addProtocol('pmtiles'");
166+
const parsed = extractState(body);
167+
expect(parsed.center).toEqual([-119.4, 36.8]);
168+
expect(parsed.zoom).toBe(6.5);
169+
expect(parsed.style.layers[0].id).toBe('natgeo-base');
170+
});
171+
172+
it('scrubs AWS signatures and MapTiler keys from source URLs', () => {
173+
const state = sampleState();
174+
state.style.sources.natgeo.tiles = [
175+
'https://api.maptiler.com/tiles/x/{z}/{x}/{y}.png?key=SECRETKEY123',
176+
];
177+
state.style.sources.signed = {
178+
type: 'raster',
179+
tiles: ['https://s3-west.nrp-nautilus.io/b/x?X-Amz-Signature=abc123def456'],
180+
};
181+
const { body } = buildMapEmbedHtml(state);
182+
expect(body).not.toContain('SECRETKEY123');
183+
expect(body).not.toContain('abc123def456');
184+
// Still valid JSON after scrubbing.
185+
expect(() => extractState(body)).not.toThrow();
186+
});
187+
188+
it('neutralizes a </script> breakout hidden in the state', () => {
189+
const state = sampleState();
190+
state.style.name = 'evil</script><script>alert(1)</script>';
191+
const { body } = buildMapEmbedHtml(state);
192+
// The only real closing tags are the two we emit; the injected one is escaped.
193+
expect(body).not.toContain('<script>alert(1)');
194+
expect(body).toContain('\\u003c/script');
195+
// And it still round-trips.
196+
expect(extractState(body).style.name).toBe('evil</script><script>alert(1)</script>');
197+
});
198+
});

test/map-manager.export.test.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { MapManager } from '../app/map-manager.js';
3+
4+
/**
5+
* Stub the small MapLibre surface getExportState() reads. getStyle() returns
6+
* a fresh object each call (as MapLibre does) so the method's mutation can't
7+
* leak back into the live map.
8+
*/
9+
function createManager({ globe = false, style } = {}) {
10+
const map = {
11+
getStyle: () => JSON.parse(JSON.stringify(style)),
12+
getCenter: () => ({ lng: -119.4, lat: 36.8 }),
13+
getZoom: () => 6.5,
14+
getBearing: () => 12,
15+
getPitch: () => 30,
16+
};
17+
const mm = Object.create(MapManager.prototype);
18+
mm.map = map;
19+
mm._globeEnabled = globe;
20+
return mm;
21+
}
22+
23+
describe('MapManager.getExportState', () => {
24+
const styleWithTerrain = () => ({
25+
version: 8,
26+
terrain: { source: 'terrain-dem', exaggeration: 1.5 },
27+
sources: {
28+
'terrain-dem': { type: 'raster-dem', url: 'https://api.maptiler.com/x?key=SECRET' },
29+
natgeo: { type: 'raster', tiles: ['https://example/{z}/{x}/{y}.png'] },
30+
},
31+
layers: [{ id: 'natgeo-base', type: 'raster', source: 'natgeo' }],
32+
});
33+
34+
it('captures the camera and projection', () => {
35+
const s = createManager({ globe: true, style: styleWithTerrain() }).getExportState();
36+
expect(s.center).toEqual([-119.4, 36.8]);
37+
expect(s.zoom).toBe(6.5);
38+
expect(s.bearing).toBe(12);
39+
expect(s.pitch).toBe(30);
40+
expect(s.projection).toBe('globe');
41+
});
42+
43+
it('reports mercator when the globe is off', () => {
44+
const s = createManager({ globe: false, style: styleWithTerrain() }).getExportState();
45+
expect(s.projection).toBe('mercator');
46+
});
47+
48+
it('strips terrain and its keyed DEM source (must not leak the MapTiler key)', () => {
49+
const s = createManager({ style: styleWithTerrain() }).getExportState();
50+
expect(s.style.terrain).toBeUndefined();
51+
expect(s.style.sources['terrain-dem']).toBeUndefined();
52+
expect(s.style.sources.natgeo).toBeDefined();
53+
expect(JSON.stringify(s.style)).not.toContain('SECRET');
54+
});
55+
56+
it('keeps the full style (sources + layers) for re-hydration', () => {
57+
const s = createManager({ style: styleWithTerrain() }).getExportState();
58+
expect(s.style.layers[0].id).toBe('natgeo-base');
59+
expect(s.style.version).toBe(8);
60+
});
61+
});

0 commit comments

Comments
 (0)