feat: add magic arrow tool to generate AI-powered relations between text shapes

This commit is contained in:
2025-09-10 19:19:04 -03:00
parent 0e6802e141
commit c2c88b6df0
2 changed files with 144 additions and 1 deletions

View File

@@ -0,0 +1,101 @@
import { generateText } from "./processing";
import { KnowledgeGraphManager } from "./knowledge-graph";
import { createAITextResult } from "./ai-shapes";
export function extractTextContentFromShape(editor: any, shape: any): string {
if (!shape) return "";
if (shape.type === "ai-text-result") return shape.props?.content ?? "";
if (shape.type === "ai-image") return shape.props?.extractedText ?? "";
if (shape.type === "text" || shape.type === "note") return shape.props?.text ?? "";
return "";
}
export async function generateRelationText(textA: string, textB: string): Promise<string> {
const userPrompt = [
"Given two short texts, write a brief plain-text relation between them.",
"One line only. No markdown, no lists, no headings.",
"Return only the relation phrase/sentence.",
"",
`Text A: "${textA}"`,
`Text B: "${textB}"`,
].join("\n");
const text = await generateText(userPrompt, 0.4);
return (text ?? "").trim();
}
export function midpointBetweenShapes(editor: any, aId: string, bId: string): { x: number; y: number } {
const aBounds = editor.getShapePageBounds(aId);
const bBounds = editor.getShapePageBounds(bId);
const aShape = editor.getShape(aId);
const bShape = editor.getShape(bId);
const acx = aBounds ? aBounds.x + aBounds.w / 2 : (aShape?.x ?? 0);
const acy = aBounds ? aBounds.y + aBounds.h / 2 : (aShape?.y ?? 0);
const bcx = bBounds ? bBounds.x + bBounds.w / 2 : (bShape?.x ?? 0);
const bcy = bBounds ? bBounds.y + bBounds.h / 2 : (bShape?.y ?? 0);
const midx = (acx + bcx) / 2;
const midy = (acy + bcy) / 2;
const dx = bcx - acx;
const dy = bcy - acy;
const len = Math.hypot(dx, dy) || 1;
// Perpendicular unit vector
const px = -dy / len;
const py = dx / len;
const offset = Math.min(120, Math.max(40, len * 0.12));
return { x: midx + px * offset, y: midy + py * offset };
}
export async function insertRelationBetweenShapes(
editor: any,
aId: string,
bId: string,
relationText: string
) {
const aBounds = editor.getShapePageBounds(aId);
const bBounds = editor.getShapePageBounds(bId);
const aShape = editor.getShape(aId);
const bShape = editor.getShape(bId);
const acx = aBounds ? aBounds.x + aBounds.w / 2 : (aShape?.x ?? 0);
const acy = aBounds ? aBounds.y + aBounds.h / 2 : (aShape?.y ?? 0);
const bcx = bBounds ? bBounds.x + bBounds.w / 2 : (bShape?.x ?? 0);
const bcy = bBounds ? bBounds.y + bBounds.h / 2 : (bShape?.y ?? 0);
const dx = bcx - acx;
const dy = bcy - acy;
const len = Math.hypot(dx, dy) || 1;
const px = -dy / len;
const py = dx / len;
let baseOffset = Math.min(120, Math.max(40, len * 0.12));
let pos = { x: (acx + bcx) / 2 + px * baseOffset, y: (acy + bcy) / 2 + py * baseOffset };
let relationId = "";
editor.batch?.(() => {
relationId = createAITextResult(editor, {
fromShapeId: null,
sourceType: "analysis",
content: relationText.trim(),
x: pos.x,
y: pos.y,
});
// Nudge relation to avoid overlap with A or B (up to 2 passes)
for (let i = 0; i < 2; i++) {
const relBounds = editor.getShapePageBounds(relationId);
if (!relBounds) break;
const overlapsA = aBounds && !(relBounds.x > aBounds.x + aBounds.w || relBounds.x + relBounds.w < aBounds.x || relBounds.y > aBounds.y + aBounds.h || relBounds.y + relBounds.h < aBounds.y);
const overlapsB = bBounds && !(relBounds.x > bBounds.x + bBounds.w || relBounds.x + relBounds.w < bBounds.x || relBounds.y > bBounds.y + bBounds.h || relBounds.y + relBounds.h < bBounds.y);
if (overlapsA || overlapsB) {
baseOffset = Math.min(200, Math.floor(baseOffset * 1.5));
pos = { x: (acx + bcx) / 2 + px * baseOffset, y: (acy + bcy) / 2 + py * baseOffset };
editor.updateShape({ id: relationId, type: "ai-text-result", x: pos.x, y: pos.y });
} else {
break;
}
}
const kg = new KnowledgeGraphManager(editor);
// Both arrows point into the relation shape: a -> relation, b -> relation
kg.createConnection(aId, relationId, "relates_to");
kg.createConnection(bId, relationId, "relates_to");
});
return relationId;
}

View File

@@ -26,9 +26,12 @@ import { AIImageShapeUtil, AITextResultShapeUtil } from "@/lib/tldraw/ai-shapes"
import { createImageShapeFromFile, setupFileDropHandler, generateText } from "@/lib/tldraw/processing"; import { createImageShapeFromFile, setupFileDropHandler, generateText } from "@/lib/tldraw/processing";
import { createAITextResult } from "@/lib/tldraw/ai-shapes"; import { createAITextResult } from "@/lib/tldraw/ai-shapes";
import { KnowledgeGraphManager } from "@/lib/tldraw/knowledge-graph"; import { KnowledgeGraphManager } from "@/lib/tldraw/knowledge-graph";
import { Camera, Sparkles } from "lucide-react"; import { Camera, Sparkles, Link2 } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { extractTextContentFromShape, generateRelationText, insertRelationBetweenShapes } from "@/lib/tldraw/relations";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
export const Route = createFileRoute("/space")({ export const Route = createFileRoute("/space")({
validateSearch: z.object({ validateSearch: z.object({
@@ -152,6 +155,35 @@ function SpaceRoute() {
await createImageShapeFromFile(editor, file, { x: x + w / 2, y: y + h / 2 }); await createImageShapeFromFile(editor, file, { x: x + w / 2, y: y + h / 2 });
}; };
const handleMagicArrow = async () => {
try {
const ids: string[] = editor.getSelectedShapeIds?.() ?? [];
if (ids.length !== 2) {
toast.error("Select exactly two text shapes to use Magic Arrow.");
return;
}
const [aId, bId] = ids;
const a = editor.getShape(aId as any);
const b = editor.getShape(bId as any);
const textA = extractTextContentFromShape(editor, a);
const textB = extractTextContentFromShape(editor, b);
if (!(textA && textB)) {
toast.error("Both selected shapes must contain text.");
return;
}
const toastId = toast.loading("Generating relation...");
const rel = await generateRelationText(textA, textB);
if (!rel) {
toast.error("Failed to generate relation.", { id: toastId });
return;
}
await insertRelationBetweenShapes(editor, aId, bId, rel);
toast.success("Relation added", { id: toastId });
} catch (e) {
toast.error("Magic Arrow failed. Try again.");
}
};
const handleOpenPrompt = () => { const handleOpenPrompt = () => {
setPromptText(""); setPromptText("");
setGenError(null); setGenError(null);
@@ -201,6 +233,15 @@ function SpaceRoute() {
<TooltipContent>Upload Image (OCR)</TooltipContent> <TooltipContent>Upload Image (OCR)</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip>
<TooltipTrigger>
<Button type="button" onClick={handleMagicArrow}>
<Link2 />
</Button>
</TooltipTrigger>
<TooltipContent>Magic Arrow (select two text shapes)</TooltipContent>
</Tooltip>
<Dialog open={promptOpen} onOpenChange={setPromptOpen}> <Dialog open={promptOpen} onOpenChange={setPromptOpen}>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
@@ -263,6 +304,7 @@ function SpaceRoute() {
return ( return (
<div className="mx-4 mt-4" style={{ position: "relative", inset: 0 }}> <div className="mx-4 mt-4" style={{ position: "relative", inset: 0 }}>
<Toaster />
<Tldraw <Tldraw
onMount={(editor) => { onMount={(editor) => {
editor.user.updateUserPreferences({ colorScheme: "dark" }); editor.user.updateUserPreferences({ colorScheme: "dark" });