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,297 @@
# Story 3.1: Chat Panel UI with Streaming AI Responses
Status: done
## Story
As a user,
I want a chat panel where I can converse with an AI about my diagram,
So that I can describe what I want and see the AI's responses stream in real-time.
## Acceptance Criteria
1. **Given** I am in the diagram editor, **When** I look at the right panel, **Then** I see a Chat tab (active by default) with a message input area at the bottom, a scrollable message history above, and a mic button placeholder (disabled, for Epic 5).
2. **Given** I type a message and press Enter (or click Send), **When** the message is sent to the AI backend, **Then** my message appears in the chat history immediately **And** the AI response streams token-by-token into a new message bubble **And** first token appears in < 1 second.
3. **Given** the AI is streaming a response, **When** I view the chat panel, **Then** I see a typing indicator while tokens stream **And** the chat auto-scrolls to show the latest content **And** the message progressively renders with markdown formatting.
4. **Given** the AI service is unavailable, **When** I send a message, **Then** I see a user-friendly error message within 5 seconds **And** the chat remains functional for viewing history.
## Tasks / Subtasks
- [x] Task 1: Create diagram copilot API route (AC: 2, 4)
- [x] 1.1 Create `packages/api/src/modules/ai/copilot/router.ts` with POST `/` endpoint
- [x] 1.2 Wire `enforceAuth``validate("json", copilotMessageSchema)` → handler
- [x] 1.3 Create `copilotMessageSchema` in same file or adjacent schema file — extends chat message with `diagramId: z.string()`, `diagramType: z.string()`, optional `graphContext: z.string()`
- [x] 1.4 Handler calls `streamText()` with diagram-specific system prompt, returns `result.toUIMessageStreamResponse()`
- [x] 1.5 Register route in `packages/api/src/modules/ai/router.ts` as `.route("/copilot", copilotRouter)`
- [x] 1.6 Create diagram system prompt in `packages/ai/src/modules/copilot/system-prompt.ts` — instructs AI about diagram types, graph model, and conversation scope
- [x] Task 2: Create ChatPanel component (AC: 1, 2, 3)
- [x] 2.1 Create `apps/web/src/modules/chat/components/ChatPanel.tsx` — main copilot panel
- [x] 2.2 Use AI SDK `useChat` hook with `api` pointing to `/api/ai/copilot` endpoint
- [x] 2.3 Pass `body: { diagramId, diagramType }` to `useChat` for diagram context
- [x] 2.4 Message list: scrollable container, auto-scroll on new content via `useRef` + `scrollIntoView`
- [x] 2.5 Input area: text input with Enter-to-send, Send button, disabled mic button placeholder
- [x] 2.6 Render user messages and assistant messages with parts-based rendering (`message.parts.map(...)`)
- [x] Task 3: Implement streaming UX (AC: 2, 3)
- [x] 3.1 Typing indicator: show animated dots while `status === "submitted"` (waiting for first token)
- [x] 3.2 Streaming indicator: progressive text rendering while `status === "streaming"`
- [x] 3.3 Auto-scroll: scroll to bottom on each streaming update, respect user scroll-up (pause auto-scroll if user scrolled up)
- [x] 3.4 Markdown rendering: render assistant text parts with basic markdown (bold, italic, code blocks, lists) — use a lightweight markdown renderer or dangerouslySetInnerHTML with sanitization
- [x] Task 4: Wire ChatPanel into RightPanel (AC: 1)
- [x] 4.1 Replace the placeholder content in `RightPanel.tsx` Chat tab with `<ChatPanel diagramId={...} diagramType={...} />`
- [x] 4.2 Pass `diagramId` and `diagramType` as props from DiagramEditor → RightPanel → ChatPanel
- [x] 4.3 Ensure Chat tab is active by default (already implemented in RightPanel)
- [x] Task 5: Error handling (AC: 4)
- [x] 5.1 Use `useChat`'s `onError` callback to show toast via sonner
- [x] 5.2 Display inline error message in chat when `error` is set — "Something went wrong. Please try again."
- [x] 5.3 Keep message history visible and input functional during error state
- [x] 5.4 Add Stop button visible during streaming (`status === "submitted" || status === "streaming"`) that calls `stop()`
- [x] Task 6: Persist chat per diagram (AC: 1, 2)
- [x] 6.1 Add `diagramId` column to existing `chat.chat` table (nullable text, indexed) — or create a new `copilot_chat` table in a `copilot` pgSchema
- [x] 6.2 Generate and apply Drizzle migration
- [x] 6.3 On ChatPanel mount: query for existing chat by diagramId, load history if exists
- [x] 6.4 On first message: create chat record linked to diagramId, then stream
- [x] 6.5 Register any new table exports in `packages/db/src/schema/index.ts`
- [x] Task 7: Tests (all ACs)
- [x] 7.1 Unit test copilot message schema validation (valid/invalid payloads)
- [x] 7.2 Unit test system prompt generation (correct diagram type context)
- [ ] 7.3 Unit test ChatPanel message rendering logic — deferred: React component rendering tests go to E2E per project testing standards
- [ ] 7.4 Unit test auto-scroll behavior — deferred: DOM interaction tests go to E2E per project testing standards
- [ ] 7.5 Unit test status-based UI states — deferred: React component rendering tests go to E2E per project testing standards
## Dev Notes
### Existing Infrastructure — DO NOT Reinvent
The TurboStarter template already has a complete generic chat system. **Reuse patterns, do NOT duplicate:**
| What Exists | Location | How to Reuse |
|-------------|----------|-------------|
| Chat DB schema (chat, message, part tables) | `packages/db/src/schema/chat.ts` | Extend with `diagramId` column or create parallel copilot schema |
| Generic chat API (CRUD + streaming) | `packages/api/src/modules/ai/chat.ts` | Reference patterns; copilot gets its OWN route |
| `streamChat()` with AI SDK | `packages/ai/src/modules/chat/api.ts` | Study `createUIMessageStream` + `streamText` + `smoothStream` patterns |
| Chat composer hook | `apps/web/src/modules/chat/composer/hooks/use-composer.tsx` | Study `getChatInstance` + `DefaultChatTransport` pattern |
| Assistant message renderer | `apps/web/src/modules/chat/thread/message/assistant/index.tsx` | Study parts-based rendering with status-driven streaming UX |
| Model definitions (12 models, 5 providers) | `packages/ai/src/modules/chat/constants.ts` | Reuse model registry; copilot picks a default model |
| React Query chat hooks | `apps/web/src/modules/chat/lib/api.ts` | Reference patterns for chat CRUD queries |
| Hono API client setup | `apps/web/src/lib/api/client.tsx` + `server.ts` | Use existing type-safe Hono RPC client |
### AI SDK Latest API (February 2026)
The AI SDK has evolved from its earlier API. Key differences:
| Old API | Current API | Notes |
|---------|-------------|-------|
| `handleSubmit()` | `sendMessage()` | No longer form-bound |
| `input` / `handleInputChange` | Manage your own input state | Use `useState` for input |
| `isLoading` | `status: 'submitted' \| 'streaming' \| 'ready' \| 'error'` | Granular status |
| `message.content` (string) | `message.parts: Part[]` | Parts-based rendering |
| `append()` | `sendMessage({ text })` | Message submission |
**Critical useChat pattern:**
```tsx
const { messages, sendMessage, status, error, stop } = useChat({
api: '/api/ai/copilot', // Custom endpoint
body: { diagramId, diagramType }, // Extra body params sent with every request
onError: (err) => toast.error('Failed to get AI response'),
onFinish: () => { /* invalidate queries if needed */ },
});
```
**Critical streamText server pattern:**
```ts
import { streamText, convertToModelMessages } from 'ai';
const result = streamText({
model: registry.languageModel('openai:gpt-4o'),
system: diagramSystemPrompt,
messages: convertToModelMessages(messages), // Convert UIMessage[] to ModelMessage[]
});
return result.toUIMessageStreamResponse(); // SSE format for useChat
```
### Diagram System Prompt
Create a diagram-aware system prompt that tells the AI:
- It is a diagram design copilot for domaingraph
- The current diagram type (BPMN, E-R, Org Chart, Architecture, Sequence, Flowchart)
- It should discuss diagram design, explain concepts, suggest improvements
- Story 3.1 is **chat-only** — the AI does NOT modify the diagram yet (that's Story 3.2)
- Keep responses concise and diagram-focused
### Component Architecture
```
DiagramEditor
├── DiagramCanvas (left, existing)
└── RightPanel (right, existing)
├── Tab: Chat (active) → <ChatPanel diagramId={id} diagramType={type} />
├── Tab: Inspector (placeholder)
└── Tab: Annotations (placeholder)
ChatPanel
├── MessageList (scrollable)
│ ├── UserMessage (text bubble)
│ └── AssistantMessage (parts-based, streaming)
├── TypingIndicator (shown when status === "submitted")
├── ErrorMessage (shown when error !== undefined)
└── InputArea
├── TextInput (Enter to send)
├── SendButton (disabled when status !== "ready")
├── StopButton (shown during streaming)
└── MicButton (placeholder, disabled)
```
### Prop Threading
The `diagramId` and `diagramType` must flow from the page down to ChatPanel:
1. `page.tsx` fetches diagram data (already does this)
2. `DiagramEditor` receives `diagram` prop (already does this)
3. Pass `diagram.id` and `diagram.diagramType` to `RightPanel`
4. `RightPanel` passes to `ChatPanel`
Check the current `DiagramEditor` component to see how `diagram` prop is structured and what fields are available.
### Styling
- Use Tailwind CSS classes (no CSS modules)
- Use shadcn/ui components from `@turbostarter/ui-web/` for Button, Input, ScrollArea
- Follow existing diagram accent pattern: consider using `--diagram-chat` or a neutral accent for the copilot
- Support light/dark mode via CSS variables (already system-wide via `colorMode="system"`)
- Use `color-mix()` for computed tints following Epic 2 patterns
### Project Structure Notes
**New files to create:**
```
packages/api/src/modules/ai/copilot/
└── router.ts # Copilot streaming endpoint
packages/ai/src/modules/copilot/
└── system-prompt.ts # Diagram system prompt generator
apps/web/src/modules/chat/components/
└── ChatPanel.tsx # Main copilot chat panel
└── ChatPanel.test.ts # Unit tests
└── MessageList.tsx # Scrollable message container (optional split)
└── ChatInput.tsx # Input area with send/mic (optional split)
```
**Existing files to modify:**
```
apps/web/src/modules/diagram/components/editor/RightPanel.tsx # Replace placeholder
apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx # Thread diagramId/type props
packages/api/src/modules/ai/router.ts # Register copilot route
packages/db/src/schema/chat.ts # Add diagramId column (or new copilot schema)
packages/db/src/schema/index.ts # Register new exports if schema changes
```
**Naming conventions:** PascalCase component files in kebab-case dirs. Co-located tests (`ChatPanel.test.ts` beside `ChatPanel.tsx`).
### References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.1]
- [Source: _bmad-output/project-context.md#Critical Implementation Rules]
- [Source: packages/db/src/schema/chat.ts — existing chat DB schema]
- [Source: packages/api/src/modules/ai/chat.ts — existing chat API router]
- [Source: packages/ai/src/modules/chat/api.ts — streamChat() implementation]
- [Source: packages/ai/src/modules/chat/constants.ts — model definitions]
- [Source: apps/web/src/modules/chat/composer/hooks/use-composer.tsx — useChat transport pattern]
- [Source: apps/web/src/modules/chat/thread/message/assistant/index.tsx — streaming message rendering]
- [Source: apps/web/src/modules/diagram/components/editor/RightPanel.tsx — current placeholder]
- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx — existing canvas]
- [Source: AI SDK docs — https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat]
- [Source: AI SDK docs — https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text]
### Previous Story Intelligence (Story 2.9)
- **Pattern**: `useGraphStore.getState()` for accessing store state in callbacks (avoids stale closures)
- **Map over Set+find**: Use `Map` for O(1) lookups when you need both membership test and value retrieval
- **Review themes**: Dead code removal, CSS variable discipline, performance-aware selectors
- **Web test count at end of 2.9**: 186 tests in apps/web
### Testing Standards
- Vitest with `describe`/`it`/`expect` — explicit imports from `vitest`
- Co-located test files: `ChatPanel.test.ts` next to `ChatPanel.tsx`
- Factory functions for test data (e.g., `createTestMessage(overrides)`)
- Test schema validation, prompt generation, UI state logic
- Do NOT test React component rendering (deferred to E2E) — test logic/hooks/utilities
- Run: `pnpm --filter @turbostarter/web test` or `pnpm test`
### Critical Don't-Miss Rules
- **ESM-only**: No `require()`. All `import` statements.
- **Path alias**: `~/` for app-internal imports in apps/web
- **IDs**: `generateId()` from `@turbostarter/shared/utils` — 32-char text columns
- **Validation**: `validate("json", schema)` middleware on Hono — never inline `.parse()`
- **Error handling**: `HttpException(HttpStatusCode.XXX, {...})` — never raw status numbers
- **No business logic in routers**: Handler delegates to domain function
- **Workspace imports**: Must match `exports` field exactly (e.g., `@turbostarter/ai/credits/server`)
- **pgSchema**: If creating new schema, wrap with `prefix()` in `schema/index.ts`
- **Conventional commits**: `feat: implement chat panel UI with streaming AI responses`
- **Soft-delete guards**: `isNull(deletedAt)` on every query touching soft-deleted tables
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6
### Debug Log References
- Fixed `useChat` `body` option — not a top-level option in latest AI SDK; used `prepareSendMessagesRequest` in `DefaultChatTransport` instead
- Fixed `toChatMessage` type error — DB `text()` column typed as `string` but `UIMessagePart` needs literal `"text"` type; used `"text" as const`
### Code Review Fixes Applied
- **H1**: Added GET `/messages` endpoint to copilot router + `getCopilotHistory` in api.ts; CopilotPanel fetches history on mount via `useQuery` and seeds via `setMessages`
- **H2**: Tasks 7.3-7.5 marked as deferred (not [x]) with explanation notes
- **M1**: Removed unused `generateId` import from CopilotPanel
- **M2**: Wrapped `DefaultChatTransport` in `useMemo` to prevent recreation on every render
- **M3**: Added `graphContext: z.string().optional()` to copilot message schema
- **M4**: Rewrote `types.ts` to derive `DiagramType` from DB `diagramTypeEnum` (single source of truth)
- **Cascading fix**: Changed PDF module `Chat` imports from `@turbostarter/ai/chat/types` to `SelectPdfChat` from `@turbostarter/db/schema/pdf` (type broke when `diagramId` was added to `chat.chat`)
### Completion Notes List
- Copilot API route created at `/api/ai/copilot` with own system prompt, schema, and streaming
- Reuses existing `chat.chat`, `chat.message`, `chat.part` DB tables (no new schema)
- Added `diagramId` column (nullable, indexed) to `chat.chat` table with migration
- CopilotPanel uses `useChat` directly (simpler than `useComposer` hook)
- Chat history persists and loads on page refresh via GET `/messages` endpoint
- Auto-scroll with user-scroll-pause detection
- Typing indicator, error display, stop button, markdown rendering all functional
- 15 copilot tests (9 schema + 6 system prompt) passing
- 186 web tests passing (unchanged)
- Task 7.3-7.5 (component rendering tests) deferred per testing standards: "Do NOT test React component rendering — deferred to E2E"
### File List
**Created:**
- `packages/ai/src/modules/copilot/types.ts`
- `packages/ai/src/modules/copilot/schema.ts`
- `packages/ai/src/modules/copilot/schema.test.ts`
- `packages/ai/src/modules/copilot/system-prompt.ts`
- `packages/ai/src/modules/copilot/system-prompt.test.ts`
- `packages/ai/src/modules/copilot/api.ts`
- `packages/api/src/modules/ai/copilot/router.ts`
- `apps/web/src/modules/copilot/components/CopilotPanel.tsx`
- `packages/db/migrations/0002_numerous_siren.sql`
**Modified:**
- `packages/ai/package.json` — added `./copilot/*` export
- `packages/api/src/modules/ai/router.ts` — registered copilot route
- `packages/db/src/schema/chat.ts` — added `diagramId` column + index
- `apps/web/src/modules/diagram/components/editor/RightPanel.tsx` — wired CopilotPanel
- `apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx` — passed diagramId/diagramType props
- `apps/web/src/modules/pdf/components/recent-chats.tsx` — fixed Chat type import (cascading fix)
- `apps/web/src/modules/pdf/history/list/item.tsx` — fixed Chat type import (cascading fix)

View File

@@ -41,15 +41,15 @@ story_location: "{project-root}/_bmad-output/implementation-artifacts"
development_status:
# ── Epic 1: Workspace & Diagram Management (Phase 1 - Foundation) ──
epic-1: in-progress
epic-1: done
1-1-create-and-view-diagrams: done
1-2-organize-diagrams-into-projects: done
1-3-diagram-access-control-and-management: done
1-4-recent-view-and-drag-and-drop-organization: done
epic-1-retrospective: optional
epic-1-retrospective: done
# ── Epic 2: Interactive Canvas & Diagram Types (Phase 2) ──
epic-2: in-progress
epic-2: done
2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: done
2-2-elk-js-auto-layout-engine-in-web-worker: done
2-3-bpmn-diagram-type-renderer: done
@@ -59,11 +59,11 @@ development_status:
2-7-sequence-diagram-type-renderer: done
2-8-flowchart-diagram-type-renderer: done
2-9-node-selection-and-manual-repositioning: done
epic-2-retrospective: optional
epic-2-retrospective: done
# ── Epic 3: AI Copilot & Chat (Phase 2) ──
epic-3: backlog
3-1-chat-panel-ui-with-streaming-ai-responses: backlog
epic-3: in-progress
3-1-chat-panel-ui-with-streaming-ai-responses: done
3-2-ai-diagram-generation-from-natural-language: backlog
3-3-badge-based-element-referencing-for-targeted-modifications: backlog
3-4-ai-semantic-suggestions-and-accept-reject-workflow: backlog

View File

@@ -0,0 +1,299 @@
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
import { api } from "~/lib/api/client";
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
import { Prose } from "~/modules/common/prose";
import type { DiagramType } from "~/modules/diagram/types/graph";
interface CopilotPanelProps {
diagramId: string;
diagramType: DiagramType;
}
export function CopilotPanel({ diagramId, diagramType }: CopilotPanelProps) {
const chatId = useMemo(() => `copilot-${diagramId}`, [diagramId]);
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const userScrolledRef = useRef(false);
// Fetch existing chat history on mount (H1 fix)
const { data: initialMessages } = useQuery({
queryKey: ["copilot", "messages", chatId],
queryFn: async () => {
const res = await api.ai.copilot.messages.$get({
query: { chatId },
});
if (!res.ok) return [];
return res.json();
},
staleTime: Infinity,
});
// Memoize transport to avoid recreation on every render (M2 fix)
const transport = useMemo(
() =>
new DefaultChatTransport({
api: api.ai.copilot.$url().toString(),
prepareSendMessagesRequest: ({ messages, id }) => {
const lastMessage = messages.at(-1);
return {
body: {
...lastMessage,
chatId: id,
diagramId,
diagramType,
},
};
},
}),
[diagramId, diagramType],
);
const { messages, sendMessage, status, error, stop, setMessages } = useChat({
id: chatId,
transport,
onError: (err) => {
console.error("[copilot]", err);
toast.error("Failed to get AI response");
},
});
// Seed chat with persisted history once loaded
useEffect(() => {
if (initialMessages && initialMessages.length > 0 && messages.length === 0) {
setMessages(
initialMessages.map((m) => ({
...m,
createdAt: new Date(),
})),
);
}
}, [initialMessages, messages.length, setMessages]);
const isSubmitting = status === "submitted" || status === "streaming";
// Auto-scroll on new content, but pause if user scrolled up
useEffect(() => {
if (userScrolledRef.current) return;
const viewport = scrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]",
);
if (viewport) {
viewport.scrollTop = viewport.scrollHeight;
}
}, [messages]);
// Detect user scroll
useEffect(() => {
const viewport = scrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]",
);
if (!viewport) return;
let timeoutId: NodeJS.Timeout;
const handleScroll = () => {
const isAtBottom =
Math.abs(
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight,
) < 80;
userScrolledRef.current = !isAtBottom;
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
userScrolledRef.current = false;
}, 2000);
};
viewport.addEventListener("scroll", handleScroll);
return () => {
viewport.removeEventListener("scroll", handleScroll);
clearTimeout(timeoutId);
};
}, []);
const handleSend = useCallback(() => {
const text = input.trim();
if (!text || isSubmitting) return;
void sendMessage({
text,
metadata: {},
});
setInput("");
userScrolledRef.current = false;
}, [input, isSubmitting, sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
return (
<div className="flex h-full flex-col">
{/* Messages area */}
<ScrollArea ref={scrollRef} className="flex-1">
<div className="flex flex-col gap-1 p-3">
{messages.length === 0 && (
<EmptyState />
)}
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex flex-col gap-1",
message.role === "user" ? "items-end" : "items-start",
)}
>
{message.role === "user" ? (
<UserBubble message={message} />
) : (
<AssistantBubble
message={message}
isStreaming={
status === "streaming" &&
message.id === messages.at(-1)?.id
}
/>
)}
</div>
))}
{/* Typing indicator */}
{status === "submitted" && (
<div className="flex items-start gap-2 py-2">
<div className="flex gap-1">
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:0ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:150ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce [animation-delay:300ms]" />
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Something went wrong. Please try again.
</div>
)}
</div>
</ScrollArea>
{/* Input area */}
<div className="shrink-0 border-t border-border p-3">
<div className="relative">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about your diagram..."
rows={1}
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 pr-20 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-1.5 bottom-1.5 flex items-center gap-1">
{/* Mic button placeholder (Epic 5) */}
<Button
size="icon"
variant="ghost"
className="size-7"
disabled
title="Voice input (coming soon)"
>
<Icons.Mic className="size-3.5 text-muted-foreground" />
</Button>
{/* Send / Stop button */}
{isSubmitting ? (
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={() => stop()}
>
<Icons.Square className="size-3 fill-current" />
</Button>
) : (
<Button
size="icon"
variant="ghost"
className="size-7"
disabled={!input.trim()}
onClick={handleSend}
>
<Icons.ArrowUp className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
</div>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Icons.Sparkles className="mb-3 size-8 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">AI Copilot</p>
<p className="mt-1 text-xs text-muted-foreground/60">
Ask questions about your diagram or describe what you want to build
</p>
</div>
);
}
const UserBubble = memo<{ message: { id: string; parts: Array<{ type: string; text?: string }> } }>(
({ message }) => (
<div className="max-w-[85%] rounded-2xl rounded-br-sm bg-primary px-3 py-2 text-primary-foreground">
{message.parts.map((part, i) =>
part.type === "text" ? (
<p key={`${message.id}-${i}`} className="text-sm whitespace-pre-wrap">
{part.text}
</p>
) : null,
)}
</div>
),
);
UserBubble.displayName = "UserBubble";
const AssistantBubble = memo<{
message: { id: string; parts: Array<{ type: string; text?: string }> };
isStreaming: boolean;
}>(({ message, isStreaming }) => (
<div className="max-w-[95%]">
<Prose className="text-sm">
{message.parts.map((part, i) =>
part.type === "text" && part.text ? (
<MemoizedMarkdown
key={`${message.id}-${i}`}
content={part.text}
id={`copilot-${message.id}-${i}`}
/>
) : null,
)}
{isStreaming && message.parts.length === 0 && (
<span className="inline-block size-2 animate-pulse rounded-full bg-muted-foreground/50" />
)}
</Prose>
</div>
));
AssistantBubble.displayName = "AssistantBubble";

View File

@@ -131,7 +131,11 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
</div>
{/* Right panel */}
<RightPanel open={rightPanelOpen} />
<RightPanel
open={rightPanelOpen}
diagramId={diagram.id}
diagramType={diagram.type as DiagramType}
/>
</div>
<EditorStatusBar diagramType={diagram.type as DiagramType} />

View File

@@ -3,6 +3,10 @@
import { useState } from "react";
import { Icons } from "@turbostarter/ui-web/icons";
import { CopilotPanel } from "~/modules/copilot/components/CopilotPanel";
import type { DiagramType } from "../../types/graph";
type Tab = "chat" | "inspector" | "annotations";
const tabs: { key: Tab; label: string }[] = [
@@ -13,9 +17,11 @@ const tabs: { key: Tab; label: string }[] = [
interface RightPanelProps {
open: boolean;
diagramId: string;
diagramType: DiagramType;
}
export function RightPanel({ open }: RightPanelProps) {
export function RightPanel({ open, diagramId, diagramType }: RightPanelProps) {
const [activeTab, setActiveTab] = useState<Tab>("chat");
return (
@@ -43,20 +49,12 @@ export function RightPanel({ open }: RightPanelProps) {
</div>
{/* Tab content */}
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
<div className="flex flex-1 flex-col overflow-hidden">
{activeTab === "chat" && (
<>
<Icons.Sparkles className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
AI Copilot
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
Start a conversation to build your diagram
</p>
</>
<CopilotPanel diagramId={diagramId} diagramType={diagramType} />
)}
{activeTab === "inspector" && (
<>
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
<Icons.Search className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
Inspector
@@ -64,10 +62,10 @@ export function RightPanel({ open }: RightPanelProps) {
<p className="text-xs text-muted-foreground/60 mt-1">
Select a node to see its properties
</p>
</>
</div>
)}
{activeTab === "annotations" && (
<>
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
<Icons.MessageSquare className="h-8 w-8 text-muted-foreground/30 mb-3" />
<p className="text-sm font-medium text-muted-foreground">
Annotations
@@ -75,7 +73,7 @@ export function RightPanel({ open }: RightPanelProps) {
<p className="text-xs text-muted-foreground/60 mt-1">
Coming soon
</p>
</>
</div>
)}
</div>
</div>

View File

@@ -16,7 +16,7 @@ import { TurboLink } from "~/modules/common/turbo-link";
import { pdf } from "../lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
import type { SelectPdfChat as Chat } from "@turbostarter/db/schema/pdf";
dayjs.extend(relativeTime);

View File

@@ -22,7 +22,7 @@ import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import { pdf } from "~/modules/pdf/lib/api";
import type { Chat } from "@turbostarter/ai/chat/types";
import type { SelectPdfChat as Chat } from "@turbostarter/db/schema/pdf";
interface ChatHistoryListItemProps {
readonly chat: Chat;

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,9 +22,12 @@ export const messageRoleEnum = schema.enum("role", [
"user",
]);
export const chat = schema.table("chat", {
export const chat = schema.table(
"chat",
{
id: text().primaryKey().notNull().$defaultFn(generateId),
name: text(),
diagramId: text(),
userId: text()
.references(() => user.id, {
onDelete: "cascade",
@@ -25,7 +35,9 @@ export const chat = schema.table("chat", {
})
.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),