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:
@@ -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",
|
||||
|
||||
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;
|
||||
47
packages/api/src/modules/ai/copilot/router.ts
Normal file
47
packages/api/src/modules/ai/copilot/router.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
2
packages/db/migrations/0002_numerous_siren.sql
Normal file
2
packages/db/migrations/0002_numerous_siren.sql
Normal 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");
|
||||
2158
packages/db/migrations/meta/0002_snapshot.json
Normal file
2158
packages/db/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user