rspace-online/lib/mi-content-triage.ts

143 lines
3.6 KiB
TypeScript

/**
* 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;
}
}