feat: implement Stories 3.4, 3.5, 3.6 — AI proposals, wizard, hover & palette
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
Story 3.4: AI semantic suggestions with accept/reject workflow - ProposalBar overlay with visual diff - Accept/reject flow with graph snapshot restore - useProposalDiff hook for change summary - System prompt scoping for selected elements Story 3.5: New diagram wizard with AI type inference - CreateDiagramDialog with AI type inference (Haiku) - initialDescription prop for chat-first flow - Auto-send on mount with hasSentInitial ref guard - DB migration for diagram description column Story 3.6: Hover affordances and command palette - HoverAffordances toolbar (5 AI actions, debounced) - CommandPalette (Cmd+K) with AI, nav, Go to Node - prefillChat/fitViewRequested/focusNodeId actions - Code review: getNodesBounds, onOpenRightPanel, timer cleanup, test count fix 374 tests passing (251 web + 123 AI). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ export const modelStrategies = customProvider({
|
||||
[Model.GEMINI_2_5_PRO]: cached(google("gemini-2.5-pro")),
|
||||
[Model.GEMINI_2_5_FLASH]: cached(google("gemini-2.5-flash")),
|
||||
[Model.CLAUDE_4_SONNET]: cached(anthropic("claude-sonnet-4-5")),
|
||||
[Model.CLAUDE_HAIKU_4_5]: cached(anthropic("claude-haiku-4-5-latest")),
|
||||
[Model.CLAUDE_3_7_SONNET]: cached(anthropic("claude-3-7-sonnet-latest")),
|
||||
[Model.GROK_4]: cached(xai("grok-4")),
|
||||
[Model.GROK_3]: cached(xai("grok-3-mini-fast")),
|
||||
|
||||
@@ -29,6 +29,7 @@ export const Model = {
|
||||
GEMINI_2_5_PRO: "gemini-2-5-pro",
|
||||
GEMINI_2_5_FLASH: "gemini-2-5-flash",
|
||||
CLAUDE_4_SONNET: "claude-4-sonnet",
|
||||
CLAUDE_HAIKU_4_5: "claude-haiku-4-5",
|
||||
CLAUDE_3_7_SONNET: "claude-3-7-sonnet",
|
||||
GROK_4: "grok-4",
|
||||
GROK_3: "grok-3",
|
||||
|
||||
@@ -237,6 +237,96 @@ describe("buildCopilotSystemPrompt", () => {
|
||||
expect(prompt).toContain("Scoped context");
|
||||
});
|
||||
});
|
||||
|
||||
describe("semantic analysis section", () => {
|
||||
it("should include semantic analysis section for all diagram types", () => {
|
||||
const types: DiagramType[] = [
|
||||
"bpmn",
|
||||
"er",
|
||||
"orgchart",
|
||||
"architecture",
|
||||
"sequence",
|
||||
"flowchart",
|
||||
];
|
||||
|
||||
for (const type of types) {
|
||||
const prompt = buildCopilotSystemPrompt(type);
|
||||
expect(prompt).toContain("## Semantic analysis");
|
||||
expect(prompt).toContain("Note:");
|
||||
expect(prompt).toContain("Consider:");
|
||||
}
|
||||
});
|
||||
|
||||
it("should include BPMN-specific semantic rules", () => {
|
||||
const prompt = buildCopilotSystemPrompt("bpmn");
|
||||
expect(prompt).toContain("error boundaries");
|
||||
expect(prompt).toContain("gateways without corresponding merge");
|
||||
expect(prompt).toContain("inter-pool message flows");
|
||||
expect(prompt).toContain("missing end events");
|
||||
});
|
||||
|
||||
it("should include E-R-specific semantic rules", () => {
|
||||
const prompt = buildCopilotSystemPrompt("er");
|
||||
expect(prompt).toContain("M:N relationships");
|
||||
expect(prompt).toContain("junction/associative table");
|
||||
expect(prompt).toContain("without primary keys");
|
||||
expect(prompt).toContain("circular foreign key");
|
||||
});
|
||||
|
||||
it("should include architecture-specific semantic rules", () => {
|
||||
const prompt = buildCopilotSystemPrompt("architecture");
|
||||
expect(prompt).toContain("single points of failure");
|
||||
expect(prompt).toContain("missing load balancers");
|
||||
});
|
||||
|
||||
it("should include flowchart-specific semantic rules", () => {
|
||||
const prompt = buildCopilotSystemPrompt("flowchart");
|
||||
expect(prompt).toContain("unreachable nodes");
|
||||
expect(prompt).toContain("decisions with single outgoing path");
|
||||
expect(prompt).toContain("missing terminal nodes");
|
||||
});
|
||||
|
||||
it("should include orgchart-specific semantic rules", () => {
|
||||
const prompt = buildCopilotSystemPrompt("orgchart");
|
||||
expect(prompt).toContain("employees without managers");
|
||||
expect(prompt).toContain("span of control");
|
||||
});
|
||||
|
||||
it("should include sequence-specific semantic rules", () => {
|
||||
const prompt = buildCopilotSystemPrompt("sequence");
|
||||
expect(prompt).toContain("messages without return");
|
||||
expect(prompt).toContain("participants with no interactions");
|
||||
});
|
||||
|
||||
it("should instruct non-blocking behavior for semantic issues", () => {
|
||||
const prompt = buildCopilotSystemPrompt("bpmn");
|
||||
expect(prompt).toContain("Do not block diagram generation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("change summary instruction", () => {
|
||||
it("should include change summary section", () => {
|
||||
const prompt = buildCopilotSystemPrompt("bpmn");
|
||||
expect(prompt).toContain("## Change summary");
|
||||
expect(prompt).toContain("**Changes:**");
|
||||
});
|
||||
|
||||
it("should include change summary for all diagram types", () => {
|
||||
const types: DiagramType[] = [
|
||||
"bpmn",
|
||||
"er",
|
||||
"orgchart",
|
||||
"architecture",
|
||||
"sequence",
|
||||
"flowchart",
|
||||
];
|
||||
|
||||
for (const type of types) {
|
||||
const prompt = buildCopilotSystemPrompt(type);
|
||||
expect(prompt).toContain("Change summary");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
interface ContextResult {
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import type { DiagramType, SelectedElement } from "./types";
|
||||
|
||||
const SEMANTIC_RULES: Record<DiagramType, string> = {
|
||||
bpmn: `- Check for processes without error boundaries or exception handling
|
||||
- Check for gateways without corresponding merge/join
|
||||
- Check for pools without inter-pool message flows
|
||||
- Check for missing end events in subprocess branches`,
|
||||
er: `- Check for M:N relationships that may need a junction/associative table
|
||||
- Check for entities without primary keys
|
||||
- Check for potential circular foreign key dependencies
|
||||
- Check for denormalization opportunities or concerns`,
|
||||
orgchart: `- Check for employees without managers (except root)
|
||||
- Check for excessive span of control (>10 direct reports)`,
|
||||
architecture: `- Check for single points of failure
|
||||
- Check for services without database connections when data persistence is expected
|
||||
- Check for missing load balancers in multi-instance deployments`,
|
||||
sequence: `- Check for messages without return responses
|
||||
- Check for participants with no interactions`,
|
||||
flowchart: `- Check for unreachable nodes
|
||||
- Check for decisions with single outgoing path
|
||||
- Check for missing terminal nodes`,
|
||||
};
|
||||
|
||||
const DIAGRAM_DESCRIPTIONS: Record<DiagramType, string> = {
|
||||
bpmn: "BPMN (Business Process Model and Notation) — processes with activities, gateways, events, pools, and lanes",
|
||||
er: "Entity-Relationship — database schemas with entities, attributes, and relationships (1:1, 1:N, M:N)",
|
||||
@@ -50,7 +71,7 @@ const EDGE_TYPE_REFERENCE: Record<DiagramType, string> = {
|
||||
flowchart: `- (default): Flow. Use label for conditions ("Yes", "No")`,
|
||||
};
|
||||
|
||||
const TYPE_INFERENCE_RULES = `If the diagram type is not established, infer from the user's description:
|
||||
export const TYPE_INFERENCE_RULES = `If the diagram type is not established, infer from the user's description:
|
||||
- Business processes, workflows, approvals, order handling → bpmn
|
||||
- Database schemas, tables, entities, data models → er
|
||||
- Team structures, org hierarchies, reporting lines → orgchart
|
||||
@@ -211,5 +232,14 @@ ${graphContext ? `The diagram currently contains:\n\`\`\`json\n${graphContext}\n
|
||||
- Generate IDs as "n1", "n2", ... for nodes and "e1", "e2", ... for edges
|
||||
- Keep text responses concise and diagram-focused
|
||||
- Use markdown formatting (bold, lists, code blocks) — no h1 headings
|
||||
- Today's date is ${date}${buildScopedContextSection(selectedElements, selectedContext)}`;
|
||||
- Today's date is ${date}
|
||||
|
||||
## Change summary
|
||||
When modifying an existing diagram, include a brief change summary in your response before calling the tool. Format: "**Changes:** Adding N nodes, modifying N edges, removing N nodes." This helps users understand what will change before reviewing the visual diff.
|
||||
|
||||
## Semantic analysis
|
||||
After generating or modifying a diagram, briefly note any semantic issues you detect:
|
||||
${SEMANTIC_RULES[diagramType]}
|
||||
Present as helpful inline suggestions using "Note:" or "Consider:" prefix.
|
||||
Do not block diagram generation for semantic issues.${buildScopedContextSection(selectedElements, selectedContext)}`;
|
||||
}
|
||||
|
||||
126
packages/ai/src/modules/copilot/type-inference.test.ts
Normal file
126
packages/ai/src/modules/copilot/type-inference.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock the AI SDK generateObject before importing the module
|
||||
vi.mock("ai", () => ({
|
||||
generateObject: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock model strategies
|
||||
vi.mock("../chat/strategies", () => ({
|
||||
modelStrategies: {
|
||||
languageModel: vi.fn(() => "mock-model"),
|
||||
},
|
||||
}));
|
||||
|
||||
import { generateObject } from "ai";
|
||||
import { inferDiagramType } from "./type-inference";
|
||||
|
||||
const mockGenerateObject = vi.mocked(generateObject);
|
||||
|
||||
describe("inferDiagramType", () => {
|
||||
it("should return the inferred type and confidence for ER description", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "er", confidence: 0.95 },
|
||||
} as never);
|
||||
|
||||
const result = await inferDiagramType("database schema for user management");
|
||||
expect(result.type).toBe("er");
|
||||
expect(result.confidence).toBe(0.95);
|
||||
});
|
||||
|
||||
it("should return the inferred type for BPMN description", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "bpmn", confidence: 0.88 },
|
||||
} as never);
|
||||
|
||||
const result = await inferDiagramType("order approval workflow with manager review");
|
||||
expect(result.type).toBe("bpmn");
|
||||
expect(result.confidence).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should return the inferred type for architecture description", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "architecture", confidence: 0.92 },
|
||||
} as never);
|
||||
|
||||
const result = await inferDiagramType("microservices system design with API gateway");
|
||||
expect(result.type).toBe("architecture");
|
||||
});
|
||||
|
||||
it("should return the inferred type for orgchart description", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "orgchart", confidence: 0.9 },
|
||||
} as never);
|
||||
|
||||
const result = await inferDiagramType("company team structure with reporting lines");
|
||||
expect(result.type).toBe("orgchart");
|
||||
});
|
||||
|
||||
it("should return the inferred type for sequence description", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "sequence", confidence: 0.85 },
|
||||
} as never);
|
||||
|
||||
const result = await inferDiagramType("API call flow between browser and server");
|
||||
expect(result.type).toBe("sequence");
|
||||
});
|
||||
|
||||
it("should return the inferred type for flowchart description", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "flowchart", confidence: 0.87 },
|
||||
} as never);
|
||||
|
||||
const result = await inferDiagramType("decision tree for loan approval");
|
||||
expect(result.type).toBe("flowchart");
|
||||
});
|
||||
|
||||
it("should pass the description in the prompt", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "er", confidence: 0.9 },
|
||||
} as never);
|
||||
|
||||
const description = "user registration database tables";
|
||||
await inferDiagramType(description);
|
||||
|
||||
expect(mockGenerateObject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining(description) as string,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should include type inference rules in the prompt", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "bpmn", confidence: 0.9 },
|
||||
} as never);
|
||||
|
||||
await inferDiagramType("some description");
|
||||
|
||||
expect(mockGenerateObject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: expect.stringContaining("Business processes") as string,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should use a schema that covers all 6 diagram types", async () => {
|
||||
mockGenerateObject.mockResolvedValueOnce({
|
||||
object: { type: "er", confidence: 0.9 },
|
||||
} as never);
|
||||
|
||||
await inferDiagramType("test");
|
||||
|
||||
const call = mockGenerateObject.mock.calls[0]![0] as { schema: { shape: { type: { options: string[] } } } };
|
||||
const typeOptions = call.schema.shape.type.options as string[];
|
||||
expect(typeOptions).toEqual(
|
||||
expect.arrayContaining(["bpmn", "er", "orgchart", "architecture", "sequence", "flowchart"]),
|
||||
);
|
||||
expect(typeOptions).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should propagate errors from generateObject", async () => {
|
||||
mockGenerateObject.mockRejectedValueOnce(new Error("API error"));
|
||||
|
||||
await expect(inferDiagramType("test description")).rejects.toThrow("API error");
|
||||
});
|
||||
});
|
||||
40
packages/ai/src/modules/copilot/type-inference.ts
Normal file
40
packages/ai/src/modules/copilot/type-inference.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
import { modelStrategies } from "../chat/strategies";
|
||||
import { Model } from "../chat/types";
|
||||
|
||||
import type { DiagramType } from "./types";
|
||||
|
||||
import { TYPE_INFERENCE_RULES } from "./system-prompt";
|
||||
|
||||
const typeInferenceSchema = z.object({
|
||||
type: z.enum([
|
||||
"bpmn",
|
||||
"er",
|
||||
"orgchart",
|
||||
"architecture",
|
||||
"sequence",
|
||||
"flowchart",
|
||||
]),
|
||||
confidence: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
export type TypeInferenceResult = z.infer<typeof typeInferenceSchema>;
|
||||
|
||||
const TYPE_INFERENCE_PROMPT = `Classify this description into a diagram type.
|
||||
|
||||
${TYPE_INFERENCE_RULES}
|
||||
|
||||
Return the most likely diagram type and your confidence (0-1).`;
|
||||
|
||||
export async function inferDiagramType(
|
||||
description: string,
|
||||
): Promise<TypeInferenceResult> {
|
||||
const result = await generateObject({
|
||||
model: modelStrategies.languageModel(Model.CLAUDE_HAIKU_4_5),
|
||||
schema: typeInferenceSchema,
|
||||
prompt: `${TYPE_INFERENCE_PROMPT}\n\nDescription: "${description}"`,
|
||||
});
|
||||
return result.object;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as z from "zod";
|
||||
|
||||
import { getCopilotHistory, streamCopilot } from "@turbostarter/ai/copilot/api";
|
||||
import { copilotMessageSchema } from "@turbostarter/ai/copilot/schema";
|
||||
import { inferDiagramType } from "@turbostarter/ai/copilot/type-inference";
|
||||
|
||||
import { enforceAuth, deductCredits, rateLimiter, validate } from "../../../middleware";
|
||||
|
||||
@@ -12,6 +13,10 @@ const chatIdQuerySchema = z.object({
|
||||
chatId: z.string(),
|
||||
});
|
||||
|
||||
const inferTypeSchema = z.object({
|
||||
description: z.string().min(3).max(500),
|
||||
});
|
||||
|
||||
export const copilotRouter = new Hono<{
|
||||
Variables: {
|
||||
user: User;
|
||||
@@ -27,6 +32,17 @@ export const copilotRouter = new Hono<{
|
||||
return c.json(messages);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/infer-type",
|
||||
enforceAuth,
|
||||
rateLimiter,
|
||||
validate("json", inferTypeSchema),
|
||||
async (c) => {
|
||||
const { description } = c.req.valid("json");
|
||||
const result = await inferDiagramType(description);
|
||||
return c.json(result);
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
enforceAuth,
|
||||
|
||||
@@ -24,12 +24,14 @@ export const createDiagramSchema = z.object({
|
||||
"sequence",
|
||||
"flowchart",
|
||||
]),
|
||||
description: z.string().max(500).optional(),
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateDiagramBodySchema = z
|
||||
.object({
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
projectId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
graphData: z
|
||||
@@ -45,6 +47,7 @@ export const updateDiagramBodySchema = z
|
||||
.refine(
|
||||
(data) =>
|
||||
data.title !== undefined ||
|
||||
data.description !== undefined ||
|
||||
data.projectId !== undefined ||
|
||||
data.sortOrder !== undefined ||
|
||||
data.graphData !== undefined,
|
||||
|
||||
1
packages/db/migrations/0003_motionless_peter_parker.sql
Normal file
1
packages/db/migrations/0003_motionless_peter_parker.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "diagram" ADD COLUMN "description" text;
|
||||
2164
packages/db/migrations/meta/0003_snapshot.json
Normal file
2164
packages/db/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1772245471347,
|
||||
"tag": "0002_numerous_siren",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1772312586761,
|
||||
"tag": "0003_motionless_peter_parker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export const diagram = pgTable("diagram", {
|
||||
title: text().notNull(),
|
||||
type: diagramTypeEnum().notNull(),
|
||||
graphData: jsonb().$type<object>().default({}),
|
||||
description: text(),
|
||||
userId: text()
|
||||
.references(() => user.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
|
||||
Reference in New Issue
Block a user