feat: implement Story 3.2 — AI diagram generation from natural language

Add complete AI-powered diagram generation pipeline: natural language input
→ type inference → graph patch generation → validated canvas render with
ELK.js layout animation. Includes adversarial code review fixes for
diagramType enum validation, duplicate ID detection, tool part history
preservation, PATCH error handling, and graphData structural validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 13:34:46 +00:00
parent 26215d9060
commit 6dcb4dcd6f
13 changed files with 1577 additions and 44 deletions

View File

@@ -14,9 +14,25 @@ import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
import { api } from "~/lib/api/client";
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
import { Prose } from "~/modules/common/prose";
import { useGraphStore } from "~/modules/diagram/stores/useGraphStore";
import { flowToGraph } from "~/modules/diagram/lib/graph-converter";
import { useGraphMutation } from "../hooks/useGraphMutation";
import type { DiagramType } from "~/modules/diagram/types/graph";
// Type helper for tool invocation parts from AI SDK
interface ToolPart {
type: string;
toolCallId: string;
state: "input-streaming" | "input-available" | "output-available" | "output-error";
output?: unknown;
input?: unknown;
}
function isGenerateDiagramTool(part: { type: string }): part is ToolPart {
return part.type === "tool-generateDiagram";
}
interface CopilotPanelProps {
diagramId: string;
diagramType: DiagramType;
@@ -28,6 +44,9 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const userScrolledRef = useRef(false);
const appliedToolCallIds = useRef(new Set<string>());
const { applyGraphPatch } = useGraphMutation(diagramId, diagramType);
// Fetch existing chat history on mount (H1 fix)
const { data: initialMessages } = useQuery({
@@ -49,12 +68,28 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
api: api.ai.copilot.$url().toString(),
prepareSendMessagesRequest: ({ messages, id }) => {
const lastMessage = messages.at(-1);
// Serialize current graph state for AI context
const currentNodes = useGraphStore.getState().nodes;
const currentEdges = useGraphStore.getState().edges;
const graphContext =
currentNodes.length > 0
? JSON.stringify(
flowToGraph(currentNodes, currentEdges, {
version: "1",
title: "",
diagramType,
}),
)
: undefined;
return {
body: {
...lastMessage,
chatId: id,
diagramId,
diagramType,
graphContext,
},
};
},
@@ -83,8 +118,45 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
}
}, [initialMessages, messages.length, setMessages]);
// Detect and apply graph patches from tool invocations
useEffect(() => {
for (const message of messages) {
if (message.role !== "assistant") continue;
for (const part of message.parts) {
if (
isGenerateDiagramTool(part) &&
part.state === "output-available" &&
!appliedToolCallIds.current.has(part.toolCallId)
) {
appliedToolCallIds.current.add(part.toolCallId);
const result = part.output as
| { success: true; data: Parameters<typeof applyGraphPatch>[0] }
| { success: false; errors: string[] };
if (result.success) {
applyGraphPatch(result.data);
} else {
toast.error("Diagram generation failed: invalid graph structure");
console.error("[copilot] Graph validation errors:", result.errors);
}
}
}
}
}, [messages, applyGraphPatch]);
const isSubmitting = status === "submitted" || status === "streaming";
// Check if currently generating a diagram (tool call in progress)
const isGeneratingDiagram = useMemo(() => {
const lastMessage = messages.at(-1);
if (!lastMessage || lastMessage.role !== "assistant") return false;
return lastMessage.parts.some(
(p) =>
isGenerateDiagramTool(p) &&
(p.state === "input-streaming" || p.state === "input-available"),
);
}, [messages]);
// Auto-scroll on new content, but pause if user scrolled up
useEffect(() => {
if (userScrolledRef.current) return;
@@ -187,6 +259,14 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
</div>
)}
{/* Diagram generation indicator */}
{isGeneratingDiagram && (
<div className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
<Icons.Loader2 className="size-3 animate-spin" />
Generating diagram...
</div>
)}
{/* Error display */}
{error && (
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -204,7 +284,7 @@ export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your diagram..."
placeholder="Describe what you want to build..."
rows={1}
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 pr-20 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
@@ -252,9 +332,11 @@ function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Icons.Sparkles className="mb-3 size-8 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">AI Copilot</p>
<p className="text-sm font-medium text-muted-foreground">
What are you designing today?
</p>
<p className="mt-1 text-xs text-muted-foreground/60">
Ask questions about your diagram or describe what you want to build
Describe your diagram and I'll generate it for you
</p>
</div>
);
@@ -276,24 +358,36 @@ const UserBubble = memo<{ message: { id: string; parts: Array<{ type: string; te
UserBubble.displayName = "UserBubble";
const AssistantBubble = memo<{
message: { id: string; parts: Array<{ type: string; text?: string }> };
message: { id: string; parts: Array<{ type: string; text?: string; state?: string }> };
isStreaming: boolean;
}>(({ message, isStreaming }) => (
<div className="max-w-[95%]">
<Prose className="text-sm">
{message.parts.map((part, i) =>
part.type === "text" && part.text ? (
<MemoizedMarkdown
key={`${message.id}-${i}`}
content={part.text}
id={`copilot-${message.id}-${i}`}
/>
) : null,
}>(({ message, isStreaming }) => {
const hasToolResult = message.parts.some(
(p) => isGenerateDiagramTool(p) && p.state === "output-available",
);
return (
<div className="max-w-[95%]">
<Prose className="text-sm">
{message.parts.map((part, i) =>
part.type === "text" && part.text ? (
<MemoizedMarkdown
key={`${message.id}-${i}`}
content={part.text}
id={`copilot-${message.id}-${i}`}
/>
) : null,
)}
{isStreaming && message.parts.length === 0 && (
<span className="inline-block size-2 animate-pulse rounded-full bg-muted-foreground/50" />
)}
</Prose>
{hasToolResult && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<Icons.Check className="size-3 text-green-500" />
Diagram updated
</div>
)}
{isStreaming && message.parts.length === 0 && (
<span className="inline-block size-2 animate-pulse rounded-full bg-muted-foreground/50" />
)}
</Prose>
</div>
));
</div>
);
});
AssistantBubble.displayName = "AssistantBubble";

View File

@@ -0,0 +1,93 @@
"use client";
import { useCallback } from "react";
import { toast } from "sonner";
import { api } from "~/lib/api/client";
import { useGraphStore } from "~/modules/diagram/stores/useGraphStore";
import { graphToFlow } from "~/modules/diagram/lib/graph-converter";
import type { DiagramType, GraphData } from "~/modules/diagram/types/graph";
interface GraphPatchData {
meta: {
diagramType: string;
title: string;
version?: string;
layoutDirection?: "DOWN" | "RIGHT" | "LEFT" | "UP";
edgeRouting?: "ORTHOGONAL" | "SPLINES" | "POLYLINE";
};
nodes: GraphData["nodes"];
edges: GraphData["edges"];
pools?: GraphData["pools"];
groups?: GraphData["groups"];
}
export function useGraphMutation(diagramId: string, diagramType: DiagramType) {
const setNodes = useGraphStore((s) => s.setNodes);
const setEdges = useGraphStore((s) => s.setEdges);
const setLayoutDirection = useGraphStore((s) => s.setLayoutDirection);
const setEdgeRouting = useGraphStore((s) => s.setEdgeRouting);
const requestLayout = useGraphStore((s) => s.requestLayout);
const applyGraphPatch = useCallback(
(patch: GraphPatchData) => {
const effectiveDiagramType =
(patch.meta.diagramType as DiagramType) ?? diagramType;
const graphData: GraphData = {
meta: {
version: patch.meta.version ?? "1",
title: patch.meta.title,
diagramType: effectiveDiagramType,
layoutDirection: patch.meta.layoutDirection,
edgeRouting: patch.meta.edgeRouting,
},
nodes: patch.nodes,
edges: patch.edges,
pools: patch.pools,
groups: patch.groups,
};
const { nodes, edges } = graphToFlow(graphData);
setNodes(nodes);
setEdges(edges);
if (graphData.meta?.layoutDirection) {
setLayoutDirection(graphData.meta.layoutDirection);
}
if (graphData.meta?.edgeRouting) {
setEdgeRouting(graphData.meta.edgeRouting);
}
requestLayout();
// Persist graphData to database (fire-and-forget with error reporting)
api.diagrams[":id"]
.$patch({
param: { id: diagramId },
json: { graphData: graphData as unknown as Record<string, unknown> },
})
.then((res) => {
if (!res.ok) {
toast.error("Failed to save diagram — changes may be lost on reload");
}
})
.catch(() => {
toast.error("Failed to save diagram — changes may be lost on reload");
});
},
[
diagramId,
diagramType,
setNodes,
setEdges,
setLayoutDirection,
setEdgeRouting,
requestLayout,
],
);
return { applyGraphPatch };
}

View File

@@ -139,6 +139,15 @@ export function useAutoLayout() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutDirection, edgeRouting]);
// Re-layout on explicit request (e.g., AI-generated graph patch)
const layoutRequestId = useGraphStore((s) => s.layoutRequestId);
useEffect(() => {
if (layoutRequestId > 0 && nodeCount > 0) {
runLayout();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutRequestId]);
// Cleanup worker on unmount
useEffect(() => {
return () => {

View File

@@ -24,6 +24,7 @@ interface GraphState {
isLayouting: boolean;
highlightedNodeId: string | null;
selectedNodeIds: string[];
layoutRequestId: number;
setNodes: (nodes: Node[]) => void;
setEdges: (edges: Edge[]) => void;
onNodesChange: OnNodesChange;
@@ -34,6 +35,7 @@ interface GraphState {
setIsLayouting: (isLayouting: boolean) => void;
setHighlightedNodeId: (id: string | null) => void;
setSelectedNodeIds: (ids: string[]) => void;
requestLayout: () => void;
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
reset: () => void;
}
@@ -49,6 +51,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
isLayouting: false,
highlightedNodeId: null,
selectedNodeIds: [],
layoutRequestId: 0,
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
setEdges: (edges) => set({ edges }),
@@ -71,6 +74,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
setIsLayouting: (isLayouting) => set({ isLayouting }),
setHighlightedNodeId: (highlightedNodeId) => set({ highlightedNodeId }),
setSelectedNodeIds: (selectedNodeIds) => set({ selectedNodeIds }),
requestLayout: () => set((s) => ({ layoutRequestId: s.layoutRequestId + 1 })),
initializeFromGraphData: (nodes, edges) => {
set({ nodes, edges, nodeCount: nodes.length });
@@ -88,6 +92,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
isLayouting: false,
highlightedNodeId: null,
selectedNodeIds: [],
layoutRequestId: 0,
});
},
}));