feat: implement Story 3.1 — chat panel UI with streaming AI responses

Add AI copilot chat panel to the diagram editor with streaming responses,
chat history persistence, and markdown rendering. Includes copilot API route,
diagram-aware system prompt, and schema with 15 passing tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 10:03:43 +00:00
parent 9d13d0f562
commit 26215d9060
20 changed files with 3223 additions and 37 deletions

View File

@@ -0,0 +1,159 @@
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
smoothStream,
streamText,
} from "ai";
import { eq } from "@turbostarter/db";
import { chat, message, part } from "@turbostarter/db/schema/chat";
import { db } from "@turbostarter/db/server";
import { modelStrategies } from "../chat/strategies";
import { Model, Role } from "../chat/types";
import { buildCopilotSystemPrompt } from "./system-prompt";
import type { CopilotMessagePayload } from "./schema";
import type { DiagramType } from "./types";
import type {
InsertChat,
InsertMessage,
InsertPart,
} from "@turbostarter/db/schema/chat";
const DEFAULT_MODEL = Model.CLAUDE_4_SONNET;
const createCopilotChat = async (data: InsertChat) =>
db
.insert(chat)
.values(data)
.onConflictDoUpdate({
target: chat.id,
set: data,
})
.returning();
const createMessage = async (data: InsertMessage) =>
db.insert(message).values(data).onConflictDoUpdate({
target: message.id,
set: data,
});
const createParts = async (data: InsertPart[]) =>
db.insert(part).values(data).onConflictDoNothing();
const getChatMessages = async (chatId: string) =>
db.query["chat.message"].findMany({
where: eq(message.chatId, chatId),
orderBy: (message, { asc }) => [asc(message.createdAt)],
with: {
part: {
orderBy: (part, { asc }) => [asc(part.order)],
},
},
});
const toChatMessage = (msg: Awaited<ReturnType<typeof getChatMessages>>[number]) => ({
id: msg.id,
role: msg.role as "user" | "assistant",
parts: msg.part.map((p) => {
const details = p.details as Record<string, unknown>;
return {
type: "text" as const,
text: (details.text as string) ?? "",
};
}),
});
export const getCopilotHistory = async (chatId: string) => {
const messages = await getChatMessages(chatId);
return messages.map(toChatMessage);
};
export const streamCopilot = async ({
chatId,
diagramId,
diagramType,
userId,
signal,
...msg
}: CopilotMessagePayload & { signal: AbortSignal; userId: string }) => {
// Upsert chat record (linked to diagram)
await createCopilotChat({
id: chatId,
userId,
diagramId,
name: `Copilot — ${diagramType.toUpperCase()}`,
});
// Load existing messages for context
const existingMessages = await getChatMessages(chatId);
// Persist the user message
await createMessage({ id: msg.id, chatId, role: Role.USER });
await createParts(
msg.parts.map(({ type, ...details }, order) => ({
type,
order,
details,
messageId: msg.id,
})),
);
const systemPrompt = buildCopilotSystemPrompt(diagramType as DiagramType);
const stream = createUIMessageStream({
execute: ({ writer }) => {
const result = streamText({
model: modelStrategies.languageModel(DEFAULT_MODEL),
messages: convertToModelMessages([
...existingMessages.map(toChatMessage),
{
id: msg.id,
role: msg.role ?? "user",
parts: msg.parts,
},
]),
system: systemPrompt,
abortSignal: signal,
experimental_transform: smoothStream({
chunking: "word",
delayInMs: 15,
}),
onError: (error) => {
console.error("[copilot]", error);
},
});
void result.consumeStream();
writer.merge(result.toUIMessageStream());
},
onFinish: async ({ responseMessage }) => {
await createMessage({
id: responseMessage.id,
chatId,
role: Role.ASSISTANT,
});
await createParts(
responseMessage.parts.map(({ type, ...details }, order) => ({
type,
details,
messageId: responseMessage.id,
order,
})),
);
},
});
return createUIMessageStreamResponse({
stream,
headers: {
"Content-Type": "application/octet-stream",
"Content-Encoding": "none",
},
});
};

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { copilotMessageSchema } from "./schema";
describe("copilotMessageSchema", () => {
const validPayload = {
id: "msg-123",
chatId: "chat-456",
diagramId: "diagram-789",
diagramType: "bpmn",
parts: [{ type: "text" as const, text: "Add a gateway node" }],
};
it("should accept a valid copilot message", () => {
const result = copilotMessageSchema.safeParse(validPayload);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.role).toBe("user");
}
});
it("should accept all diagram types", () => {
const types = ["bpmn", "er", "orgchart", "architecture", "sequence", "flowchart"];
for (const type of types) {
const result = copilotMessageSchema.safeParse({
...validPayload,
diagramType: type,
});
expect(result.success).toBe(true);
}
});
it("should reject invalid diagram type", () => {
const result = copilotMessageSchema.safeParse({
...validPayload,
diagramType: "invalid",
});
expect(result.success).toBe(false);
});
it("should reject missing diagramId", () => {
const { diagramId: _, ...without } = validPayload;
const result = copilotMessageSchema.safeParse(without);
expect(result.success).toBe(false);
});
it("should reject missing parts", () => {
const { parts: _, ...without } = validPayload;
const result = copilotMessageSchema.safeParse(without);
expect(result.success).toBe(false);
});
it("should reject empty parts text", () => {
const result = copilotMessageSchema.safeParse({
...validPayload,
parts: [{ type: "file", url: "x" }],
});
expect(result.success).toBe(false);
});
it("should default role to user", () => {
const result = copilotMessageSchema.safeParse(validPayload);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.role).toBe("user");
}
});
it("should accept explicit role", () => {
const result = copilotMessageSchema.safeParse({
...validPayload,
role: "assistant",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.role).toBe("assistant");
}
});
it("should reject invalid role", () => {
const result = copilotMessageSchema.safeParse({
...validPayload,
role: "system",
});
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,20 @@
import * as z from "zod";
import { DIAGRAM_TYPES } from "./types";
export const copilotMessageSchema = z.object({
id: z.string(),
chatId: z.string(),
diagramId: z.string(),
diagramType: z.enum(DIAGRAM_TYPES as [string, ...string[]]),
graphContext: z.string().optional(),
parts: z.array(
z.object({
type: z.literal("text"),
text: z.string(),
}),
),
role: z.enum(["user", "assistant"]).optional().default("user"),
});
export type CopilotMessagePayload = z.infer<typeof copilotMessageSchema>;

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { buildCopilotSystemPrompt } from "./system-prompt";
import type { DiagramType } from "./types";
describe("buildCopilotSystemPrompt", () => {
it("should include the diagram type in uppercase", () => {
const prompt = buildCopilotSystemPrompt("bpmn");
expect(prompt).toContain("BPMN");
expect(prompt).toContain("Type: BPMN");
});
it("should include diagram description for each type", () => {
const types: DiagramType[] = [
"bpmn",
"er",
"orgchart",
"architecture",
"sequence",
"flowchart",
];
for (const type of types) {
const prompt = buildCopilotSystemPrompt(type);
expect(prompt).toContain(`Type: ${type.toUpperCase()}`);
expect(prompt).toContain("domaingraph AI copilot");
expect(prompt).toContain("CHAT-ONLY mode");
}
});
it("should mention chat-only constraint", () => {
const prompt = buildCopilotSystemPrompt("er");
expect(prompt).toContain("CHAT-ONLY mode");
expect(prompt).toContain("cannot modify the diagram directly");
});
it("should include today's date", () => {
const prompt = buildCopilotSystemPrompt("flowchart");
const year = new Date().getFullYear().toString();
expect(prompt).toContain(year);
});
it("should include ER-specific description", () => {
const prompt = buildCopilotSystemPrompt("er");
expect(prompt).toContain("Entity-Relationship");
expect(prompt).toContain("entities");
});
it("should include architecture-specific description", () => {
const prompt = buildCopilotSystemPrompt("architecture");
expect(prompt).toContain("services");
expect(prompt).toContain("databases");
});
});

View File

@@ -0,0 +1,33 @@
import type { DiagramType } from "./types";
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)",
orgchart: "Organization Chart — hierarchical structures with people, roles, and reporting lines",
architecture: "Architecture — system components including services, databases, queues, load balancers, and external systems",
sequence: "Sequence — interactions between participants over time with synchronous, asynchronous, and return messages",
flowchart: "Flowchart — decision flows with processes, decisions, terminals, I/O, and subprocesses",
};
export function buildCopilotSystemPrompt(diagramType: DiagramType): string {
const description = DIAGRAM_DESCRIPTIONS[diagramType];
return `You are the domaingraph AI copilot — a diagram design assistant.
## Current diagram
Type: ${diagramType.toUpperCase()}${description}
## Your role
- Help users think through their diagram design
- Explain diagram concepts and best practices for ${diagramType.toUpperCase()} diagrams
- Suggest improvements, missing elements, or structural changes
- Answer questions about the current diagram or diagram type
- Keep responses concise and diagram-focused
## Important constraints
- You are in CHAT-ONLY mode: you can discuss and advise, but you cannot modify the diagram directly yet
- When users ask you to add or change elements, explain what you would do and tell them this capability is coming soon
- Use markdown formatting for clarity (bold, lists, code blocks)
- Do not use h1 headings in responses
- Today's date is ${new Date().toLocaleDateString("en-US", { year: "numeric", month: "short", day: "2-digit", weekday: "short" })}`;
}

View File

@@ -0,0 +1,5 @@
import { diagramTypeEnum } from "@turbostarter/db/schema/diagram";
export type DiagramType = (typeof diagramTypeEnum.enumValues)[number];
export const DIAGRAM_TYPES = diagramTypeEnum.enumValues;