feat(canvas): persist artifacts and surface UI
This commit is contained in:
+1
-1
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user