22 KiB
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:
- Persistent conversation history across sessions
- Ability to switch between past conversations
- Global access from any page (floating widget)
- 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 endpointPOST /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/chattransition 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
429response 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/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
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
@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):
// Existing endpoint stays active during migration window
@Post('chat')
chat(@Body() dto: AiChatDto & { conversationId?: string })
- Request accepts both
conversationIdand legacysessionId. - Resolution order:
conversationIdfirst,sessionIdfallback, then create new conversation. - Response includes
conversationIdand keepsmemory.sessionIdfor compatibility with existing clients. - Deprecation:
sessionIdrequest 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.memorySessionIdis the canonical mapping from conversation to Redis memory.- Conversation delete flow removes:
ChatConversationrow (cascadeChatMessage)- Redis key for
memorySessionId
- Expired Redis memory triggers rehydration from last
Npersisted 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 UnavailablewithRetry-After. - Keep current input in client draft box for user retry.
- Return
- Network timeout from client to API:
- Show pending state (
Sending...) for request timeout window. - Retry once automatically in background, then surface actionable error.
- Show pending state (
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
429with 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
@Injectable({ providedIn: 'root' })
export class AiChatService {
private currentConversation$ = new BehaviorSubject<ChatConversation | null>(null);
private conversations$ = new BehaviorSubject<ChatConversation[]>([]);
// Conversation CRUD
createConversation(title?: string): Observable<ChatConversation>
loadConversations(pagination?: ListConversationsQuery): Observable<ChatConversation[]>
loadConversation(id: string): Observable<ChatConversation>
deleteConversation(id: string): Observable<void>
updateConversation(id: string, data: UpdateConversationDto): Observable<ChatConversation>
// Messages
sendMessage(conversationId: string, query: string): Observable<AiAgentChatResponse>
loadMessages(conversationId: string): Observable<AiChatMessage[]>
// State
getCurrentConversation(): Observable<ChatConversation | null>
getConversations(): Observable<ChatConversation[]>
}
2.2 Enhanced localStorage Structure
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
searchTextfield onChatConversation. - Update
searchTextwhen title changes or one of first 3 user messages changes.
- Add
- Query strategy:
- PostgreSQL full-text search:
to_tsvector('english', searchText)+plainto_tsquery. - Add
GINindex via SQL migration forsearchTextvector expression.
- PostgreSQL full-text search:
- 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 controllerai-chat-widget.component.scss- Fixed positioning stylesai-chat-widget.component.html- Template
Key Features:
@Component({
selector: 'gf-ai-chat-widget',
template: `
<button class="chat-toggle" (click)="toggleChat()">
<mat-icon>chat</mat-icon>
</button>
<div class="chat-container" *ngIf="isOpen">
<gf-ai-chat-sidebar
[conversations]="conversations"
(conversationSelected)="loadConversation($event)"
(newChat)="startNewChat()"
></gf-ai-chat-sidebar>
<gf-ai-chat-panel
[conversationId]="currentConversationId"
[messages]="messages"
(messageSent)="handleMessage($event)"
></gf-ai-chat-panel>
</div>
`,
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.tsai-chat-sidebar.component.scssai-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, height600px. - Sidebar default open.
- Tablet (
768px - 1199px):- Floating widget anchored bottom-right.
- Container target width
600px, height70vh. - 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
AiChatServicefor 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
@Component({
selector: 'gf-root',
template: `
<router-outlet></router-outlet>
<gf-ai-chat-widget></gf-ai-chat-widget>
`
})
export class AppComponent {}
4.3 Permission & Feature Flag
Service: Check user permissions before showing widget
if (this.user.hasPermissionToReadAiPrompt) {
showChatWidget = true;
}
Implementation Order
Week 0: Contract + Safety Baseline
- Finalize
POST /ai/chatcompatibility contract (conversationId, legacysessionId, response fields). - Finalize Redis lifecycle mapping and delete semantics.
- Finalize Prisma enums/indexes and migration plan.
- Finalize degraded-mode behavior for DB/Redis/network failure paths.
- Finalize API rate-limit policy and
429response payload contract. - Finalize search implementation spec and indexing plan.
- Finalize responsive UX specs for desktop/tablet/mobile.
- Define measurable SLOs and add regression gate placeholders.
- Define security test matrix for ownership and cross-user access.
Week 1: Backend Foundation
- Add Prisma models + migration
- Create conversation service
- Add controller endpoints
- Update AI service to persist messages
- Write tests for conversation CRUD
Week 2: Frontend Service
- Create
AiChatServicewith API integration - Migrate existing chat panel to use service
- Add conversation loading/saving logic
- Update localStorage structure
- Test service in isolation
Week 3: UI Components
- Build chat sidebar component
- Extract chat panel logic to shared component
- Create global chat widget wrapper
- Add to app component
- Implement conversation switching
- 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/chatremains backward-compatible for existingsessionIdclients 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
429contract 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
// 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
// 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
sessionIdchat 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
429payload 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
# 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
- Deploy backend contract changes first (
conversationId+ legacysessionIdsupport). - Keep existing frontend on legacy
sessionIdflow. - Deploy frontend widget/service using conversation APIs.
- Enable feature flag for a subset of users and monitor latency/error/security metrics.
- Remove legacy
sessionIdclient usage after one stable release cycle.
Rollback Plan
If critical issues arise:
- Frontend: Fall back to localStorage-only mode
- Backend: Keep old endpoints functional
- Feature flag: Disable global widget
- Database: Keep existing data, revert schema changes
Open Questions
-
Should we store message metadata (toolCalls, verification) in JSON or separate tables?
- Decision: JSON for simplicity (RDBMS not optimal for sparse data)
-
Should we auto-generate conversation titles?
- Decision: Yes, use first 50 chars of first user message
-
Should conversations be shareable (like ChatGPT)?
- Decision: No, private-only for MVP
-
Max messages per conversation?
- Decision: No limit (database), but lazy load last 100
-
Should we export conversations?
- Decision: Future enhancement, not MVP
Files to Create
apps/api/src/app/endpoints/ai/ai-chat-conversation.dto.tsapps/api/src/app/endpoints/ai/ai-chat-conversation.service.tsapps/api/src/app/endpoints/ai/ai-chat-conversation.controller.tsapps/client/src/app/services/ai-chat.service.tsapps/client/src/app/components/global/ai-chat-widget/apps/client/src/app/components/global/ai-chat-widget/ai-chat-widget.component.tsapps/client/src/app/components/global/ai-chat-widget/ai-chat-sidebar/
Files to Modify
prisma/schema.prismaapps/api/src/app/endpoints/ai/ai.service.tsapps/api/src/app/endpoints/ai/ai.controller.tsapps/client/src/app/pages/portfolio/analysis/ai-chat-panel/ai-chat-panel.component.tsapps/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
- Convert the five ticket-ready deliverables into Linear sub-tasks with owners.
- Approve transition contract and Redis lifecycle spec before schema migration.
- Implement Week 0 hardening items and merge behind feature flag.
- Execute Week 1-3 phases with SLO/security gates active in CI.
- Run rollout checklist and remove legacy
sessionIdpath after stability window.