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

@@ -11,7 +11,8 @@
"./pdf/*": "./src/modules/pdf/*.ts",
"./tts/*": "./src/modules/tts/*.ts",
"./stt/*": "./src/modules/stt/*.ts",
"./credits/*": "./src/modules/credits/*.ts"
"./credits/*": "./src/modules/credits/*.ts",
"./copilot/*": "./src/modules/copilot/*.ts"
},
"scripts": {
"clean": "git clean -xdf .cache .turbo dist node_modules",

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;

View File

@@ -0,0 +1,47 @@
import { Hono } from "hono";
import * as z from "zod";
import { getCopilotHistory, streamCopilot } from "@turbostarter/ai/copilot/api";
import { copilotMessageSchema } from "@turbostarter/ai/copilot/schema";
import { enforceAuth, deductCredits, rateLimiter, validate } from "../../../middleware";
import type { User } from "@turbostarter/auth";
const chatIdQuerySchema = z.object({
chatId: z.string(),
});
export const copilotRouter = new Hono<{
Variables: {
user: User;
};
}>()
.get(
"/messages",
enforceAuth,
validate("query", chatIdQuerySchema),
async (c) => {
const { chatId } = c.req.valid("query");
const messages = await getCopilotHistory(chatId);
return c.json(messages);
},
)
.post(
"/",
enforceAuth,
rateLimiter,
validate("json", copilotMessageSchema),
async (c) => {
const input = c.req.valid("json");
// Deduct 1 credit per copilot message
await deductCredits(1, "copilot")(c, async () => { /* noop */ });
return streamCopilot({
...input,
signal: c.req.raw.signal,
userId: c.var.user.id,
});
},
);

View File

@@ -5,6 +5,7 @@ import { getUserCredits } from "@turbostarter/ai/credits/server";
import { enforceAuth } from "../../middleware";
import { chatRouter } from "./chat";
import { copilotRouter } from "./copilot/router";
import { imageRouter } from "./image";
import { pdfRouter } from "./pdf";
import { sttRouter } from "./stt";
@@ -13,6 +14,7 @@ import { ttsRouter } from "./tts";
export const aiRouter = new Hono()
.use(enforceAuth)
.route("/chat", chatRouter)
.route("/copilot", copilotRouter)
.route("/pdf", pdfRouter)
.route("/image", imageRouter)
.route("/tts", ttsRouter)

View File

@@ -0,0 +1,2 @@
ALTER TABLE "chat"."chat" ADD COLUMN "diagram_id" text;--> statement-breakpoint
CREATE INDEX "chat_diagram_id_idx" ON "chat"."chat" USING btree ("diagram_id");

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1771885601062,
"tag": "0001_fuzzy_gorilla_man",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1772245471347,
"tag": "0002_numerous_siren",
"breakpoints": true
}
]
}

View File

@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { integer, jsonb, pgSchema, timestamp, text } from "drizzle-orm/pg-core";
import {
index,
integer,
jsonb,
pgSchema,
timestamp,
text,
} from "drizzle-orm/pg-core";
import { generateId } from "@turbostarter/shared/utils";
@@ -15,17 +22,22 @@ export const messageRoleEnum = schema.enum("role", [
"user",
]);
export const chat = schema.table("chat", {
id: text().primaryKey().notNull().$defaultFn(generateId),
name: text(),
userId: text()
.references(() => user.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
createdAt: timestamp().defaultNow(),
});
export const chat = schema.table(
"chat",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
name: text(),
diagramId: text(),
userId: text()
.references(() => user.id, {
onDelete: "cascade",
onUpdate: "cascade",
})
.notNull(),
createdAt: timestamp().defaultNow(),
},
(t) => [index("chat_diagram_id_idx").on(t.diagramId)],
);
export const message = schema.table("message", {
id: text().primaryKey().notNull().$defaultFn(generateId),