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>
298 lines
17 KiB
Markdown
298 lines
17 KiB
Markdown
# 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)
|