feat(canvas): persist artifacts and surface UI

This commit is contained in:
William Valentin
2026-02-25 11:18:53 -08:00
parent ac60fa5be3
commit e3e98058b0
11 changed files with 330 additions and 5 deletions
+1 -1
View File
@@ -247,7 +247,7 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions):
let channelAgents: ReturnType<typeof createMessageRouter>['agents'] | null = null;
const gateway = createGateway({
config, configPath: options?.persistConfigPath ?? options?.configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor,
config, configPath: options?.persistConfigPath ?? options?.configPath, dataDir, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor,
channelRegistry, pairingManager, lifecycle, memoryStore,
getBackendMode: () => backendMode,
setBackendMode: (mode) => {
+5 -1
View File
@@ -282,6 +282,7 @@ export function initPairingManager(config: Config, store?: PairingStore): Pairin
export interface GatewayDeps {
config: Config;
configPath?: string;
dataDir: string;
sessionManager: SessionManager;
modelRouter: ModelRouter;
systemPrompt: string;
@@ -301,7 +302,7 @@ export interface GatewayDeps {
}
export function createGateway(deps: GatewayDeps): GatewayServer {
const { config, configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, channelRegistry, pairingManager, lifecycle, getChannelAgents } = deps;
const { config, configPath, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, channelRegistry, pairingManager, lifecycle, getChannelAgents, dataDir } = deps;
const gateway = new GatewayServer({
port: config.server.port,
@@ -358,6 +359,9 @@ export function createGateway(deps: GatewayDeps): GatewayServer {
),
},
},
canvas: {
persistDir: resolve(dataDir, 'canvas'),
},
nodes: {
enabled: config.server.nodes.enabled,
allowedRoles: config.server.nodes.allowed_roles,
+21
View File
@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { CanvasStore } from './canvas-store.js';
describe('CanvasStore', () => {
@@ -55,4 +58,22 @@ describe('CanvasStore', () => {
expect(store.get('ws:1', 'a1')).toBeUndefined();
expect(store.list('ws:1').map((a) => a.id).sort()).toEqual(['a2', 'a3']);
});
it('persists artifacts when a persistDir is configured', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'flynn-canvas-'));
try {
const store = new CanvasStore({ persistDir: dir });
store.put('ws:1', {
id: 'a1',
type: 'note',
content: { text: 'hello' },
});
const reloaded = new CanvasStore({ persistDir: dir });
expect(reloaded.list('ws:1')).toHaveLength(1);
expect(reloaded.get('ws:1', 'a1')?.content).toEqual({ text: 'hello' });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+129 -1
View File
@@ -1,3 +1,6 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
export interface CanvasArtifact {
id: string;
type: string;
@@ -8,6 +11,11 @@ export interface CanvasArtifact {
updatedAt: number;
}
export interface CanvasStoreOptions {
maxArtifactsPerSession?: number;
persistDir?: string;
}
interface CanvasPutInput {
id?: string;
type: string;
@@ -22,10 +30,25 @@ interface CanvasPutInput {
*/
export class CanvasStore {
private readonly sessions = new Map<string, Map<string, CanvasArtifact>>();
private readonly hydratedSessions = new Set<string>();
private readonly maxArtifactsPerSession: number;
private readonly persistDir?: string;
constructor(private readonly maxArtifactsPerSession = 200) {}
constructor(options: CanvasStoreOptions | number = {}) {
if (typeof options === 'number') {
this.maxArtifactsPerSession = options;
} else {
this.maxArtifactsPerSession = options.maxArtifactsPerSession ?? 200;
this.persistDir = options.persistDir;
}
if (this.persistDir) {
mkdirSync(this.persistDir, { recursive: true });
}
}
list(sessionId: string): CanvasArtifact[] {
this.ensureHydrated(sessionId);
const entries = this.sessions.get(sessionId);
if (!entries) {
return [];
@@ -34,10 +57,12 @@ export class CanvasStore {
}
get(sessionId: string, artifactId: string): CanvasArtifact | undefined {
this.ensureHydrated(sessionId);
return this.sessions.get(sessionId)?.get(artifactId);
}
put(sessionId: string, input: CanvasPutInput): CanvasArtifact {
this.ensureHydrated(sessionId);
const id = sanitizeArtifactId(input.id);
const now = Date.now();
let entries = this.sessions.get(sessionId);
@@ -64,10 +89,12 @@ export class CanvasStore {
entries.delete(oldest.id);
}
}
this.persistSession(sessionId);
return artifact;
}
delete(sessionId: string, artifactId: string): boolean {
this.ensureHydrated(sessionId);
const entries = this.sessions.get(sessionId);
if (!entries) {
return false;
@@ -76,18 +103,90 @@ export class CanvasStore {
if (entries.size === 0) {
this.sessions.delete(sessionId);
}
this.persistSession(sessionId);
return removed;
}
clear(sessionId: string): number {
this.ensureHydrated(sessionId);
const entries = this.sessions.get(sessionId);
if (!entries) {
return 0;
}
const count = entries.size;
this.sessions.delete(sessionId);
this.persistSession(sessionId);
return count;
}
private ensureHydrated(sessionId: string): void {
if (!this.persistDir || this.hydratedSessions.has(sessionId)) {
return;
}
this.hydratedSessions.add(sessionId);
const filePath = resolve(this.persistDir, sessionFileName(sessionId));
try {
const raw = readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw) as { artifacts?: CanvasArtifact[] } | null;
const artifacts = Array.isArray(parsed?.artifacts) ? parsed?.artifacts ?? [] : [];
const entries = new Map<string, CanvasArtifact>();
const now = Date.now();
for (const item of artifacts) {
const normalized = normalizeArtifact(item, now);
if (!normalized) {
continue;
}
entries.set(normalized.id, normalized);
}
if (entries.size > 0) {
this.trimEntries(entries);
this.sessions.set(sessionId, entries);
}
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err?.code !== 'ENOENT') {
// Ignore corrupt state; we'll rebuild as new artifacts arrive.
}
}
}
private persistSession(sessionId: string): void {
if (!this.persistDir) {
return;
}
const filePath = resolve(this.persistDir, sessionFileName(sessionId));
const entries = this.sessions.get(sessionId);
try {
if (!entries || entries.size === 0) {
rmSync(filePath, { force: true });
return;
}
const payload = {
sessionId,
artifacts: Array.from(entries.values()).sort((a, b) => b.updatedAt - a.updatedAt),
updatedAt: Date.now(),
};
writeFileSync(filePath, JSON.stringify(payload, null, 2));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Failed to persist canvas session ${sessionId}: ${message}`);
}
}
private trimEntries(entries: Map<string, CanvasArtifact>): void {
if (entries.size <= this.maxArtifactsPerSession) {
return;
}
const sorted = Array.from(entries.values()).sort((a, b) => a.updatedAt - b.updatedAt);
const excess = sorted.length - this.maxArtifactsPerSession;
for (let i = 0; i < excess; i += 1) {
const artifact = sorted[i];
if (artifact) {
entries.delete(artifact.id);
}
}
}
}
function sanitizeArtifactId(raw?: string): string {
@@ -97,3 +196,32 @@ function sanitizeArtifactId(raw?: string): string {
}
return `art_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
function sessionFileName(sessionId: string): string {
const encoded = Buffer.from(sessionId).toString('base64url');
return `canvas_${encoded}.json`;
}
function normalizeArtifact(raw: CanvasArtifact, now: number): CanvasArtifact | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
const type = typeof raw.type === 'string' ? raw.type.trim() : '';
if (!id || !type) {
return null;
}
const title = typeof raw.title === 'string' && raw.title.trim().length > 0 ? raw.title.trim() : undefined;
const createdAt = Number.isFinite(raw.createdAt) ? raw.createdAt : now;
const updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : createdAt;
const metadata = raw.metadata && typeof raw.metadata === 'object' ? raw.metadata : undefined;
return {
id,
type,
title,
content: raw.content,
metadata: metadata as Record<string, unknown> | undefined,
createdAt,
updatedAt,
};
}
+8 -1
View File
@@ -106,6 +106,10 @@ export interface GatewayServerConfig {
sessions?: Record<string, Partial<LaneQueueConfig>>;
};
};
canvas?: {
persistDir?: string;
maxArtifactsPerSession?: number;
};
nodes?: {
enabled: boolean;
allowedRoles: string[];
@@ -203,7 +207,10 @@ export class GatewayServer {
});
this.laneQueue = new LaneQueue(config.queue);
this.canvasStore = new CanvasStore();
this.canvasStore = new CanvasStore({
maxArtifactsPerSession: config.canvas?.maxArtifactsPerSession,
persistDir: config.canvas?.persistDir,
});
this.metrics = new MetricsCollector({
getQueueDepth: () => this.laneQueue.totalPending(),
});
+122
View File
@@ -16,6 +16,11 @@ let _slashPopupIndex = -1;
let _elements = {};
let _pendingAttachments = [];
let _sessionSort = 'recent';
let _client = null;
let _canvasOpen = false;
let _canvasLoading = false;
let _canvasArtifacts = [];
let _canvasError = null;
// ── Slash Command Definitions ───────────────────────────────
@@ -675,6 +680,101 @@ async function loadHistory(client) {
}
}
// ── Canvas Surface ───────────────────────────────────────────
function renderCanvasPanel() {
const panel = _elements.canvasPanel;
if (!panel) {return;}
if (!_canvasOpen) {
panel.classList.add('hidden');
panel.innerHTML = '';
return;
}
panel.classList.remove('hidden');
panel.innerHTML = `
<div class="flex items-center justify-between px-3 py-2 border border-zinc-800 rounded-lg bg-zinc-900/70">
<div class="text-xs font-semibold text-zinc-200 uppercase tracking-wide">Canvas</div>
<button id="chat-canvas-refresh" class="text-xs text-zinc-400 hover:text-zinc-100 transition-colors">Refresh</button>
</div>
<div id="chat-canvas-body" class="mt-2 space-y-2"></div>
`;
const refreshBtn = panel.querySelector('#chat-canvas-refresh');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
void loadCanvasArtifacts();
});
}
const body = panel.querySelector('#chat-canvas-body');
if (!body) {return;}
if (!_currentSession) {
body.innerHTML = '<div class="text-xs text-zinc-500">Select a session to view canvas artifacts.</div>';
return;
}
if (_canvasLoading) {
body.innerHTML = '<div class="text-xs text-zinc-500">Loading canvas artifacts...</div>';
return;
}
if (_canvasError) {
body.innerHTML = `<div class="text-xs text-red-400">${escapeHtml(_canvasError)}</div>`;
return;
}
if (_canvasArtifacts.length === 0) {
body.innerHTML = '<div class="text-xs text-zinc-500">No canvas artifacts yet.</div>';
return;
}
for (const artifact of _canvasArtifacts) {
const card = document.createElement('div');
card.className = 'border border-zinc-800 rounded-lg bg-zinc-900 px-3 py-2';
const title = artifact.title ? escapeHtml(artifact.title) : 'Untitled';
const type = artifact.type ? escapeHtml(artifact.type) : 'unknown';
const updatedAt = typeof artifact.updatedAt === 'number' ? formatMessageTimestamp(artifact.updatedAt) : '';
const contentPreview = escapeHtml(JSON.stringify(artifact.content ?? '', null, 2).slice(0, 800));
card.innerHTML = `
<div class="flex items-center justify-between text-[11px] text-zinc-500">
<div>${title}</div>
<div>${type}${updatedAt ? ` · ${updatedAt}` : ''}</div>
</div>
<pre class="mt-2 text-[11px] leading-relaxed text-zinc-300 whitespace-pre-wrap break-words">${contentPreview}</pre>
`;
body.appendChild(card);
}
}
async function loadCanvasArtifacts() {
if (!_client) {return;}
_canvasLoading = true;
_canvasError = null;
renderCanvasPanel();
if (!_currentSession) {
_canvasArtifacts = [];
_canvasLoading = false;
renderCanvasPanel();
return;
}
try {
const result = await _client.call('canvas.list', { sessionId: _currentSession });
_canvasArtifacts = result?.artifacts ?? [];
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
_canvasArtifacts = [];
_canvasError = `Failed to load canvas: ${message}`;
} finally {
_canvasLoading = false;
renderCanvasPanel();
}
}
// ── Send Message ────────────────────────────────────────────
async function sendMessage(client, overrideText) {
@@ -836,6 +936,7 @@ const EDIT_ICON = '<svg class="w-3.5 h-3.5 fill-current" viewBox="0 0 16 16" xml
export const ChatPage = {
async render(el, client) {
_client = client;
el.innerHTML = `
<div class="flex flex-col h-[calc(100vh-6rem)] md:h-[calc(100vh-3rem)] max-w-3xl">
<div class="flex items-center gap-3 pb-3 border-b border-zinc-800 mb-3 flex-wrap">
@@ -847,7 +948,9 @@ export const ChatPage = {
</select>
<button id="chat-new-session" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">+ New</button>
<button id="chat-load-history" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">History</button>
<button id="chat-load-canvas" class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors">Canvas</button>
</div>
<div id="chat-canvas-panel" class="hidden mb-3"></div>
<div class="flex-1 overflow-y-auto flex flex-col gap-3 py-3" id="chat-messages"></div>
<div class="flex items-center gap-2 py-2 flex-wrap">
<button id="chat-search" class="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700 hover:text-zinc-50 hover:border-zinc-600 transition-colors cursor-pointer select-none" title="Search the web">
@@ -882,6 +985,7 @@ export const ChatPage = {
fileInput: el.querySelector('#chat-file'),
attachments: el.querySelector('#chat-attachments'),
slashPopup: el.querySelector('#slash-popup'),
canvasPanel: el.querySelector('#chat-canvas-panel'),
};
// Load sessions into dropdown
@@ -890,6 +994,9 @@ export const ChatPage = {
// Event: session change
_elements.sessionSelect.addEventListener('change', () => {
_currentSession = _elements.sessionSelect.value || null;
if (_canvasOpen) {
void loadCanvasArtifacts();
}
});
_elements.sessionSort.addEventListener('change', () => {
@@ -914,6 +1021,16 @@ export const ChatPage = {
loadHistory(client);
});
// Event: load canvas
el.querySelector('#chat-load-canvas').addEventListener('click', () => {
_canvasOpen = !_canvasOpen;
if (_canvasOpen) {
void loadCanvasArtifacts();
} else {
renderCanvasPanel();
}
});
// Event: search button toggle
_elements.searchBtn.addEventListener('click', () => {
setSearchMode(!_searchMode);
@@ -1040,5 +1157,10 @@ export const ChatPage = {
_sessionSort = 'recent';
_elements = {};
_pendingAttachments = [];
_client = null;
_canvasOpen = false;
_canvasLoading = false;
_canvasArtifacts = [];
_canvasError = null;
},
};
+19
View File
@@ -89,6 +89,20 @@ function createClient() {
}
return { ok: true };
}
if (method === 'canvas.list') {
return {
artifacts: [
{
id: 'a1',
type: 'note',
title: 'Example',
content: { text: 'hello' },
createdAt: Date.now(),
updatedAt: Date.now(),
},
],
};
}
return null;
},
stream(method: string, params?: Record<string, unknown>) {
@@ -164,6 +178,11 @@ describe('ChatPage wiring', () => {
await Promise.resolve();
expect(calls.some((entry) => entry.method === 'sessions.history')).toBe(true);
root.querySelector('#chat-load-canvas').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
await Promise.resolve();
const canvasCall = calls.find((entry) => entry.method === 'canvas.list');
expect(canvasCall?.params?.sessionId).toBe('ws:alpha');
root.querySelector('#chat-search').dispatchEvent(new windowObj.Event('click', { bubbles: true }));
const input = root.querySelector('#chat-input');
input.value = 'status of flynn';