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:
159
packages/ai/src/modules/copilot/api.ts
Normal file
159
packages/ai/src/modules/copilot/api.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
};
|
||||
87
packages/ai/src/modules/copilot/schema.test.ts
Normal file
87
packages/ai/src/modules/copilot/schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
packages/ai/src/modules/copilot/schema.ts
Normal file
20
packages/ai/src/modules/copilot/schema.ts
Normal 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>;
|
||||
55
packages/ai/src/modules/copilot/system-prompt.test.ts
Normal file
55
packages/ai/src/modules/copilot/system-prompt.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
33
packages/ai/src/modules/copilot/system-prompt.ts
Normal file
33
packages/ai/src/modules/copilot/system-prompt.ts
Normal 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" })}`;
|
||||
}
|
||||
5
packages/ai/src/modules/copilot/types.ts
Normal file
5
packages/ai/src/modules/copilot/types.ts
Normal 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;
|
||||
Reference in New Issue
Block a user