From 7e0172efa6d32d29c8d76cfdcf999e8d0959c23f Mon Sep 17 00:00:00 2001 From: Max P Date: Tue, 24 Feb 2026 12:50:40 -0500 Subject: [PATCH] docs: add persistent chat widget implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Database schema with ChatConversation + ChatMessage models - Transition contract for backward-compatible chat API - Redis lifecycle mapping and rehydration strategy - Security test matrix and performance SLOs - Rate limiting and failure mode specifications - 3-phase implementation (backend → service → UI) - Estimated timeline: 5 weeks --- .../plans/persistent-chat-widget-history.md | 601 ++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 thoughts/shared/plans/persistent-chat-widget-history.md diff --git a/thoughts/shared/plans/persistent-chat-widget-history.md b/thoughts/shared/plans/persistent-chat-widget-history.md new file mode 100644 index 000000000..2fef444c3 --- /dev/null +++ b/thoughts/shared/plans/persistent-chat-widget-history.md @@ -0,0 +1,601 @@ +# Persistent Chat Widget with History Sidebar - Implementation Plan + +## Problem Statement + +Current AI chat implementation loses conversation history on page refresh and is only accessible from the portfolio/analysis page. Users need: +1. Persistent conversation history across sessions +2. Ability to switch between past conversations +3. Global access from any page (floating widget) +4. Similar UX to OpenAI's ChatGPT interface + +## Current Architecture + +**Frontend:** `apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/` +- Embedded in portfolio analysis page only +- Component state for messages (lost on refresh) +- localStorage: `gf_ai_chat_messages`, `gf_ai_chat_session_id` +- Max 200 messages in localStorage (recently added) + +**Backend:** `apps/api/src/app/endpoints/ai/ai.controller.ts` +- `POST /ai/chat` - single conversation endpoint +- `POST /ai/chat/feedback` - feedback submission +- Redis memory: 10 turns, 24h TTL per session + +**Limitations:** +- No cross-session persistence +- No database storage +- Single conversation only +- Page-scoped (not global) + +## Solution Architecture + +### Ticket-Ready Deliverables (Must Close Before Build) + +- [ ] Define `POST /ai/chat` transition contract: request/response schema, compatibility window, and deprecation path. +- [ ] Harden Prisma design: role enum, composite conversation list index, and conversation-to-memory session mapping field. +- [ ] Define Redis lifecycle policy: keying, TTL behavior, memory rehydration behavior, and delete semantics. +- [ ] Add security test matrix: cross-user conversation access, ownership checks, and expected status codes (`403`/`404`). +- [ ] Define measurable performance SLOs and regression gates (p95 latency + pagination/query budgets). +- [ ] Define failure-mode behavior for DB/Redis outages, retry policy, and UI degraded-state signaling. +- [ ] Define API rate limits and `429` response contract for chat and conversation endpoints. +- [ ] Define search implementation details (fields, indexing strategy, query behavior). +- [ ] Define responsive UX specifications (desktop/tablet/mobile layout and collision behavior). + +### Phase 1: Database Schema & Backend + +#### 1.1 Prisma Schema Extensions + +```prisma +// prisma/schema.prisma + +model ChatConversation { + id String @id @default(cuid()) + userId String + memorySessionId String @unique + title String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages ChatMessage[] + user User @relation(fields: [userId], references: [id]) + + @@index([userId, updatedAt]) + @@index([userId, createdAt]) +} + +model ChatMessage { + id String @id @default(cuid()) + conversationId String + role ChatMessageRole + content String @db.Text + metadata Json? // Store response data (toolCalls, verification, etc.) + createdAt DateTime @default(now()) + conversation ChatConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + + @@index([conversationId, createdAt]) +} + +enum ChatMessageRole { + USER + ASSISTANT +} +``` + +#### 1.2 Backend Changes + +**New DTOs:** `apps/api/src/app/endpoints/ai/ai-chat-conversation.dto.ts` +```typescript +export class CreateConversationDto { + title?: string; +} + +export class UpdateConversationDto { + title?: string; +} + +export class ListConversationsQueryDto { + limit?: number = 20; + offset?: number = 0; + search?: string; +} +``` + +**New Service:** `apps/api/src/app/endpoints/ai/ai-chat-conversation.service.ts` +- `createConversation(userId, title?)` +- `listConversations(userId, pagination)` +- `getConversation(id, userId)` +- `listMessages(conversationId, userId, pagination)` +- `deleteConversation(id, userId)` +- `updateConversation(id, data, userId)` + +**Updated Controller:** `apps/api/src/app/endpoints/ai/ai.controller.ts` +```typescript +@Post('conversations') +createConversation(@Body() dto: CreateConversationDto) + +@Get('conversations') +listConversations(@Query() query: ListConversationsQueryDto) + +@Get('conversations/:id') +getConversation(@Param('id') id: string) + +@Get('conversations/:id/messages') +listMessages(@Param('id') id: string, @Query() query: ListMessagesQueryDto) + +@Delete('conversations/:id') +deleteConversation(@Param('id') id: string) + +@Patch('conversations/:id') +updateConversation(@Param('id') id: string, @Body() dto: UpdateConversationDto) +``` + +**Transition Contract (Backward-Compatible):** +```typescript +// Existing endpoint stays active during migration window +@Post('chat') +chat(@Body() dto: AiChatDto & { conversationId?: string }) +``` + +- Request accepts both `conversationId` and legacy `sessionId`. +- Resolution order: `conversationId` first, `sessionId` fallback, then create new conversation. +- Response includes `conversationId` and keeps `memory.sessionId` for compatibility with existing clients. +- Deprecation: `sessionId` request usage marked deprecated after widget rollout + one release cycle. + +**Updated AI Service:** `apps/api/src/app/endpoints/ai/ai.service.ts` +- Resolve active conversation using transition contract (`conversationId` / `sessionId`) +- Auto-create conversation on first message if no conversation mapping exists +- Save all user/assistant messages to database after response completes +- Load recent conversation messages when resuming and rehydrate memory when Redis session expires +- Keep Redis memory isolated by user + memorySessionId to support safe conversation switching + +#### 1.3 Redis Lifecycle Mapping + +- Memory key format remains: `ai-agent-memory-${userId}-${memorySessionId}` +- `ChatConversation.memorySessionId` is the canonical mapping from conversation to Redis memory. +- Conversation delete flow removes: + - `ChatConversation` row (cascade `ChatMessage`) + - Redis key for `memorySessionId` +- Expired Redis memory triggers rehydration from last `N` persisted messages for that conversation. +- Redis TTL remains 24h for memory state; persisted DB history remains source of truth. + +#### 1.4 Error Handling and Degraded Modes + +- **DB unavailable on `POST /ai/chat`:** + - Continue assistant response using Redis memory when available. + - Return `historyPersistence: 'degraded'` in response metadata. + - Queue unsaved message pair for retry with exponential backoff (`1s, 2s, 4s, 8s, 16s`, max 5 attempts). + - Client shows non-blocking banner: `History sync delayed. Retrying.` +- **Redis unavailable on `POST /ai/chat`:** + - Rebuild prompt context from last 10 persisted conversation messages in DB. + - Return `memorySource: 'database_fallback'` in response metadata. +- **DB and Redis unavailable:** + - Return `503 Service Unavailable` with `Retry-After`. + - Keep current input in client draft box for user retry. +- **Network timeout from client to API:** + - Show pending state (`Sending...`) for request timeout window. + - Retry once automatically in background, then surface actionable error. + +#### 1.5 Rate Limiting + +- `POST /ai/conversations`: max **50 conversations per user/day**. +- `POST /ai/chat`: max **100 messages per conversation/hour** and **300 messages per user/hour**. +- `POST /ai/chat/feedback`: max **30 feedback events per user/hour**. +- Enforce at API layer and return `429` with payload: + - `code: 'RATE_LIMIT_EXCEEDED'` + - `scope: 'conversation' | 'user' | 'feedback'` + - `retryAfterSeconds: number` + +### Phase 2: Frontend Service + +#### 2.1 New AI Chat Service + +**File:** `apps/client/src/app/services/ai-chat.service.ts` + +```typescript +@Injectable({ providedIn: 'root' }) +export class AiChatService { + private currentConversation$ = new BehaviorSubject(null); + private conversations$ = new BehaviorSubject([]); + + // Conversation CRUD + createConversation(title?: string): Observable + loadConversations(pagination?: ListConversationsQuery): Observable + loadConversation(id: string): Observable + deleteConversation(id: string): Observable + updateConversation(id: string, data: UpdateConversationDto): Observable + + // Messages + sendMessage(conversationId: string, query: string): Observable + loadMessages(conversationId: string): Observable + + // State + getCurrentConversation(): Observable + getConversations(): Observable +} +``` + +#### 2.2 Enhanced localStorage Structure + +```typescript +interface ChatStorageState { + currentConversationId: string; + conversations: Array<{ + id: string; + title?: string; + lastUpdated: Date; + messageCount: number; + }>; +} +``` + +#### 2.3 Search Specification + +- Search scope: conversation `title` + rolling preview text built from the first 3 user messages. +- Data model support: + - Add `searchText` field on `ChatConversation`. + - Update `searchText` when title changes or one of first 3 user messages changes. +- Query strategy: + - PostgreSQL full-text search: `to_tsvector('english', searchText)` + `plainto_tsquery`. + - Add `GIN` index via SQL migration for `searchText` vector expression. +- API behavior: + - Debounce 300ms on client before query. + - Return top 20 matches ordered by rank, then by `updatedAt DESC`. + +### Phase 3: UI Components + +#### 3.1 Global Chat Widget + +**Location:** `apps/client/src/app/components/global/ai-chat-widget/` + +**Files:** +- `ai-chat-widget.component.ts` - Main widget controller +- `ai-chat-widget.component.scss` - Fixed positioning styles +- `ai-chat-widget.component.html` - Template + +**Key Features:** +```typescript +@Component({ + selector: 'gf-ai-chat-widget', + template: ` + + +
+ + + +
+ `, + styles: [` + .chat-toggle { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 1000; + } + .chat-container { + position: fixed; + bottom: 80px; + right: 24px; + width: 800px; + height: 600px; + z-index: 1000; + } + `] +}) +export class GfAiChatWidgetComponent { + isOpen = false; + conversations$ = this.aiChatService.getConversations(); + // ... +} +``` + +#### 3.2 Chat History Sidebar + +**Location:** `apps/client/src/app/components/global/ai-chat-widget/ai-chat-sidebar/` + +**Files:** +- `ai-chat-sidebar.component.ts` +- `ai-chat-sidebar.component.scss` +- `ai-chat-sidebar.component.html` + +**Features:** +- List of conversations with titles +- Search/filter +- New chat button +- Delete conversation option +- Auto-generate titles from first message + +#### 3.3 Responsive UX Specification + +- **Desktop (`>= 1200px`)**: + - Floating widget anchored bottom-right. + - Container target width `800px`, height `600px`. + - Sidebar default open. +- **Tablet (`768px - 1199px`)**: + - Floating widget anchored bottom-right. + - Container target width `600px`, height `70vh`. + - Sidebar collapsible and closed by default. +- **Mobile (`< 768px`)**: + - Full-screen modal overlay. + - Slide-out sidebar with explicit back button. + - Input composer fixed to bottom safe area. +- **Collision behavior**: + - Widget offsets from existing floating action buttons. + - Highest overlay z-index in app shell layer. + - Escape key and backdrop click close behavior defined consistently. + +### Phase 4: Migration & Integration + +#### 4.1 Refactor Existing Chat Panel + +**Current:** `apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts` + +**Changes:** +- Remove localStorage logic (move to service) +- Use `AiChatService` for all operations +- Keep as embedded option in portfolio page +- Share service with global widget + +#### 4.2 Global Integration + +**File:** `apps/client/src/app/app.component.ts` + +```typescript +@Component({ + selector: 'gf-root', + template: ` + + + ` +}) +export class AppComponent {} +``` + +#### 4.3 Permission & Feature Flag + +**Service:** Check user permissions before showing widget +```typescript +if (this.user.hasPermissionToReadAiPrompt) { + showChatWidget = true; +} +``` + +## Implementation Order + +### Week 0: Contract + Safety Baseline +1. Finalize `POST /ai/chat` compatibility contract (`conversationId`, legacy `sessionId`, response fields). +2. Finalize Redis lifecycle mapping and delete semantics. +3. Finalize Prisma enums/indexes and migration plan. +4. Finalize degraded-mode behavior for DB/Redis/network failure paths. +5. Finalize API rate-limit policy and `429` response payload contract. +6. Finalize search implementation spec and indexing plan. +7. Finalize responsive UX specs for desktop/tablet/mobile. +8. Define measurable SLOs and add regression gate placeholders. +9. Define security test matrix for ownership and cross-user access. + +### Week 1: Backend Foundation +1. Add Prisma models + migration +2. Create conversation service +3. Add controller endpoints +4. Update AI service to persist messages +5. Write tests for conversation CRUD + +### Week 2: Frontend Service +1. Create `AiChatService` with API integration +2. Migrate existing chat panel to use service +3. Add conversation loading/saving logic +4. Update localStorage structure +5. Test service in isolation + +### Week 3: UI Components +1. Build chat sidebar component +2. Extract chat panel logic to shared component +3. Create global chat widget wrapper +4. Add to app component +5. Implement conversation switching +6. Polish UI (animations, responsive design) + +## Acceptance Criteria + +- [ ] Widget toggle button visible on all pages (with permission) +- [ ] Clicking toggle opens/closes floating chat window +- [ ] Sidebar shows list of past conversations (title + timestamp) +- [ ] Clicking conversation loads it from database +- [ ] "New chat" button clears and starts fresh conversation +- [ ] All conversations persist across page refreshes +- [ ] All conversations persist across browser sessions +- [ ] Conversations sync across devices (database-backed) +- [ ] Search/filter conversations by title +- [ ] Delete individual conversations +- [ ] Rename conversations +- [ ] Existing portfolio chat still works embedded +- [ ] `POST /ai/chat` remains backward-compatible for existing `sessionId` clients during transition +- [ ] Conversation endpoints enforce ownership checks (cross-user IDs blocked with `403`/`404`) +- [ ] Conversation list query uses indexed path and meets p95 target +- [ ] Conversation message load is paginated and meets p95 target +- [ ] Redis delete + rehydration behavior is deterministic and covered by tests +- [ ] DB and Redis failure modes follow documented degraded behavior and user-facing status messaging +- [ ] API rate limits enforced with deterministic `429` contract for each scoped limit +- [ ] Search returns ranked matches over title + first-message preview text +- [ ] Widget behavior matches desktop/tablet/mobile UX specification + +## Testing Strategy + +### Backend Tests +```typescript +// ai-chat-conversation.service.spec.ts +describe('AiChatConversationService', () => { + it('should create conversation with auto-generated title'); + it('should list conversations for user'); + it('should paginate results'); + it('should delete conversation and cascade messages'); + it('should reject cross-user conversation access'); + it('should use memorySessionId mapping for Redis lifecycle'); +}); + +// ai.controller.spec.ts +describe('AiController chat transition contract', () => { + it('should accept conversationId on POST /ai/chat'); + it('should accept legacy sessionId on POST /ai/chat'); + it('should return conversationId and memory.sessionId in response'); + it('should return deterministic 429 payload when rate limits are exceeded'); +}); +``` + +### Frontend Tests +```typescript +// ai-chat.service.spec.ts +describe('AiChatService', () => { + it('should sync with localStorage'); + it('should handle network errors gracefully'); + it('should cache conversations locally'); +}); + +// ai-chat-widget.component.spec.ts +describe('GfAiChatWidgetComponent', () => { + it('should toggle open/closed'); + it('should load conversation from service'); + it('should create new conversation'); +}); +``` + +### E2E Tests +- Create conversation → refresh → verify persists +- Switch between conversations → verify correct context +- Delete conversation → verify removed from list +- Search conversations → verify filtering +- Attempt to load another user's conversation ID → verify `403`/`404` +- Continue old `sessionId` chat flow after deploy → verify compatibility +- Expire Redis key for active conversation → verify memory rehydration from persisted messages +- Simulate DB write outage on send → verify degraded status + retry behavior +- Simulate Redis outage on send → verify DB-context fallback path +- Exceed per-conversation and per-user limits → verify `429` payload and retry timing +- Validate search ranking over title + first-message preview text +- Validate desktop/tablet/mobile widget layouts and sidebar behavior + +### Manual Verification +- Send message in widget and embedded panel → latest message renders first (reverse chronological UI behavior). +- Rate assistant response after list reordering → feedback applies to the correct assistant message. +- Delete conversation and refresh page → conversation remains deleted and does not reappear. +- Force API timeout from browser devtools and verify pending + retry + actionable error messaging. + +## Performance Targets (SLOs) + +- Conversation list (`GET /ai/conversations`, limit 50): p95 API latency < 250ms (excluding network RTT). +- Message page load (`GET /ai/conversations/:id/messages`, limit 100): p95 API latency < 350ms. +- Chat send overhead from persistence layer (`POST /ai/chat`): additional p95 < 120ms over current baseline. +- Widget open to first message paint (cached list path): p95 < 400ms on desktop local benchmark. +- DB query budget: + - List conversations: max 2 queries/request + - Load messages page: max 2 queries/request + - Send message persist path: max 4 queries/request + +## Performance Strategies + +- **localStorage caching**: Keep lightweight conversation summary cache locally. +- **Lazy loading**: Load messages on demand by conversation. +- **Pagination defaults**: 50 conversations, 100 messages per page. +- **Debounced search**: 300ms debounce before API search. +- **Optimistic UI**: Update local list immediately, reconcile with server response. +- **Backpressure**: Rate-limit enforcement protects API and LLM spend under burst traffic. +- **Degraded mode retries**: Background sync retries prevent data loss on transient failures. + +## Migration Strategy + +### Database Migration +```bash +# Create new tables +pnpm nx run api:prisma:migrate + +# Backfill existing Redis sessions (optional) +# Script to load active Redis sessions into database +``` + +### Zero-Downtime Deployment +1. Deploy backend contract changes first (`conversationId` + legacy `sessionId` support). +2. Keep existing frontend on legacy `sessionId` flow. +3. Deploy frontend widget/service using conversation APIs. +4. Enable feature flag for a subset of users and monitor latency/error/security metrics. +5. Remove legacy `sessionId` client usage after one stable release cycle. + +## Rollback Plan + +If critical issues arise: +1. Frontend: Fall back to localStorage-only mode +2. Backend: Keep old endpoints functional +3. Feature flag: Disable global widget +4. Database: Keep existing data, revert schema changes + +## Open Questions + +1. Should we store message metadata (toolCalls, verification) in JSON or separate tables? + - **Decision**: JSON for simplicity (RDBMS not optimal for sparse data) + +2. Should we auto-generate conversation titles? + - **Decision**: Yes, use first 50 chars of first user message + +3. Should conversations be shareable (like ChatGPT)? + - **Decision**: No, private-only for MVP + +4. Max messages per conversation? + - **Decision**: No limit (database), but lazy load last 100 + +5. Should we export conversations? + - **Decision**: Future enhancement, not MVP + +## Files to Create + +1. `apps/api/src/app/endpoints/ai/ai-chat-conversation.dto.ts` +2. `apps/api/src/app/endpoints/ai/ai-chat-conversation.service.ts` +3. `apps/api/src/app/endpoints/ai/ai-chat-conversation.controller.ts` +4. `apps/client/src/app/services/ai-chat.service.ts` +5. `apps/client/src/app/components/global/ai-chat-widget/` +6. `apps/client/src/app/components/global/ai-chat-widget/ai-chat-widget.component.ts` +7. `apps/client/src/app/components/global/ai-chat-widget/ai-chat-sidebar/` + +## Files to Modify + +1. `prisma/schema.prisma` +2. `apps/api/src/app/endpoints/ai/ai.service.ts` +3. `apps/api/src/app/endpoints/ai/ai.controller.ts` +4. `apps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.ts` +5. `apps/client/src/app/app.component.ts` + +## Estimated Effort + +- **Week 0 hardening**: 3-4 days +- **Backend implementation**: 4-6 days +- **Frontend service + search**: 4-5 days +- **UI components + responsive UX**: 4-6 days +- **Testing + stabilization**: 4-5 days + +**Total**: ~19-26 days (4-5 weeks) + +### Scope Control Option (If 3 Weeks Is Required) + +- Keep transition contract, Redis mapping, security tests, and core SLOs. +- Defer full-text search to title-only search for MVP. +- Defer tablet-specific layout polish to post-MVP. + +## Plan Confidence + +- **Architecture confidence**: Medium-high (strong phase structure, explicit rollout, measurable SLOs). +- **Primary risks**: Transition contract drift and Redis lifecycle edge cases. +- **Risk control basis**: Ownership tests, compatibility tests, and perf/security gates in CI. + +## Next Steps + +1. Convert the five ticket-ready deliverables into Linear sub-tasks with owners. +2. Approve transition contract and Redis lifecycle spec before schema migration. +3. Implement Week 0 hardening items and merge behind feature flag. +4. Execute Week 1-3 phases with SLO/security gates active in CI. +5. Run rollout checklist and remove legacy `sessionId` path after stability window.