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:
parent
493ae2442c
commit
feabf89137
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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">×</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}">×</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">×</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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
120
server/index.ts
120
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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue