feat: add MI Content Triage — paste/drop content for AI-powered shape layout

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-05 11:50:44 -08:00
parent 493ae2442c
commit feabf89137
5 changed files with 694 additions and 4 deletions

View File

@ -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";

142
lib/mi-content-triage.ts Normal file
View File

@ -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<string, any>;
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<void> {
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;
}
}

342
lib/mi-triage-panel.ts Normal file
View File

@ -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<string, { icon: string; label: string }> = {
"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 `
<div class="mi-triage-header">
<span class="mi-triage-title">MI Content Triage</span>
</div>
<div class="mi-triage-body mi-triage-loading">
<div class="mi-triage-spinner"></div>
<p>Analyzing content...</p>
</div>
`;
}
private renderError(): string {
return `
<div class="mi-triage-header">
<span class="mi-triage-title">MI Content Triage</span>
<button class="mi-triage-close" data-action="close">&times;</button>
</div>
<div class="mi-triage-body mi-triage-error">
<p>${this.manager.error || "Something went wrong"}</p>
</div>
`;
}
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 `
<div class="mi-triage-card" data-index="${i}">
<div class="mi-triage-card-header">
<span class="mi-triage-card-icon">${info.icon}</span>
<span class="mi-triage-card-label">${escapeHtml(s.label)}</span>
<span class="mi-triage-card-badge">${info.label}</span>
<button class="mi-triage-card-remove" data-action="remove" data-index="${i}">&times;</button>
</div>
<div class="mi-triage-card-snippet">${escapeHtml(s.snippet || "")}</div>
</div>`;
})
.join("");
const connList =
p.connections.length > 0
? `<div class="mi-triage-connections">
<div class="mi-triage-conn-title">Connections</div>
${p.connections
.map((c) => {
const from = p.shapes[c.fromIndex]?.label || `#${c.fromIndex}`;
const to = p.shapes[c.toIndex]?.label || `#${c.toIndex}`;
return `<div class="mi-triage-conn">${escapeHtml(from)}${escapeHtml(to)}<span class="mi-triage-conn-reason">${escapeHtml(c.reason || "")}</span></div>`;
})
.join("")}
</div>`
: "";
return `
<div class="mi-triage-header">
<span class="mi-triage-title">MI Content Triage</span>
<button class="mi-triage-close" data-action="close">&times;</button>
</div>
<div class="mi-triage-summary">${escapeHtml(p.summary)}</div>
<div class="mi-triage-body">
${shapeCards}
${connList}
</div>
<div class="mi-triage-footer">
<button class="mi-triage-btn mi-triage-btn-cancel" data-action="close">Cancel</button>
<button class="mi-triage-btn mi-triage-btn-commit" data-action="commit">Create All (${p.shapes.length} shape${p.shapes.length !== 1 ? "s" : ""})</button>
</div>
`;
}
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

View File

@ -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<Response | null> {
async function serveStatic(path: string, url?: URL): Promise<Response | null> {
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<string, string> = { "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<WSData>({
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;
}
}

View File

@ -2062,6 +2062,42 @@
<div class="canvas-loading__spinner"></div>
<div class="canvas-loading__text">Loading canvas...</div>
</div>
<div id="triage-drop-overlay">
<div class="triage-drop-inner">
<span class="triage-drop-icon">🍄</span>
<span>Drop content here for MI triage</span>
</div>
</div>
<style>
#triage-drop-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(20, 184, 166, 0.08);
border: 3px dashed #14b8a6;
pointer-events: none;
align-items: center;
justify-content: center;
}
#triage-drop-overlay.active {
display: flex;
}
.triage-drop-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #14b8a6;
background: var(--rs-surface, #1e1e2e);
padding: 32px 48px;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.triage-drop-icon { font-size: 36px; }
</style>
<div id="select-rect"></div>
<script type="module">
@ -2112,7 +2148,9 @@
peerIdToColor,
OfflineStore,
MiCanvasBridge,
installSelectionTransforms
installSelectionTransforms,
TriageManager,
MiTriagePanel
} from "@lib";
import { RStackIdentity } from "@shared/components/rstack-identity";
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
@ -3563,6 +3601,58 @@
window.__canvasApi = { newShape, findFreePosition, SHAPE_DEFAULTS, setupShapeEventListeners, sync, canvasContent };
installSelectionTransforms();
// ── MI Content Triage — drag/drop + paste handlers ──
{
const overlay = document.getElementById("triage-drop-overlay");
let dragEnterCount = 0;
function startTriage(text, type) {
const mgr = new TriageManager();
const panel = new MiTriagePanel(mgr);
mgr.analyze(text, type);
}
document.addEventListener("dragenter", (e) => {
if (e.dataTransfer?.types?.includes("text/plain") || e.dataTransfer?.types?.includes("text/uri-list")) {
dragEnterCount++;
overlay.classList.add("active");
}
});
document.addEventListener("dragover", (e) => {
if (overlay.classList.contains("active")) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
});
document.addEventListener("dragleave", (e) => {
dragEnterCount--;
if (dragEnterCount <= 0) {
dragEnterCount = 0;
overlay.classList.remove("active");
}
});
document.addEventListener("drop", (e) => {
dragEnterCount = 0;
overlay.classList.remove("active");
const text = e.dataTransfer?.getData("text/plain") || e.dataTransfer?.getData("text/uri-list") || "";
if (text.trim()) {
e.preventDefault();
startTriage(text, "drop");
}
});
// Paste handler — only when not focused on an input/textarea/contenteditable
document.addEventListener("paste", (e) => {
const el = document.activeElement;
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
const text = e.clipboardData?.getData("text/plain") || "";
if (text.trim() && text.length > 20) {
e.preventDefault();
startTriage(text, "paste");
}
});
}
// Toolbar button handlers — set pending tool for click-to-place
document.getElementById("new-markdown").addEventListener("click", async () => {
const data = await fetchNotesData();