From d9878dea73218f32c8cbe3acddfb43f4fdb27b1b Mon Sep 17 00:00:00 2001 From: local Date: Fri, 27 Mar 2026 00:59:24 +0000 Subject: [PATCH] docs: complete project research --- .planning/research/.ARCHITECTURE.md.swp | Bin 0 -> 1024 bytes .planning/research/ARCHITECTURE.md | 389 ++++++++++++++++++++++++ .planning/research/FEATURES.md | 202 ++++++++++++ .planning/research/PITFALLS.md | 302 ++++++++++++++++++ .planning/research/STACK.md | 180 +++++++++++ .planning/research/SUMMARY.md | 195 ++++++++++++ 6 files changed, 1268 insertions(+) create mode 100644 .planning/research/.ARCHITECTURE.md.swp create mode 100644 .planning/research/ARCHITECTURE.md create mode 100644 .planning/research/FEATURES.md create mode 100644 .planning/research/PITFALLS.md create mode 100644 .planning/research/STACK.md create mode 100644 .planning/research/SUMMARY.md diff --git a/.planning/research/.ARCHITECTURE.md.swp b/.planning/research/.ARCHITECTURE.md.swp new file mode 100644 index 0000000000000000000000000000000000000000..34f8f126c4016c3a5352eceabf57d5a41b6d3f6c GIT binary patch literal 1024 zcmYc?$V<%2S1{KzVn6{Z)r<_4#VA5JIhDmJCHVz7rQs6#8Tq-X`jy4{X^FX+IhDFa zsRjA^j_Ij+C7H?2`6;RTdIdR&d3l+6>H0;f#i@x!$r<{NLCzkYA+F9Lp+T;CxhZh7 OMj4|aFd71shX4TDeID`v literal 0 HcmV?d00001 diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md new file mode 100644 index 0000000..a8bb8c7 --- /dev/null +++ b/.planning/research/ARCHITECTURE.md @@ -0,0 +1,389 @@ +# Architecture Research + +**Domain:** Blazor WebAssembly AI Chat Application +**Researched:** 2026-03-27 +**Confidence:** HIGH (Microsoft official docs + verified community patterns) + +## Standard Architecture + +### System Overview + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ BROWSER (Blazor WASM) │ +├──────────────────────────────────────────────────────────────────────┤ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────┐ │ +│ │ ChatPage │ │ ConvList │ │ MessageBubble │ │ +│ │ (container) │ │ (sidebar) │ │ (leaf component) │ │ +│ └───────┬────────┘ └───────┬────────┘ └────────────────────────┘ │ +│ │ │ │ +│ ┌───────▼──────────────────▼─────────────────────────────────────┐ │ +│ │ ConversationStateService (Singleton DI) │ │ +│ │ Holds: active conversation, message list, loading flag │ │ +│ └───────┬────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────▼────────────────────────────────────────────────────────┐ │ +│ │ ChatApiClient (HttpClient wrapper) │ │ +│ │ POST /api/conversations, GET /api/stream?…, DELETE … │ │ +│ └───────┬────────────────────────────────────────────────────────┘ │ +└──────────┼─────────────────────────────────────────────────────────┘ + │ HTTP / SSE (text/event-stream) + │ +┌──────────▼─────────────────────────────────────────────────────────┐ +│ ASP.NET Core Minimal API (Server) │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ ChatEndpoints │ │ ConversationEndpoints │ │ +│ │ POST /api/chat │ │ GET/POST/DELETE /api/… │ │ +│ │ GET /api/chat/… │ │ │ │ +│ └──────────┬───────────┘ └───────────────┬──────────────────┘ │ +│ │ │ │ +│ ┌──────────▼───────────┐ ┌───────────────▼──────────────────┐ │ +│ │ OpenAiService │ │ ConversationRepository │ │ +│ │ (streams tokens via │ │ (reads/writes JSON files) │ │ +│ │ openai-dotnet SDK) │ │ │ │ +│ └──────────┬───────────┘ └───────────────┬──────────────────┘ │ +│ │ │ │ +├─────────────┼───────────────────────────────┼────────────────────────┤ +│ │ HTTPS │ local disk │ +│ ┌─────▼──────┐ ┌─────────▼──────────────────┐ │ +│ │ OpenAI API │ │ ~/chat-data/ │ │ +│ │ (GPT-4o) │ │ conversations/{id}.json │ │ +│ └────────────┘ └────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Component Responsibilities + +| Component | Responsibility | Typical Implementation | +|-----------|----------------|------------------------| +| ChatPage | Top-level container — composes sidebar + chat panel, owns route | Blazor page component (`@page "/chat/{id?}"`) | +| ConversationList | Lists saved conversations, triggers create/delete/switch | Child component with EventCallback to parent | +| MessageList | Renders all messages in active conversation | Child component, iterates message model | +| MessageBubble | Renders single message — user vs AI, markdown for AI | Leaf component, uses Markdig or similar | +| ChatInput | Text area + send button, raises OnSend event | Child component with EventCallback | +| ConversationStateService | Singleton in-memory state — active conversation, messages, streaming flag | C# service registered `AddSingleton`, raises `OnChange` events | +| ChatApiClient | Wraps HttpClient, handles streaming plumbing | Scoped service, uses `SetBrowserResponseStreamingEnabled(true)` | +| ChatEndpoints | Minimal API: accepts message, streams SSE response from OpenAI | Static endpoint methods wired in `Program.cs` | +| ConversationEndpoints | Minimal API: CRUD for conversations | Static endpoint methods | +| OpenAiService | Calls OpenAI SDK, returns `IAsyncEnumerable` of tokens | Scoped service on server | +| ConversationRepository | Read/write JSON files on disk | Singleton or Scoped service on server | + +## Recommended Project Structure + +``` +ChatAgentWebApp/ +├── ChatAgentWebApp.Client/ # Blazor WASM project +│ ├── Components/ +│ │ ├── Chat/ +│ │ │ ├── ChatPage.razor # Page — route entry point +│ │ │ ├── MessageList.razor # Renders message history +│ │ │ ├── MessageBubble.razor # Single message (user/AI) +│ │ │ └── ChatInput.razor # Text input + send +│ │ └── Conversations/ +│ │ └── ConversationList.razor # Sidebar conversation switcher +│ ├── Services/ +│ │ ├── ConversationStateService.cs # In-memory singleton state +│ │ └── ChatApiClient.cs # HttpClient wrapper + SSE reading +│ └── Program.cs # DI registration, HttpClient base URL +│ +├── ChatAgentWebApp.Server/ # ASP.NET Core Minimal API project +│ ├── Endpoints/ +│ │ ├── ChatEndpoints.cs # POST /api/chat/stream (SSE) +│ │ └── ConversationEndpoints.cs # GET/POST/DELETE /api/conversations +│ ├── Services/ +│ │ ├── OpenAiService.cs # Wraps openai-dotnet SDK, yields tokens +│ │ └── ConversationRepository.cs # JSON file read/write +│ ├── Models/ # Server-only models (request/response) +│ └── Program.cs # Minimal API wiring, CORS, DI +│ +└── ChatAgentWebApp.Shared/ # Shared library (both projects reference) + └── Models/ + ├── Conversation.cs # Shared model — id, title, createdAt + └── ChatMessage.cs # Shared model — role, content, timestamp +``` + +### Structure Rationale + +- **Client/Services/:** All HttpClient wiring and streaming logic lives here, not in components. Components stay dumb (data in via parameters, actions out via EventCallback). +- **Shared/Models/:** Models used by both Client (display) and Server (serialization) live here. Eliminates duplicate DTOs. +- **Server/Endpoints/:** Minimal API endpoint registration separated by concern (chat streaming vs conversation CRUD). Keeps Program.cs clean. +- **Server/Services/:** OpenAI SDK calls and file I/O isolated from HTTP concerns. Enables testing without HTTP context. + +## Architectural Patterns + +### Pattern 1: SSE Streaming from Minimal API to WASM Client + +**What:** The server endpoint writes `text/event-stream` frames to the response as OpenAI tokens arrive. The WASM client reads the response as a stream using `SetBrowserResponseStreamingEnabled(true)` and processes tokens without waiting for the full response. + +**When to use:** Any time you need token-by-token streaming from an LLM. The alternative (wait for full response, then display) is noticeably worse UX for long answers. + +**Trade-offs:** Slightly more plumbing than a simple JSON response. SSE is one-directional (server to client), which is fine here — the client sends the initial message as a POST, and the stream returns the reply. + +**Server endpoint pattern:** +```csharp +// Server: ChatEndpoints.cs +app.MapPost("/api/chat/stream", async (ChatRequest request, OpenAiService ai, + ConversationRepository repo, HttpContext http) => +{ + http.Response.Headers.ContentType = "text/event-stream"; + http.Response.Headers.CacheControl = "no-cache"; + + await foreach (var token in ai.StreamResponseAsync(request)) + { + await http.Response.WriteAsync($"data: {token}\n\n"); + await http.Response.Body.FlushAsync(); + } + + await http.Response.WriteAsync("event: done\ndata: end\n\n"); + await repo.AppendMessageAsync(request.ConversationId, assistantMessage); +}); +``` + +**Client consumption pattern:** +```csharp +// Client: ChatApiClient.cs +var req = new HttpRequestMessage(HttpMethod.Post, "/api/chat/stream"); +req.SetBrowserResponseStreamingEnabled(true); // Critical for WASM +req.Content = JsonContent.Create(chatRequest); + +var response = await _httpClient.SendAsync(req, + HttpCompletionOption.ResponseHeadersRead); // Don't buffer full body + +using var stream = await response.Content.ReadAsStreamAsync(); +using var reader = new StreamReader(stream); + +while (!reader.EndOfStream) +{ + var line = await reader.ReadLineAsync(); + if (line?.StartsWith("data: ") == true) + { + var token = line[6..]; + _stateService.AppendToken(token); + await InvokeAsync(StateHasChanged); // Update UI per token + } +} +``` + +### Pattern 2: Singleton State Service as Shared State + +**What:** A `ConversationStateService` registered as a singleton (in WASM, scoped = singleton anyway) holds the active conversation, message list, and streaming state. Components subscribe to an `OnChange` event to re-render when state updates. + +**When to use:** When multiple components need to reflect the same data — the sidebar list, the message pane, and the input box all depend on the same active conversation. + +**Trade-offs:** Simple and explicit. Not as formal as Redux/Flux but appropriate for single-user personal tool. Avoids prop-drilling through component hierarchies. + +**Example:** +```csharp +// ConversationStateService.cs +public class ConversationStateService +{ + public Conversation? ActiveConversation { get; private set; } + public List Messages { get; } = new(); + public bool IsStreaming { get; private set; } + + public event Action? OnChange; // Components subscribe to this + + public void SetActive(Conversation conv) + { + ActiveConversation = conv; + Messages.Clear(); + NotifyStateChanged(); + } + + public void AppendToken(string token) + { + // Append to last message (AI response being streamed) + Messages.Last().Content += token; + NotifyStateChanged(); + } + + private void NotifyStateChanged() => OnChange?.Invoke(); +} +``` + +### Pattern 3: Repository for JSON File Persistence + +**What:** `ConversationRepository` encapsulates all file I/O. Each conversation is stored as `{id}.json` in a configured data directory. The repository loads/saves these files and maintains an in-memory index for listing. + +**When to use:** Always — never write `File.ReadAll...` directly in endpoint handlers. Even for JSON files, the repository pattern keeps concerns separate and makes the storage medium swappable. + +**Trade-offs:** Slight overhead vs inline file I/O. But it cleanly separates persistence from HTTP handling and makes unit testing possible without touching the filesystem. + +```csharp +// ConversationRepository.cs +public class ConversationRepository +{ + private readonly string _dataDir; + + public async Task> GetAllAsync() { ... } + public async Task GetByIdAsync(string id) { ... } + public async Task SaveAsync(Conversation conv) { ... } + public async Task DeleteAsync(string id) { ... } + public async Task AppendMessageAsync(string id, ChatMessage msg) { ... } +} +``` + +## Data Flow + +### Sending a Message and Receiving a Streaming Response + +``` +[User types message + clicks Send] + ↓ +[ChatInput.razor] → OnSend EventCallback + ↓ +[ChatPage.razor] calls ConversationStateService.BeginStreaming() + ↓ +[ChatApiClient.PostMessageStreamAsync()] — POST /api/chat/stream + ↓ +[Server: ChatEndpoints] receives request + ↓ +[OpenAiService.StreamResponseAsync()] → calls OpenAI GPT API + ↓ +[OpenAI returns streaming response] — tokens arrive incrementally + ↓ +[Server writes SSE frames] → "data: Hello\n\n", "data: world\n\n", ... + ↓ +[Client StreamReader] reads lines as they arrive (ResponseHeadersRead) + ↓ +[ConversationStateService.AppendToken()] mutates last message + ↓ +[OnChange event fires] → all subscribed components call StateHasChanged() + ↓ +[MessageList re-renders] — user sees tokens appearing in real time + ↓ +[Server sends "event: done"] → client marks IsStreaming = false + ↓ +[Server persists full response] → ConversationRepository.AppendMessageAsync() +``` + +### Conversation Management Flow + +``` +[User clicks "New Conversation"] + ↓ +[ConversationList.razor] → EventCallback to ChatPage + ↓ +[ChatApiClient.CreateConversationAsync()] → POST /api/conversations + ↓ +[Server: ConversationEndpoints] → ConversationRepository.SaveAsync() + ↓ +[Returns new Conversation object] + ↓ +[ConversationStateService.SetActive(newConv)] → clears message list + ↓ +[All components re-render] — empty chat ready for input +``` + +### State Management Summary + +``` +ConversationStateService (Singleton in WASM) + OnChange event + ↓ (subscribed in OnInitialized, unsubscribed in Dispose) +[ChatPage] [MessageList] [ConversationList] [ChatInput] + ↑ + Mutations via method calls (SetActive, AppendToken, SetStreaming) + ↑ + Triggered by ChatApiClient responses +``` + +## Build Order Implications + +Components have clear dependency layers. Build from the bottom up: + +1. **Shared Models** — `Conversation`, `ChatMessage` (no deps, both projects need these) +2. **ConversationRepository** — file I/O, no HTTP (testable in isolation) +3. **OpenAiService** — OpenAI SDK calls, yields `IAsyncEnumerable` +4. **Server Endpoints** — wires services to HTTP (depends on 2 and 3) +5. **ChatApiClient** — WASM HTTP client + SSE consumer (depends on server being up) +6. **ConversationStateService** — in-memory state (depends on models, no HTTP) +7. **Leaf UI components** — `MessageBubble`, `ChatInput`, `ConversationList` (pure display) +8. **Container components** — `MessageList`, `ChatPage` (compose leaves, use state service) + +This order maps naturally to build phases: backend first (phases 1-3), then state layer, then UI. + +## Scaling Considerations + +This is a single-user personal tool. Scaling is not a concern for v1. + +| Scale | Architecture Adjustment | +|-------|--------------------------| +| 1 user (current) | Monolith fine, JSON files fine, no auth needed | +| Multi-user | Add auth, move to SQLite or Postgres, scope state service per user | +| Cloud deploy | Externalize API key via Azure Key Vault, containerize server | + +### Scaling Priorities (if needed in v2+) + +1. **First bottleneck:** JSON files don't support concurrent writes — add SQLite (EF Core migration is straightforward) +2. **Second bottleneck:** Single server process — Blazor WASM + separate API already decoupled; scale API independently + +## Anti-Patterns + +### Anti-Pattern 1: Calling OpenAI from the WASM Client + +**What people do:** Register `HttpClient` in the Blazor WASM project and call `api.openai.com` directly. + +**Why it's wrong:** The OpenAI API key is visible to anyone who opens browser DevTools. The key is in the `Authorization` header of every request. + +**Do this instead:** All OpenAI calls go through the backend API. The server reads the key from `appsettings.json` or environment variables (server-side, never shipped to browser). + +### Anti-Pattern 2: Buffering the Streaming Response + +**What people do:** Call `await response.Content.ReadAsStringAsync()` after `SendAsync`, then parse the complete response. + +**Why it's wrong:** In Blazor WASM, without `SetBrowserResponseStreamingEnabled(true)` and `ResponseHeadersRead`, the browser buffers the entire response before making any of it available. The UI shows nothing until the full AI response is complete — defeating the entire point of streaming. + +**Do this instead:** Set `SetBrowserResponseStreamingEnabled(true)` on the `HttpRequestMessage` and use `HttpCompletionOption.ResponseHeadersRead` in `SendAsync`. Read the response as a stream line by line. + +### Anti-Pattern 3: Calling StateHasChanged from a Background Thread + +**What people do:** Mutate state and call `StateHasChanged()` directly inside async token-reading loops. + +**Why it's wrong:** In Blazor WASM, this is mostly harmless today because WASM is single-threaded — but in .NET 10+, multi-threaded WASM is becoming a reality. The correct pattern also reads more clearly. + +**Do this instead:** Use `await InvokeAsync(StateHasChanged)` when updating UI from within async callbacks or loops. This schedules re-render on the correct synchronization context and is safe across all hosting models. + +### Anti-Pattern 4: Fat Components (Logic in Razor Files) + +**What people do:** Put API call logic, JSON deserialization, and state mutation directly in `.razor` component code blocks. + +**Why it's wrong:** The tutorial nature of this project means code must be readable. Logic buried in components is hard to explain, hard to test, and violates single responsibility. The builder is also learning Blazor patterns — fat components teach bad habits. + +**Do this instead:** Components only call services. Services own all logic. This also demonstrates the Blazor DI pattern explicitly, which is a key learning objective. + +## Integration Points + +### External Services + +| Service | Integration Pattern | Notes | +|---------|---------------------|-------| +| OpenAI GPT API | `openai-dotnet` SDK on server, `IAsyncEnumerable` returned | Never from WASM client. Key in `appsettings.json` on server. | +| Browser FileSystem | None — all persistence is server-side JSON files | WASM cannot write to local disk; server has full file access | + +### Internal Boundaries + +| Boundary | Communication | Notes | +|----------|---------------|-------| +| WASM Client ↔ API Server | HTTP / SSE via `HttpClient` | Configure base URL + CORS. During development, server serves client static files (hosted model). | +| ChatPage ↔ Child Components | Blazor parameters + EventCallback | Downward via `[Parameter]`, upward via `EventCallback` | +| Components ↔ State Service | Injected singleton, `OnChange` event subscription | Components subscribe in `OnInitialized`, unsubscribe in `IDisposable.Dispose` | +| Endpoints ↔ Services | Constructor DI | Both `OpenAiService` and `ConversationRepository` injected into endpoint handlers | +| OpenAiService ↔ ConversationRepository | No direct coupling | Endpoint coordinates both — calls AI service, then persists to repo | + +## Sources + +- [Microsoft Docs: Call a web API from Blazor (aspnetcore-10.0)](https://learn.microsoft.com/en-us/aspnet/core/blazor/call-web-api?view=aspnetcore-10.0) +- [Meziantou: Streaming an HTTP response in Blazor WebAssembly](https://www.meziantou.net/streaming-an-http-response-in-blazor-webassembly.htm) +- [Strathweb: Built-in support for Server Sent Events in .NET 9](https://www.strathweb.com/2024/07/built-in-support-for-server-sent-events-in-net-9/) +- [Petkir: Stream chat to your frontend with SSE in ASP.NET Core (.NET 10)](https://www.petkir.at/blog/semantic-kernel/01_chat_03_sse) +- [openai/openai-dotnet issue #65: Streaming doesn't work properly in Blazor WASM](https://github.com/openai/openai-dotnet/issues/65) +- [Microsoft Docs: Blazor project structure](https://learn.microsoft.com/en-us/aspnet/core/blazor/project-structure?view=aspnetcore-10.0) +- [Microsoft Docs: Blazor state management](https://learn.microsoft.com/en-us/aspnet/core/blazor/state-management/?view=aspnetcore-10.0) +- [Syncfusion: MVVM Pattern in Blazor For State Management](https://www.syncfusion.com/blogs/post/mvvm-pattern-blazor-state-management) +- [PalmHill.BlazorChat — reference implementation (WASM + WebAPI + real-time LLM)](https://github.com/edgett/PalmHill.BlazorChat) + +--- +*Architecture research for: Blazor WebAssembly AI Chat Application* +*Researched: 2026-03-27* diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md new file mode 100644 index 0000000..80d2c87 --- /dev/null +++ b/.planning/research/FEATURES.md @@ -0,0 +1,202 @@ +# Feature Research + +**Domain:** Personal AI chat web application (single-user, OpenAI GPT backend, Blazor WebAssembly) +**Researched:** 2026-03-27 +**Confidence:** HIGH (core features verified against live ChatGPT/Claude, OpenAI API docs, and Blazor ecosystem) + +## Feature Landscape + +### Table Stakes (Users Expect These) + +Features users assume exist. Missing these = product feels incomplete. + +| Feature | Why Expected | Complexity | Notes | +|---------|--------------|------------|-------| +| Send message and receive response | Core function of any AI chat app | LOW | POST to backend API; backend calls OpenAI chat completions endpoint | +| Streaming token-by-token responses | ChatGPT normalized this; blocking responses feel broken | MEDIUM | Server-Sent Events (SSE) from backend; HttpClient streaming or SignalR on WASM client; creates "typewriter" effect | +| Markdown rendering in AI responses | GPT always responds with markdown; raw markdown is unreadable | MEDIUM | Markdig library on backend or client; MarkupString in Blazor to render HTML; Markdown.ColorCode for syntax highlighting | +| Syntax-highlighted code blocks | Code responses are a primary GPT use-case; unformatted code is unusable | MEDIUM | Markdown.ColorCode NuGet package; note: CsharpToColouredHTML has WASM compatibility issues — use base ColorCode package | +| Copy-to-clipboard on code blocks | Standard expectation from ChatGPT/Claude; users paste code constantly | LOW | JavaScript interop in Blazor (`navigator.clipboard.writeText`); small JS interop call | +| Multiple named conversations | Users need to separate topics; single-thread apps feel like a toy | MEDIUM | Conversation list in sidebar; each conversation has ID, title, message list; JSON file per conversation | +| Create and switch between conversations | Navigation between conversations is core workflow | LOW | Once multi-conversation storage exists, switching is just loading by ID | +| Delete conversations | Users need to clean up; no delete = clutter accumulates | LOW | Remove JSON file; update conversation list | +| Persist conversation history across sessions | Without persistence, app is useless after refresh | MEDIUM | JSON file storage on disk; load on startup; save on every message | +| Auto-scroll to latest message | Standard chat behavior; missing it feels broken | LOW | JavaScript interop to scroll div; or CSS scroll-behavior | +| Loading/thinking indicator | Users need feedback that a request is in-flight | LOW | Show spinner or "..." while awaiting first token; hide once streaming starts | +| Input disabled during response | Prevent double-submit while response is streaming | LOW | Boolean state flag; disable textarea and button while `isStreaming = true` | +| Send on Enter key | Standard text input convention for chat | LOW | `@onkeydown` handler; Shift+Enter for newline | +| Responsive layout | Mobile-friendly is expected even for personal tools | LOW | CSS flexbox/grid; sidebar collapses on small screens | + +### Differentiators (Competitive Advantage) + +Features that set the product apart. Not required, but valuable. + +| Feature | Value Proposition | Complexity | Notes | +|---------|-------------------|------------|-------| +| Auto-generated conversation titles | Reduces naming friction; GPT can summarize first message as title | LOW | Call GPT with "Summarize this in 5 words" after first exchange; update conversation title | +| System prompt / persona configuration | Power users want to customize GPT behavior per conversation | MEDIUM | Add `systemPrompt` field to conversation model; include as first message in API payload | +| Message edit and regenerate | Fix typos without starting over; common in ChatGPT | MEDIUM | Truncate conversation at edited message; resend; requires re-streaming | +| Token usage display | Helps users understand context window consumption; teaches GPT behavior | LOW | OpenAI API returns usage in response; display in footer or message metadata | +| Conversation search | Find a past conversation by keyword | MEDIUM | Client-side search over loaded conversation titles; full-text needs indexing | +| Export conversation | Save as markdown or text file | LOW | Serialize messages to markdown string; trigger browser download via JS interop | +| Model selector (GPT-4o vs GPT-4o-mini) | Cost vs quality tradeoff is real; power users want control | LOW | Dropdown stored in app settings or per-conversation; passed as `model` parameter in API call | +| Well-commented tutorial-style code | The project doubles as a Blazor learning resource | LOW (implementation cost) | Inline `// Blazor:` comments on lifecycle hooks, DI, component patterns — this is a core differentiator for this specific project's purpose | + +### Anti-Features (Commonly Requested, Often Problematic) + +Features that seem good but create problems. + +| Feature | Why Requested | Why Problematic | Alternative | +|---------|---------------|-----------------|-------------| +| Authentication / login | "What if someone else uses it?" | Single-user personal tool; adds OAuth complexity with zero value | Leave open; document that it's intentionally single-user | +| Database (SQL/SQLite) | "JSON doesn't scale" | Premature optimization for a personal tool; adds EF Core migration complexity | JSON files are fast, human-readable, and zero-setup — perfect for this scope | +| Real-time sync across tabs | "What if I have two windows open?" | SignalR state sync complexity; no real use case for single user | Reload on focus; acceptable for personal tool | +| Plugin / tool calling system | "GPT can call functions!" | That's LangChain/MCP territory; v2 scope — building it now adds architecture complexity before core chat works | Defer to v2 milestone with LangChain and MCP servers | +| Voice input / output | ChatGPT has it | OpenAI Realtime API is being deprecated May 2026; adds Web Speech API complexity; out of scope | Text-only for v1 | +| Image uploads / multimodal | GPT-4o supports it | WASM file upload + base64 encoding + vision API adds significant complexity | Text chat first; defer multimodal to v2 | +| Conversation branching | "What if I want to explore different answers?" | Complex tree data structure; confusing UX; rare real-world use | Regenerate last response is sufficient for 95% of cases | +| Infinite scroll / lazy loading | "What about long conversations?" | Adds virtual scrolling complexity; JSON load is fine at personal scale | Load full conversation on select; revisit if performance suffers | +| PWA / offline support | "Make it installable" | Service worker complexity; AI chat requires internet anyway | Responsive web design is sufficient | + +## Feature Dependencies + +``` +[JSON File Storage] + └──required by──> [Multiple Conversations] + └──required by──> [Create / Switch / Delete Conversations] + └──required by──> [Persist History Across Sessions] + +[OpenAI API Call (blocking)] + └──required by──> [Streaming Responses] + └──required by──> [Loading Indicator] + └──required by──> [Input Disabled During Streaming] + +[Markdown Rendering] + └──required by──> [Syntax-Highlighted Code Blocks] + └──required by──> [Copy-to-Clipboard on Code Blocks] + +[Streaming Responses] ──enhances──> [Auto-Scroll to Latest Message] + +[Multiple Conversations] ──enables──> [Auto-Generated Titles] +[Multiple Conversations] ──enables──> [Conversation Search] +[Multiple Conversations] ──enables──> [Export Conversation] + +[System Prompt Config] ──enhances──> [Multiple Conversations] + (each conversation can have its own persona) + +[Token Usage Display] ──conflicts with [Streaming] + (usage metadata only available when stream=false or in the final chunk) +``` + +### Dependency Notes + +- **JSON File Storage required by Multiple Conversations:** Conversations need somewhere to live before the list/switch UI can be built. Storage phase must precede conversation management phase. +- **Blocking API call required by Streaming:** Must implement the non-streaming call first to understand the request/response shape, then layer SSE streaming on top. +- **Markdown Rendering required by Syntax Highlighting:** ColorCode is a Markdig pipeline extension — Markdig must be wired in before syntax highlighting can be added. +- **Token Usage Display conflicts with Streaming:** OpenAI streams individual content chunks; the `usage` field only appears in the final chunk (`finish_reason: stop`). Implementation must capture the last chunk separately. + +## MVP Definition + +### Launch With (v1) + +Minimum viable product — what's needed to validate the concept. + +- [ ] Send message, receive non-streaming GPT response — validates API connectivity and basic loop +- [ ] Streaming responses — core UX differentiator; blocking responses feel unacceptable +- [ ] Markdown rendering with syntax highlighting — GPT responses are markdown; unrendered output is unusable +- [ ] Create / switch / delete multiple conversations — without this, the app is a single disposable thread +- [ ] JSON file persistence — conversations must survive page refresh to be useful +- [ ] Auto-scroll and loading indicator — baseline polish that makes the app feel complete +- [ ] Copy-to-clipboard on code blocks — high-frequency action for developer-focused use +- [ ] Tutorial-style inline code comments — this project's defining purpose as a learning resource + +### Add After Validation (v1.x) + +Features to add once core is working. + +- [ ] Auto-generated conversation titles — reduces friction once the core loop is validated +- [ ] System prompt / persona configuration — natural extension once multi-conversation is stable +- [ ] Model selector — easy add once API layer is clean; real value for cost control +- [ ] Export conversation — low complexity, high occasional value + +### Future Consideration (v2+) + +Features to defer until product-market fit is established. + +- [ ] Message edit and regenerate — medium complexity; wait until core loop is solid +- [ ] Token usage display — useful but not blocking; needs streaming completion handling +- [ ] Conversation search — only valuable when there are many conversations to search +- [ ] LangChain / agentic workflows — explicitly v2 scope per PROJECT.md +- [ ] RAG document retrieval — v2 scope +- [ ] MCP server integration — v2 scope + +## Feature Prioritization Matrix + +| Feature | User Value | Implementation Cost | Priority | +|---------|------------|---------------------|----------| +| Send message / receive response | HIGH | LOW | P1 | +| Streaming responses | HIGH | MEDIUM | P1 | +| Markdown + syntax highlighting | HIGH | MEDIUM | P1 | +| Multiple conversations + persistence | HIGH | MEDIUM | P1 | +| Auto-scroll + loading indicator | HIGH | LOW | P1 | +| Copy code to clipboard | HIGH | LOW | P1 | +| Tutorial-style code comments | HIGH (for this project) | LOW | P1 | +| Auto-generated conversation titles | MEDIUM | LOW | P2 | +| System prompt configuration | MEDIUM | MEDIUM | P2 | +| Model selector | MEDIUM | LOW | P2 | +| Export conversation | LOW | LOW | P2 | +| Token usage display | LOW | LOW | P2 | +| Message edit and regenerate | MEDIUM | MEDIUM | P3 | +| Conversation search | LOW | MEDIUM | P3 | + +**Priority key:** +- P1: Must have for launch +- P2: Should have, add when possible +- P3: Nice to have, future consideration + +## Competitor Feature Analysis + +| Feature | ChatGPT (OpenAI) | Claude (Anthropic) | This Project | +|---------|------------------|--------------------|--------------| +| Streaming responses | Yes, token-by-token | Yes, token-by-token | Yes — SSE via backend API | +| Markdown rendering | Yes | Yes | Yes — Markdig + MarkupString | +| Syntax highlighted code | Yes + copy button | Yes + copy button | Yes — Markdown.ColorCode | +| Multiple conversations (sidebar) | Yes | Yes (Projects) | Yes — JSON file per conversation | +| Conversation persistence | Yes (cloud) | Yes (cloud) | Yes — local JSON files | +| Auto-generated titles | Yes | Yes | v1.x — GPT summarization call | +| System prompt | Via custom instructions | Via system prompt | v1.x — per-conversation field | +| Model selector | Yes (GPT-5.4 variants) | Yes (Opus/Sonnet/Haiku) | v1.x — GPT-4o vs GPT-4o-mini | +| Voice input/output | Yes (Advanced Voice Mode) | No | Deliberately excluded from v1 | +| Image uploads | Yes (multimodal) | Yes (multimodal) | Deliberately excluded from v1 | +| Plugin / tool calling | Yes (via GPT Actions) | Yes (via tool use) | v2 — MCP servers | +| RAG / document search | Yes (file attachments) | Yes (Projects + files) | v2 — RAG milestone | +| Auth / multi-user | Yes | Yes | Deliberately excluded (single user) | + +## Blazor-Specific Implementation Notes + +These are not features per se, but implementation constraints that affect feature complexity in Blazor WebAssembly: + +- **JavaScript Interop is required for:** clipboard access, scroll-to-bottom, syntax highlighting via client-side JS libraries (Highlight.js alternative to server-side ColorCode) +- **API key must never reach WASM client:** all OpenAI calls must go through the ASP.NET Core backend API — the WASM client calls the backend, the backend calls OpenAI +- **Streaming from backend to WASM client:** options are SSE (Server-Sent Events via `HttpClient` streaming) or SignalR HubConnection; SSE is simpler for one-way server-to-client streaming; SignalR is better if bidirectional messaging is needed later +- **MarkupString in Blazor:** required to render HTML from Markdig; must be used intentionally as it bypasses Blazor's XSS protections — only render trusted content (GPT output is untrusted; sanitize or accept risk as single-user personal tool) +- **Markdown.ColorCode WASM note:** base `Markdown.ColorCode` package works in WASM; `Markdown.ColorCode.CSharpToColoredHtml` does NOT — avoid the latter + +## Sources + +- [ChatGPT vs Claude feature comparison 2026 — LogicWeb](https://www.logicweb.com/chatgpt-vs-claude-ultimate-ai-comparison-in-2026/) +- [OpenAI Streaming API documentation](https://platform.openai.com/docs/api-reference/chat/streaming) +- [OpenAI Streaming Responses Guide](https://developers.openai.com/api/docs/guides/streaming-responses) +- [OpenAI Conversation State Guide](https://platform.openai.com/docs/guides/conversation-state) +- [Best practices for OpenAI Chat streaming UI — Pamela Fox](http://blog.pamelafox.org/2023/09/best-practices-for-openai-chat-apps_16.html) +- [PalmHill.BlazorChat — Blazor WASM + LLM reference implementation](https://github.com/edgett/PalmHill.BlazorChat) +- [Blazor Live Preview Markdown with Markdig — Syncfusion](https://www.syncfusion.com/blogs/post/blazor-live-preview-markdown-editors-content-using-markdig-library) +- [Markdown.ColorCode NuGet package](https://www.nuget.org/packages/Markdown.ColorCode) +- [16 Chat UI Design Patterns 2025](https://bricxlabs.com/articles/message-screen-ui-deisgn) +- [AI Chat Interface UX Patterns — UXPatterns.dev](https://uxpatterns.dev/patterns/ai-intelligence/ai-chat) +- [Conversational AI UI Comparison 2025 — IntuitionLabs](https://intuitionlabs.ai/articles/conversational-ai-ui-comparison-2025) +- [Token management best practices — OpenAI Community](https://community.openai.com/t/best-practices-for-cost-efficient-high-quality-context-management-in-long-ai-chats/1373996) + +--- +*Feature research for: Personal AI Chat WebApp (Blazor WebAssembly + OpenAI GPT)* +*Researched: 2026-03-27* diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md new file mode 100644 index 0000000..9693f4a --- /dev/null +++ b/.planning/research/PITFALLS.md @@ -0,0 +1,302 @@ +# Pitfalls Research + +**Domain:** Blazor WebAssembly AI Chat Application (OpenAI GPT, JSON storage, streaming) +**Researched:** 2026-03-27 +**Confidence:** HIGH (multiple authoritative sources: official GitHub issues, Microsoft docs, verified community findings) + +--- + +## Critical Pitfalls + +### Pitfall 1: Streaming Silently Broken in Blazor WASM Without Custom Transport + +**What goes wrong:** +OpenAI's .NET SDK streaming (`CompleteChatStreamingAsync`, returning `IAsyncEnumerable`) does not stream token-by-token in Blazor WASM. The entire response arrives at once after generation completes, making it appear like a non-streaming call. There is no error thrown — it just does not stream. This is confirmed in the official `openai-dotnet` GitHub issue tracker (#65). + +**Why it happens:** +Blazor WASM uses a browser-based `HttpClient` backed by the Fetch API via JS interop. Response streaming requires explicitly calling `SetBrowserResponseStreamingEnabled(true)` on the underlying `HttpRequestMessage`. The OpenAI .NET SDK does not set this flag by default. Without it, the browser buffers the entire response body before exposing it to the .NET layer. + +**How to avoid:** +Create a custom `HttpClientPipelineTransport` that overrides `OnSendingRequest` to enable browser streaming: + +```csharp +public class BlazorHttpClientTransport : HttpClientPipelineTransport +{ + protected override void OnSendingRequest( + PipelineMessage message, + HttpRequestMessage httpRequest) + { + httpRequest.SetBrowserResponseStreamingEnabled(true); + } +} + +// Wire up at client construction: +var options = new OpenAIClientOptions(); +options.Transport = new BlazorHttpClientTransport(); +var chatClient = new ChatClient(model: "gpt-4o", apiKey, options); +``` + +This must be done server-side (in the backend API that proxies to OpenAI), not in the WASM client directly. + +**Warning signs:** +- Tokens appear all at once after a delay instead of progressively +- No console errors — it looks like it is working but is not streaming +- Local dev works "fine" because the full response still arrives, just not incrementally + +**Phase to address:** +Phase that introduces streaming (SSE/token-by-token rendering). Must be addressed before any streaming demo is built. + +--- + +### Pitfall 2: OpenAI API Key Exposed in Blazor WASM Client + +**What goes wrong:** +A developer puts the OpenAI API key in `wwwroot/appsettings.json` or reads it from `IConfiguration` in a WASM component. The key is then downloadable by any browser visitor by requesting `/_framework/blazor.boot.json` or simply navigating to `wwwroot/appsettings.json` directly. The key is burned. + +**Why it happens:** +Experienced C# developers coming from ASP.NET Core or console apps expect `appsettings.json` and `IConfiguration` to be server-side. In Blazor WASM, `wwwroot/appsettings.json` is a static file served to the browser — it is not protected in any way. User Secrets also do not help: they are embedded into the published bundle in plaintext. + +**How to avoid:** +The OpenAI API key must live exclusively in the backend API (ASP.NET Core Minimal API or Web API project). The WASM client calls a backend endpoint (e.g., `/api/chat`) that holds the key and proxies requests to OpenAI. The key never appears in any client-side file or JS bundle. Use `dotnet user-secrets` on the server project only. + +**Warning signs:** +- Any `IConfiguration["OpenAI:ApiKey"]` usage in a `.razor` file or service registered in the WASM `Program.cs` +- `appsettings.json` in `wwwroot/` containing any token, key, or secret +- The word `sk-` visible in browser DevTools → Network → response body for any `.json` file + +**Phase to address:** +Phase 1 (project setup / architecture foundation). This must be locked in before any OpenAI code is written. + +--- + +### Pitfall 3: Scoped DI Services Act as Singletons in WASM — State Leaks Across Conversations + +**What goes wrong:** +A developer registers a `ConversationService` or `ChatStateService` as `Scoped`, expecting it to reset between logical "sessions" like it would in ASP.NET Core (per-request). In Blazor WASM there is exactly one DI scope for the lifetime of the browser tab. The service never resets. All conversations accumulate state in a single object, producing corrupted cross-conversation history. + +**Why it happens:** +In ASP.NET Core, Scoped = per HTTP request. In Blazor WASM, Scoped = per application lifetime (equivalent to Singleton). There is no shorter-lived scope unless you use `OwningComponentBase`. Developers familiar with server-side DI expect different behavior. + +**How to avoid:** +- Understand that in WASM, `Scoped` and `Singleton` are functionally identical +- For services that manage per-conversation state, design them to hold a collection keyed by conversation ID rather than holding mutable "current conversation" state +- If a service must be component-scoped, inherit from `OwningComponentBase` which creates a DI scope tied to the component's lifetime +- Never store mutable "active session" state in a scoped/singleton service; store a dictionary of `ConversationId → ConversationState` + +**Warning signs:** +- Switching conversations causes the wrong history to appear +- Deleting a conversation does not fully clear its state from memory +- Services have fields like `CurrentConversation` or `ActiveMessages` rather than `Dictionary` + +**Phase to address:** +Phase introducing conversation state management and multi-conversation switching. + +--- + +### Pitfall 4: `StateHasChanged` Not Called During Token Streaming — UI Freezes Until Completion + +**What goes wrong:** +The developer wires up streaming correctly (transport fixed, backend proxying works) but the UI does not update token-by-token. The message bubble stays empty until all tokens arrive, then the entire response appears at once. This is indistinguishable from the streaming transport bug (Pitfall 1) if not diagnosed carefully. + +**Why it happens:** +Blazor does not automatically re-render after every `await` inside an `async` event handler or lifecycle method. When consuming an `IAsyncEnumerable` (streaming tokens), the component must explicitly call `StateHasChanged()` after appending each token. Without this call, Blazor batches rendering and only repaints when the entire method completes. + +**How to avoid:** +```csharp +await foreach (var token in streamingResponse) +{ + currentMessage += token; + StateHasChanged(); // required — Blazor will not re-render otherwise + await Task.Yield(); // prevents UI thread starvation on rapid token delivery +} +``` + +Additionally, consider throttling `StateHasChanged` calls (e.g., every 50ms or every N tokens) to avoid excessive rendering if token delivery is very fast. + +**Warning signs:** +- Streaming transport is confirmed working (via backend logs) but UI still shows nothing until complete +- Token-by-token updates visible in server logs but not in the browser +- Removing `await Task.Yield()` causes the browser tab to become unresponsive during streaming + +**Phase to address:** +Streaming UI rendering phase. Document this explicitly inline in the component code. + +--- + +### Pitfall 5: JSON File Storage Architecture Assumes Server Filesystem — Not Viable Pure Client-Side + +**What goes wrong:** +A developer writes file I/O code (`File.ReadAllText`, `File.WriteAllText`) directly in the WASM project. The code compiles without error but throws at runtime because Blazor WASM runs in a browser sandbox with no access to the host filesystem. The virtual WASM filesystem resets on every page refresh. + +**Why it happens:** +C# file APIs exist in the WASM .NET runtime but map to an in-memory virtual filesystem, not the OS disk. Developers coming from console or desktop C# assume `File.WriteAllText("conversations.json", json)` writes to disk. It does not — the data vanishes on refresh. + +**How to avoid:** +JSON file storage must live in the backend API (server-side), not in the WASM client. The correct architecture: +- WASM client calls `POST /api/conversations` → backend writes JSON to disk +- WASM client calls `GET /api/conversations` → backend reads JSON from disk and returns it +- Backend stores files in a configurable local path (e.g., `~/chat-data/`) + +This reinforces the same architectural boundary required for API key protection (Pitfall 2). + +**Warning signs:** +- Any `System.IO.File` or `System.IO.Directory` usage inside the WASM project (`Client/`) +- Conversations persist during a session but disappear on browser refresh +- Data is present in the WASM virtual FS (`MemoryFileSystem`) but absent from the OS + +**Phase to address:** +Phase 1 (architecture setup). The WASM/backend split must be established before any persistence code is written. + +--- + +### Pitfall 6: IL Trimming Silently Breaks Code in Release Builds + +**What goes wrong:** +The app works perfectly in `dotnet run` (Debug) but breaks in `dotnet publish` (Release). JSON serialization loses properties, services cannot be resolved, or features silently stop working. No exceptions in development, cryptic failures in production. + +**Why it happens:** +Blazor WASM uses aggressive IL trimming (ILLink) during publish to reduce bundle size. The trimmer performs static analysis and removes types/methods that appear unreachable — including types used only via reflection (JSON serialization, DI, JSInterop callbacks). Debug builds do not trim. + +**How to avoid:** +- Use `[JsonSerializable]` with `System.Text.Json` source generation for all DTO types +- Apply `[DynamicDependency]` to methods called via reflection +- Apply `[JSInvokable]` to all methods callable from JavaScript +- Run `dotnet publish` early in the project (Phase 1 or 2) to detect trim warnings while the surface is small +- Treat `` as a last resort, not a first step + +**Warning signs:** +- App works in `dotnet run` but throws `NullReferenceException` or loses data after `dotnet publish` +- JSON responses missing properties that were present in debug +- IL trimmer warnings during publish that were ignored + +**Phase to address:** +Phase 1 (publish pipeline verification). Also Phase covering JSON data models for conversations. + +--- + +## Technical Debt Patterns + +| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | +|----------|-------------------|----------------|-----------------| +| Put API key in WASM `appsettings.json` | Faster to get OpenAI call working | Key is permanently burned; must rotate | Never | +| Call OpenAI directly from WASM HttpClient | Eliminates backend project | API key exposed; no server-side rate limiting; blocks v2 RAG/LangChain which needs server | Never | +| Write file I/O in WASM project | Familiar C# patterns | Silent data loss on refresh; hard to migrate later | Never | +| All logic in `.razor` files | Faster iteration in early phases | Untestable; components become unmaintainable; hard to add v2 agent layer | Phase 1 only, refactor before Phase 3 | +| Single `ChatService` singleton holding all state | Simple to start | State leaks across conversations; breaks multi-conversation feature | Never in this project — multi-conversation is a core requirement | +| Skip `StateHasChanged` calls during streaming | Code is simpler | UI appears broken; streaming appears non-functional | Never | +| Skip IL trim testing until "done" | Saves time during early phases | Trim bugs compound as codebase grows | Acceptable in Phase 1-2 if `dotnet publish` test is added to Phase 3 | + +--- + +## Integration Gotchas + +| Integration | Common Mistake | Correct Approach | +|-------------|----------------|-----------------| +| OpenAI .NET SDK + Blazor WASM | Using SDK directly in WASM project without custom transport — streaming silently broken | Custom `BlazorHttpClientTransport` with `SetBrowserResponseStreamingEnabled(true)` on backend; or server-side only call | +| OpenAI streaming via backend proxy | Backend returns full `StreamingChatCompletionUpdate` objects — client gets batched response | Backend uses `IAsyncEnumerable` with `[EnumeratorCancellation]`; streams SSE or NDJSON to client | +| CORS between WASM client and backend API | Forgetting `AddCors` + `UseCors` on backend; 403/CORS errors during local dev | Configure CORS policy explicitly in backend `Program.cs`; scope to localhost dev origins | +| JSON serialization of conversation models | Properties stripped by trimmer in Release; conversation history loses fields | Use `System.Text.Json` source generators with `[JsonSerializable]` for all model types | +| Markdown rendering (AI responses) | Using `MarkupString` with raw AI output — XSS risk if AI returns script tags | Use a library like `Markdig` server-side, or a client-side library with HTML sanitization enabled | + +--- + +## Performance Traps + +| Trap | Symptoms | Prevention | When It Breaks | +|------|----------|------------|----------------| +| Calling `StateHasChanged` on every token without throttling | Browser tab becomes unresponsive during fast streaming; CPU spikes | Throttle to every 50ms or every N tokens using a timer or counter | At ~20+ tokens/second (typical GPT-4o speed) | +| Re-rendering entire conversation list on every message append | Visible flicker; full list DOM re-created on each token | Use `@key` directive on conversation list items; isolate streaming component from sidebar | At 5+ conversations in the list | +| Loading all conversation history on app start | Slow initial load for users with many old conversations | Lazy-load conversation content; sidebar shows metadata only; load full messages on selection | At 50+ conversations stored in JSON | +| Excessive component nesting for chat messages | Sluggish scroll performance with many messages | Keep message list in a single component with virtualization (`Virtualize`) for long histories | At 200+ messages in a single conversation | + +--- + +## Security Mistakes + +| Mistake | Risk | Prevention | +|---------|------|------------| +| OpenAI API key in `wwwroot/appsettings.json` | Key visible to any browser user; unlimited API charges | Key lives only in backend server project; accessed via `dotnet user-secrets` or environment variable | +| Calling OpenAI API directly from WASM (no backend) | Same as above; also bypasses rate limiting and logging | Mandatory backend proxy — this is enforced by the architecture from Phase 1 | +| Rendering AI response HTML without sanitization | AI model could produce `