From feabf891373db8e6a9c31a088136fc9b5f10126d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 5 Mar 2026 11:50:44 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20MI=20Content=20Triage=20=E2=80=94?= =?UTF-8?q?=20paste/drop=20content=20for=20AI-powered=20shape=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "dump & layout" feature where users can paste or drag-drop unstructured content onto the canvas and have Gemini 2.5 Flash analyze it, classify each piece into the appropriate folk-* shape type, and propose a multi-shape layout with semantic connections. - POST /api/mi/triage endpoint with structured JSON output from Gemini - TriageManager orchestrator (analyze, removeShape, commitAll via MiActionExecutor) - MiTriagePanel floating preview UI with shape cards, connections, Create All/Cancel - Canvas drag/drop overlay + paste handler integration Co-Authored-By: Claude Opus 4.6 --- lib/index.ts | 2 + lib/mi-content-triage.ts | 142 ++++++++++++++++ lib/mi-triage-panel.ts | 342 +++++++++++++++++++++++++++++++++++++++ server/index.ts | 120 +++++++++++++- website/canvas.html | 92 ++++++++++- 5 files changed, 694 insertions(+), 4 deletions(-) create mode 100644 lib/mi-content-triage.ts create mode 100644 lib/mi-triage-panel.ts diff --git a/lib/index.ts b/lib/index.ts index 98a3133..2f78cd3 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -97,3 +97,5 @@ export * from "./mi-actions"; export * from "./mi-action-executor"; export * from "./mi-selection-transforms"; export * from "./mi-tool-schema"; +export * from "./mi-content-triage"; +export * from "./mi-triage-panel"; diff --git a/lib/mi-content-triage.ts b/lib/mi-content-triage.ts new file mode 100644 index 0000000..2cd1e9b --- /dev/null +++ b/lib/mi-content-triage.ts @@ -0,0 +1,142 @@ +/** + * MI Content Triage β€” orchestrates content analysis and shape creation. + * + * Sends raw pasted/dropped content to `/api/mi/triage`, stores the proposal, + * and commits approved shapes via MiActionExecutor. + */ + +import type { MiAction } from "./mi-actions"; +import { MiActionExecutor } from "./mi-action-executor"; + +export interface TriageProposedShape { + tagName: string; + label: string; + props: Record; + snippet: string; +} + +export interface TriageConnection { + fromIndex: number; + toIndex: number; + reason: string; +} + +export interface TriageProposal { + summary: string; + shapes: TriageProposedShape[]; + connections: TriageConnection[]; +} + +export type TriageStatus = "idle" | "analyzing" | "ready" | "error"; + +/** Extract URLs from raw text. */ +function extractUrls(text: string): string[] { + const urlPattern = /https?:\/\/[^\s<>"{}|\\^`\[\]]+/g; + return [...new Set(text.match(urlPattern) || [])]; +} + +export class TriageManager { + proposal: TriageProposal | null = null; + status: TriageStatus = "idle"; + error: string | null = null; + + private onChange: (() => void) | null = null; + + constructor(onChange?: () => void) { + this.onChange = onChange || null; + } + + private notify() { + this.onChange?.(); + } + + async analyze(raw: string, type: "paste" | "drop" = "paste"): Promise { + if (!raw.trim()) { + this.status = "error"; + this.error = "No content to analyze"; + this.notify(); + return; + } + + this.status = "analyzing"; + this.error = null; + this.proposal = null; + this.notify(); + + try { + const res = await fetch("/api/mi/triage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: raw.slice(0, 50000), contentType: type }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Request failed" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + + const data = await res.json(); + this.proposal = { + summary: data.summary || "Content analyzed", + shapes: Array.isArray(data.shapes) ? data.shapes : [], + connections: Array.isArray(data.connections) ? data.connections : [], + }; + this.status = this.proposal.shapes.length > 0 ? "ready" : "error"; + if (this.proposal.shapes.length === 0) { + this.error = "No shapes identified in content"; + } + } catch (e: any) { + this.status = "error"; + this.error = e.message || "Analysis failed"; + this.proposal = null; + } + + this.notify(); + } + + removeShape(index: number): void { + if (!this.proposal) return; + this.proposal.shapes.splice(index, 1); + // Reindex connections β€” remove any referencing deleted index, adjust higher indices + this.proposal.connections = this.proposal.connections + .filter((c) => c.fromIndex !== index && c.toIndex !== index) + .map((c) => ({ + ...c, + fromIndex: c.fromIndex > index ? c.fromIndex - 1 : c.fromIndex, + toIndex: c.toIndex > index ? c.toIndex - 1 : c.toIndex, + })); + this.notify(); + } + + commitAll(): void { + if (!this.proposal || this.proposal.shapes.length === 0) return; + + const actions: MiAction[] = []; + + // Create shapes with $N refs + for (let i = 0; i < this.proposal.shapes.length; i++) { + const shape = this.proposal.shapes[i]; + actions.push({ + type: "create-shape", + tagName: shape.tagName, + props: { ...shape.props }, + ref: `$${i + 1}`, + }); + } + + // Create connections + for (const conn of this.proposal.connections) { + actions.push({ + type: "connect", + sourceId: `$${conn.fromIndex + 1}`, + targetId: `$${conn.toIndex + 1}`, + }); + } + + const executor = new MiActionExecutor(); + executor.execute(actions); + + this.status = "idle"; + this.proposal = null; + } +} diff --git a/lib/mi-triage-panel.ts b/lib/mi-triage-panel.ts new file mode 100644 index 0000000..5113438 --- /dev/null +++ b/lib/mi-triage-panel.ts @@ -0,0 +1,342 @@ +/** + * MI Triage Panel β€” floating preview UI for content triage proposals. + * + * Shows the AI summary, proposed shapes as cards with remove buttons, + * connections, and Create All / Cancel actions. + * Uses existing --rs-* CSS variables for theming. + */ + +import type { TriageManager } from "./mi-content-triage"; + +/** Icon lookup by tagName β€” matches TOOL_HINTS from mi-tool-schema.ts */ +const SHAPE_ICONS: Record = { + "folk-markdown": { icon: "πŸ“", label: "Note" }, + "folk-embed": { icon: "πŸ”—", label: "Embed" }, + "folk-calendar": { icon: "πŸ“…", label: "Calendar" }, + "folk-map": { icon: "πŸ—ΊοΈ", label: "Map" }, + "folk-workflow-block": { icon: "βš™οΈ", label: "Workflow" }, + "folk-social-post": { icon: "πŸ“£", label: "Social Post" }, + "folk-choice-vote": { icon: "πŸ—³οΈ", label: "Vote" }, + "folk-prompt": { icon: "πŸ€–", label: "AI Chat" }, + "folk-image-gen": { icon: "🎨", label: "AI Image" }, + "folk-slide": { icon: "πŸ–ΌοΈ", label: "Slide" }, +}; + +export class MiTriagePanel { + private el: HTMLDivElement; + private manager: TriageManager; + + constructor(manager: TriageManager) { + this.manager = manager; + this.el = document.createElement("div"); + this.el.className = "mi-triage-panel"; + this.el.innerHTML = this.renderLoading(); + this.injectStyles(); + document.body.appendChild(this.el); + + // Re-render when manager state changes + this.manager = manager; + const originalOnChange = (manager as any).onChange; + (manager as any).onChange = () => { + originalOnChange?.(); + this.render(); + }; + } + + private render() { + switch (this.manager.status) { + case "analyzing": + this.el.innerHTML = this.renderLoading(); + break; + case "ready": + this.el.innerHTML = this.renderProposal(); + this.bindEvents(); + break; + case "error": + this.el.innerHTML = this.renderError(); + this.bindCloseEvent(); + break; + default: + this.close(); + } + } + + private renderLoading(): string { + return ` +
+ MI Content Triage +
+
+
+

Analyzing content...

+
+ `; + } + + private renderError(): string { + return ` +
+ MI Content Triage + +
+
+

${this.manager.error || "Something went wrong"}

+
+ `; + } + + private renderProposal(): string { + const p = this.manager.proposal!; + const shapeCards = p.shapes + .map((s, i) => { + const info = SHAPE_ICONS[s.tagName] || { icon: "πŸ“¦", label: s.tagName }; + return ` +
+
+ ${info.icon} + ${escapeHtml(s.label)} + ${info.label} + +
+
${escapeHtml(s.snippet || "")}
+
`; + }) + .join(""); + + const connList = + p.connections.length > 0 + ? `
+
Connections
+ ${p.connections + .map((c) => { + const from = p.shapes[c.fromIndex]?.label || `#${c.fromIndex}`; + const to = p.shapes[c.toIndex]?.label || `#${c.toIndex}`; + return `
${escapeHtml(from)} β†’ ${escapeHtml(to)}${escapeHtml(c.reason || "")}
`; + }) + .join("")} +
` + : ""; + + return ` +
+ MI Content Triage + +
+
${escapeHtml(p.summary)}
+
+ ${shapeCards} + ${connList} +
+ + `; + } + + private bindEvents() { + this.el.querySelectorAll("[data-action='remove']").forEach((btn) => { + btn.addEventListener("click", (e) => { + const idx = parseInt((e.currentTarget as HTMLElement).dataset.index || "0"); + this.manager.removeShape(idx); + }); + }); + this.el.querySelector("[data-action='commit']")?.addEventListener("click", () => { + this.manager.commitAll(); + this.close(); + }); + this.bindCloseEvent(); + } + + private bindCloseEvent() { + this.el.querySelectorAll("[data-action='close']").forEach((btn) => { + btn.addEventListener("click", () => this.close()); + }); + } + + close() { + this.el.remove(); + } + + private injectStyles() { + if (document.getElementById("mi-triage-styles")) return; + const style = document.createElement("style"); + style.id = "mi-triage-styles"; + style.textContent = ` + .mi-triage-panel { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 420px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; + background: var(--rs-surface, #1e1e2e); + color: var(--rs-text, #cdd6f4); + border: 1px solid var(--rs-border, #45475a); + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + z-index: 10000; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 13px; + animation: mi-triage-in 0.2s ease-out; + } + @keyframes mi-triage-in { + from { opacity: 0; transform: translate(-50%, -48%); } + to { opacity: 1; transform: translate(-50%, -50%); } + } + .mi-triage-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 10px; + border-bottom: 1px solid var(--rs-border, #45475a); + } + .mi-triage-title { + font-weight: 600; + font-size: 14px; + } + .mi-triage-close { + background: none; + border: none; + color: var(--rs-text-muted, #a6adc8); + font-size: 18px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + } + .mi-triage-close:hover { color: var(--rs-text, #cdd6f4); } + .mi-triage-summary { + padding: 10px 16px; + font-size: 12px; + color: var(--rs-text-muted, #a6adc8); + border-bottom: 1px solid var(--rs-border, #45475a); + } + .mi-triage-body { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + } + .mi-triage-loading { + align-items: center; + justify-content: center; + padding: 40px 16px; + } + .mi-triage-spinner { + width: 28px; + height: 28px; + border: 3px solid var(--rs-border, #45475a); + border-top-color: #14b8a6; + border-radius: 50%; + animation: mi-spin 0.8s linear infinite; + margin-bottom: 12px; + } + @keyframes mi-spin { to { transform: rotate(360deg); } } + .mi-triage-error { + align-items: center; + justify-content: center; + padding: 32px 16px; + color: #f38ba8; + } + .mi-triage-card { + background: var(--rs-card-bg, #313244); + border: 1px solid var(--rs-border, #45475a); + border-radius: 10px; + padding: 10px 12px; + } + .mi-triage-card-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + } + .mi-triage-card-icon { font-size: 16px; } + .mi-triage-card-label { + flex: 1; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .mi-triage-card-badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 6px; + background: var(--rs-badge-bg, #45475a); + color: var(--rs-text-muted, #a6adc8); + white-space: nowrap; + } + .mi-triage-card-remove { + background: none; + border: none; + color: var(--rs-text-muted, #a6adc8); + font-size: 16px; + cursor: pointer; + padding: 0 2px; + line-height: 1; + } + .mi-triage-card-remove:hover { color: #f38ba8; } + .mi-triage-card-snippet { + font-size: 11px; + color: var(--rs-text-muted, #a6adc8); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .mi-triage-connections { + padding: 8px 0 0; + border-top: 1px solid var(--rs-border, #45475a); + } + .mi-triage-conn-title { + font-size: 11px; + font-weight: 600; + color: var(--rs-text-muted, #a6adc8); + margin-bottom: 4px; + } + .mi-triage-conn { + font-size: 11px; + padding: 2px 0; + color: var(--rs-text, #cdd6f4); + } + .mi-triage-conn-reason { + margin-left: 6px; + color: var(--rs-text-muted, #a6adc8); + } + .mi-triage-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--rs-border, #45475a); + } + .mi-triage-btn { + padding: 7px 16px; + border: none; + border-radius: 8px; + font-size: 13px; + cursor: pointer; + font-weight: 500; + } + .mi-triage-btn-cancel { + background: var(--rs-card-bg, #313244); + color: var(--rs-text, #cdd6f4); + } + .mi-triage-btn-cancel:hover { background: var(--rs-border, #45475a); } + .mi-triage-btn-commit { + background: #14b8a6; + color: white; + } + .mi-triage-btn-commit:hover { background: #0d9488; } + `; + document.head.appendChild(style); + } +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/server/index.ts b/server/index.ts index e7a0f40..979ff58 100644 --- a/server/index.ts +++ b/server/index.ts @@ -304,6 +304,116 @@ match-width, match-height, match-size.`; } }); +// ── MI Content Triage β€” analyze pasted/dropped content and propose shapes ── + +const TRIAGE_SYSTEM_PROMPT = `You are a content triage engine for rSpace, a spatial canvas platform. +Given raw unstructured content (pasted text, meeting notes, link dumps, etc.), +analyze it and classify each distinct piece into the most appropriate canvas shape type. + +## Shape Mapping Rules +- URLs / links β†’ folk-embed (set url prop) +- Dates / events / schedules β†’ folk-calendar (set title, description props) +- Locations / addresses / places β†’ folk-map (set query prop) +- Action items / TODOs / tasks β†’ folk-workflow-block (set label, blockType:"action" props) +- Social media content / posts β†’ folk-social-post (set content prop) +- Decisions / polls / questions for voting β†’ folk-choice-vote (set question prop) +- Everything else (prose, notes, transcripts, summaries) β†’ folk-markdown (set content prop in markdown format) + +## Output Format +Return a JSON object with: +- "summary": one-sentence overview of the content dump +- "shapes": array of { "tagName": string, "label": string, "props": object, "snippet": string (first ~80 chars of source content) } +- "connections": array of { "fromIndex": number, "toIndex": number, "reason": string } for semantic links between shapes + +## Rules +- Maximum 10 shapes per triage +- Each shape must have a unique "label" (short, descriptive title) +- props must match the shape's expected attributes +- For folk-markdown content, format nicely with headers and bullet points +- For folk-embed, extract the exact URL into props.url +- Identify connections between related items (e.g., a note references an action item, a URL is the source for a summary) +- If the content is too short or trivial for multiple shapes, still return at least one shape`; + +const KNOWN_TRIAGE_SHAPES = new Set([ + "folk-markdown", "folk-embed", "folk-calendar", "folk-map", + "folk-workflow-block", "folk-social-post", "folk-choice-vote", + "folk-prompt", "folk-image-gen", "folk-slide", +]); + +function sanitizeTriageResponse(raw: any): { shapes: any[]; connections: any[]; summary: string } { + const summary = typeof raw.summary === "string" ? raw.summary : "Content analyzed"; + let shapes = Array.isArray(raw.shapes) ? raw.shapes : []; + let connections = Array.isArray(raw.connections) ? raw.connections : []; + + // Validate and cap shapes + shapes = shapes.slice(0, 10).filter((s: any) => { + if (!s.tagName || typeof s.tagName !== "string") return false; + if (!KNOWN_TRIAGE_SHAPES.has(s.tagName)) { + s.tagName = "folk-markdown"; // fallback unknown types to markdown + } + if (!s.label) s.label = "Untitled"; + if (!s.props || typeof s.props !== "object") s.props = {}; + if (!s.snippet) s.snippet = ""; + return true; + }); + + // Validate connections β€” indices must reference valid shapes + connections = connections.filter((c: any) => { + return ( + typeof c.fromIndex === "number" && + typeof c.toIndex === "number" && + c.fromIndex >= 0 && + c.fromIndex < shapes.length && + c.toIndex >= 0 && + c.toIndex < shapes.length && + c.fromIndex !== c.toIndex + ); + }); + + return { shapes, connections, summary }; +} + +app.post("/api/mi/triage", async (c) => { + const { content, contentType = "paste" } = await c.req.json(); + if (!content || typeof content !== "string") { + return c.json({ error: "content required" }, 400); + } + if (!GEMINI_API_KEY) { + return c.json({ error: "GEMINI_API_KEY not configured" }, 503); + } + + // Truncate very long content + const truncated = content.length > 50000; + const trimmed = truncated ? content.slice(0, 50000) : content; + + try { + const { GoogleGenerativeAI } = await import("@google/generative-ai"); + const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { + responseMimeType: "application/json", + } as any, + }); + + const userPrompt = `Analyze the following ${contentType === "drop" ? "dropped" : "pasted"} content and classify each piece into canvas shapes:\n\n---\n${trimmed}\n---${truncated ? "\n\n(Content was truncated at 50k characters)" : ""}`; + + const result = await model.generateContent({ + contents: [{ role: "user", parts: [{ text: userPrompt }] }], + systemInstruction: { role: "user", parts: [{ text: TRIAGE_SYSTEM_PROMPT }] }, + }); + + const text = result.response.text(); + const parsed = JSON.parse(text); + const sanitized = sanitizeTriageResponse(parsed); + + return c.json(sanitized); + } catch (e: any) { + console.error("[mi/triage] Error:", e.message); + return c.json({ error: "Triage analysis failed" }, 502); + } +}); + function generateFallbackResponse( query: string, currentModule: string, @@ -1601,11 +1711,15 @@ function getContentType(path: string): string { return "application/octet-stream"; } -async function serveStatic(path: string): Promise { +async function serveStatic(path: string, url?: URL): Promise { const filePath = resolve(DIST_DIR, path); const file = Bun.file(filePath); if (await file.exists()) { - return new Response(file, { headers: { "Content-Type": getContentType(path) } }); + const headers: Record = { "Content-Type": getContentType(path) }; + if (url?.searchParams.has("v")) { + headers["Cache-Control"] = "public, max-age=31536000, immutable"; + } + return new Response(file, { headers }); } return null; } @@ -1770,7 +1884,7 @@ const server = Bun.serve({ const assetPath = url.pathname.slice(1); // Serve files with extensions directly if (assetPath.includes(".")) { - const staticResponse = await serveStatic(assetPath); + const staticResponse = await serveStatic(assetPath, url); if (staticResponse) return staticResponse; } } diff --git a/website/canvas.html b/website/canvas.html index 29b0716..a8a6cf9 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2062,6 +2062,42 @@
Loading canvas...
+
+
+ πŸ„ + Drop content here for MI triage +
+
+