Created
April 16, 2026 14:57
-
-
Save timrourke/90f48eb11e82cacaed1ebf47b5ef7693 to your computer and use it in GitHub Desktop.
Freshpaint AI Chat Architecture - Interactive Reveal.js Presentation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>The Freshpaint AI Chat: A Love Story Between Go and React</title> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/black.css"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/monokai.css"> | |
| <style> | |
| :root { | |
| --r-background-color: #0d1117; | |
| --r-main-color: #e6edf3; | |
| --r-heading-color: #58a6ff; | |
| --r-link-color: #58a6ff; | |
| } | |
| .reveal { | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; | |
| } | |
| .reveal h1, .reveal h2, .reveal h3 { | |
| text-transform: none; | |
| font-weight: 700; | |
| } | |
| .reveal h1 { font-size: 2.0em; } | |
| .reveal h2 { font-size: 1.5em; } | |
| .reveal h3 { font-size: 1.2em; color: #7ee787; } | |
| .reveal code { | |
| font-family: 'SF Mono', 'Fira Code', monospace; | |
| } | |
| .reveal pre { | |
| width: 100%; | |
| font-size: 0.45em; | |
| box-shadow: none; | |
| } | |
| .reveal pre code { | |
| max-height: 520px; | |
| padding: 16px; | |
| border-radius: 8px; | |
| background: #161b22; | |
| } | |
| .mermaid-container { | |
| background: #161b22; | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin: 10px auto; | |
| } | |
| .mermaid { | |
| font-size: 14px; | |
| } | |
| .file-ref { | |
| color: #7ee787; | |
| font-size: 0.5em; | |
| font-style: italic; | |
| } | |
| .emoji-huge { | |
| font-size: 3em; | |
| display: block; | |
| margin: 20px 0; | |
| } | |
| .box { | |
| background: #161b22; | |
| border: 1px solid #30363d; | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin: 10px 0; | |
| } | |
| .box-green { border-color: #238636; } | |
| .box-blue { border-color: #1f6feb; } | |
| .box-orange { border-color: #d29922; } | |
| .box-red { border-color: #da3633; } | |
| .box-purple { border-color: #8b5cf6; } | |
| .two-col { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 20px; | |
| text-align: left; | |
| } | |
| .three-col { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 16px; | |
| text-align: left; | |
| } | |
| .reveal ul { font-size: 0.75em; text-align: left; } | |
| .reveal li { margin: 6px 0; } | |
| .reveal p { font-size: 0.8em; } | |
| .reveal .small { font-size: 0.6em; color: #8b949e; } | |
| .reveal .tiny { font-size: 0.45em; color: #8b949e; } | |
| .highlight-green { color: #7ee787; } | |
| .highlight-blue { color: #58a6ff; } | |
| .highlight-orange { color: #d29922; } | |
| .highlight-red { color: #f85149; } | |
| .highlight-purple { color: #d2a8ff; } | |
| .reveal table { | |
| font-size: 0.55em; | |
| margin: 0 auto; | |
| } | |
| .reveal table th { | |
| background: #1f6feb; | |
| color: white; | |
| padding: 8px 12px; | |
| } | |
| .reveal table td { | |
| padding: 6px 12px; | |
| border-bottom: 1px solid #30363d; | |
| } | |
| .reveal table tr:nth-child(even) td { | |
| background: #161b22; | |
| } | |
| .arrow-flow { | |
| font-size: 1.2em; | |
| color: #58a6ff; | |
| letter-spacing: 4px; | |
| } | |
| .phase-label { | |
| display: inline-block; | |
| padding: 4px 12px; | |
| border-radius: 20px; | |
| font-size: 0.6em; | |
| font-weight: bold; | |
| } | |
| .phase-1 { background: #238636; color: white; } | |
| .phase-2 { background: #1f6feb; color: white; } | |
| .phase-3 { background: #d29922; color: black; } | |
| .slide-number { color: #8b949e; } | |
| .chapter-title { | |
| font-size: 0.6em; | |
| color: #8b949e; | |
| text-transform: uppercase; | |
| letter-spacing: 4px; | |
| margin-bottom: 20px; | |
| } | |
| .vs-badge { | |
| display: inline-block; | |
| background: #da3633; | |
| color: white; | |
| padding: 2px 12px; | |
| border-radius: 20px; | |
| font-size: 0.6em; | |
| font-weight: bold; | |
| } | |
| /* Scale-down classes for slides that overflow 720px configured height */ | |
| .reveal section.shrink-mild { font-size: 0.88em; } | |
| .reveal section.shrink-moderate { font-size: 0.78em; } | |
| .reveal section.shrink-heavy { font-size: 0.62em; } | |
| .reveal section.shrink-mild .mermaid-container { padding: 8px; margin: 4px auto; transform: scale(0.90); transform-origin: top center; height: auto; } | |
| .reveal section.shrink-moderate .mermaid-container { padding: 4px; margin: 2px auto; transform: scale(0.72); transform-origin: top center; margin-bottom: -60px; } | |
| .reveal section.shrink-heavy .mermaid-container { padding: 2px; margin: 0 auto; transform: scale(0.55); transform-origin: top center; margin-bottom: -120px; } | |
| .reveal section.shrink-mild .box { padding: 12px; margin: 6px 0; } | |
| .reveal section.shrink-moderate .box { padding: 8px; margin: 4px 0; } | |
| .reveal section.shrink-heavy .box { padding: 6px; margin: 2px 0; } | |
| .reveal section.shrink-moderate pre { font-size: 0.42em; } | |
| .reveal section.shrink-heavy pre { font-size: 0.38em; } | |
| .reveal section.shrink-moderate table { font-size: 0.42em; } | |
| .reveal section.shrink-heavy table { font-size: 0.38em; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="reveal"> | |
| <div class="slides"> | |
| <!-- ===================== TITLE ===================== --> | |
| <section> | |
| <h1>The Freshpaint AI Chat</h1> | |
| <h3>A Love Story Between Go and React<br>(It's Complicated)</h3> | |
| <span class="emoji-huge">🤯</span> | |
| <p class="small">An absurdly thorough deep-dive into every single layer<br>of the most over-engineered chat system you'll ever love</p> | |
| <p class="tiny">Press <kbd>→</kbd> to begin your journey. Press <kbd>↓</kbd> for details within each chapter.<br>Press <kbd>Esc</kbd> for the overview map. Estimated runtime: however long it takes you to lose your mind.</p> | |
| </section> | |
| <!-- ===================== TABLE OF CONTENTS ===================== --> | |
| <section> | |
| <h2>The Journey Ahead</h2> | |
| <div class="three-col" style="font-size: 0.6em;"> | |
| <div class="box box-green"> | |
| <h3>Act I: The Setup</h3> | |
| <ol> | |
| <li>30,000 ft Overview</li> | |
| <li>The Database Tables</li> | |
| <li>The Type Zoo</li> | |
| </ol> | |
| </div> | |
| <div class="box box-blue"> | |
| <h3>Act II: The Backend</h3> | |
| <ol start="4"> | |
| <li>Router & Auth</li> | |
| <li>The Agent Loop</li> | |
| <li>The Tool System</li> | |
| <li>The SSE Machinery</li> | |
| </ol> | |
| </div> | |
| <div class="box box-orange"> | |
| <h3>Act III: The Frontend</h3> | |
| <ol start="8"> | |
| <li>SSE Client</li> | |
| <li>The Stream Hook</li> | |
| <li>Block Parsing</li> | |
| <li>Component Tree</li> | |
| </ol> | |
| </div> | |
| </div> | |
| <div class="box box-purple" style="font-size: 0.6em; margin-top: 10px;"> | |
| <h3>Grand Finale: Full Data Flow Walkthrough</h3> | |
| <p>The complete journey of a message, from keypress to pixels</p> | |
| </div> | |
| </section> | |
| <!-- ===================== ACT I: THE SETUP ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act I</div> | |
| <h1>The 30,000 Foot View</h1> | |
| <h3>(Where we pretend this is simple)</h3> | |
| <span class="emoji-huge">🦅</span> | |
| </section> | |
| <section> | |
| <h2>What Even Is This Thing?</h2> | |
| <p>Freshpaint AI Chat is a <span class="highlight-green">conversational analytics assistant</span> that:</p> | |
| <div class="two-col"> | |
| <div class="box box-green"> | |
| <ul> | |
| <li>Talks to Claude (Sonnet 4.6)</li> | |
| <li>Queries Snowflake via MCP</li> | |
| <li>Introspects your Freshpaint account</li> | |
| <li>Runs funnel queries</li> | |
| </ul> | |
| </div> | |
| <div class="box box-blue"> | |
| <ul> | |
| <li>Renders charts, tables, KPIs</li> | |
| <li>Proposes surveys</li> | |
| <li>Suggests follow-up questions</li> | |
| <li>Does weekly summaries</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <p class="small">Think of it as "ChatGPT but it can see all your analytics data and has strong opinions about your funnels"</p> | |
| </section> | |
| <section> | |
| <h2>The Architecture (Squint Version)</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| flowchart LR | |
| U["👤 User\n(Browser)"] -->|"POST /message"| B["🏗️ Go Backend\n(conversation_handler)"] | |
| B -->|"Creates stream"| R["📦 Registry\n(in-memory)"] | |
| B -->|"Calls Claude"| C["🤖 Claude API\n(Sonnet 4.6)"] | |
| C -->|"Tool calls"| T["🔧 Tools\n(Snowflake, Account, Emit)"] | |
| T -->|"Results"| C | |
| C -->|"Streaming tokens"| B | |
| B -->|"SSE events"| R | |
| R -->|"Fan-out"| S["📡 SSE Stream\n(GET /stream)"] | |
| S -->|"text_delta, block, done"| U | |
| B -->|"Persist"| DB["💾 PostgreSQL\n(messages table)"] | |
| style U fill:#238636,color:#fff | |
| style B fill:#1f6feb,color:#fff | |
| style R fill:#d29922,color:#000 | |
| style C fill:#8b5cf6,color:#fff | |
| style T fill:#da3633,color:#fff | |
| style S fill:#238636,color:#fff | |
| style DB fill:#30363d,color:#fff | |
| </pre> | |
| </div> | |
| <p class="small">"It's just a chat app, how complicated could it be?" — Famous last words</p> | |
| </section> | |
| <section> | |
| <h2>The Two API Patterns (Because Why Not Both)</h2> | |
| <div class="two-col"> | |
| <div class="box box-green"> | |
| <h3>Old Router Pattern</h3> | |
| <p class="small">POST with <code>Kind</code> string</p> | |
| <pre><code class="language-json">{ | |
| "Kind": "ai_chat_list_conversations", | |
| "Token": "...", | |
| "EnvID": "...", | |
| "Body": {} | |
| }</code></pre> | |
| <p class="tiny">Used for: list, save, approval</p> | |
| </div> | |
| <div class="box box-blue"> | |
| <h3>New HTTP Endpoints</h3> | |
| <p class="small">RESTful-ish paths</p> | |
| <pre><code class="language-text">GET /ai-chat/conversations/{id}/stream | |
| POST /ai-chat/conversations/{id}/message | |
| POST /ai-chat/conversations/{id}/cancel</code></pre> | |
| <p class="tiny">Used for: streaming, messaging, cancel</p> | |
| </div> | |
| </div> | |
| <p class="small">The streaming endpoints needed real HTTP semantics, so they got their own routes.<br>The CRUD stuff? Still living in 2024's router pattern. Coexistence is beautiful. 🤝</p> | |
| </section> | |
| </section> | |
| <!-- ===================== DATABASE ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act I, Scene 2</div> | |
| <h1>The Database Tables</h1> | |
| <h3>(Where data goes to live forever)</h3> | |
| <span class="emoji-huge">💾</span> | |
| </section> | |
| <section> | |
| <h2>Four Tables to Rule Them All</h2> | |
| <div class="two-col" style="font-size: 0.5em; gap: 12px;"> | |
| <div class="box box-green" style="padding: 12px;"> | |
| <h3 style="margin: 0 0 8px;">cro_chat_conversations</h3> | |
| <code>uuid id PK</code><br> | |
| <code>uuid env_id FK</code>, <code>text uid FK</code><br> | |
| <code>text title</code>, <code>text kind</code> <span class="tiny">(standard | weekly_summary)</span><br> | |
| <code>enum processing_status</code> <span class="tiny">(idle | processing | error)</span><br> | |
| <code>timestamptz last_message_at</code> | |
| </div> | |
| <div class="box box-blue" style="padding: 12px;"> | |
| <h3 style="margin: 0 0 8px;">cro_chat_messages</h3> | |
| <code>uuid id PK</code><br> | |
| <code>uuid conversation_id FK</code> → conversations<br> | |
| <code>text role</code> <span class="tiny">(user | assistant | system)</span><br> | |
| <code>jsonb content</code> <span class="tiny highlight-orange">← the big one</span><br> | |
| <code>int ordinal</code> <span class="tiny highlight-orange">← ordering!</span><br> | |
| <code>enum status</code>, <code>bool visible_to_user</code> | |
| </div> | |
| </div> | |
| <div class="two-col" style="font-size: 0.5em; gap: 12px; margin-top: 8px;"> | |
| <div class="box" style="padding: 12px;"> | |
| <h3 style="margin: 0 0 8px;">ai_chat_approvals</h3> | |
| <code>uuid id PK</code>, <code>uuid account_id FK</code>, <code>text consent_version</code> | |
| </div> | |
| <div class="box" style="padding: 12px;"> | |
| <h3 style="margin: 0 0 8px;">ai_snowflake_credentials</h3> | |
| <code>uuid account_id PK</code>, <code>text snowflake_pat</code> <span class="tiny">(encrypted!)</span> | |
| </div> | |
| </div> | |
| <p class="tiny">Full schema also has timestamps, uid, env_id on each table. File: db/migrations/perfalytics/20260305120000_cro_chat_conversations_and_messages.sql</p> | |
| </section> | |
| <section> | |
| <h2>The <code>content</code> JSONB Field</h2> | |
| <p>This single JSONB field stores EVERYTHING — here be dragons 🐉</p> | |
| <div class="two-col"> | |
| <div class="box box-green"> | |
| <h3>User Message</h3> | |
| <pre><code class="language-json">{ | |
| "blocks": [ | |
| { | |
| "type": "text", | |
| "text": "Show me conversion rates" | |
| } | |
| ] | |
| }</code></pre> | |
| </div> | |
| <div class="box box-blue"> | |
| <h3>Assistant Message</h3> | |
| <pre><code class="language-json">{ | |
| "blocks": [ | |
| {"type": "text", "text": "Let me analyze..."}, | |
| {"type": "tool_use", "name": "SQL_Execution_Tool", | |
| "id": "abc", "input": {"sql": "SELECT..."}}, | |
| {"type": "tool_use", "name": "emit_chart", | |
| "id": "def", "input": {"chartType": "bar", ...}}, | |
| {"type": "text", "text": "Here are the results!"} | |
| ] | |
| }</code></pre> | |
| </div> | |
| </div> | |
| <p class="small">The assistant content is the raw Anthropic SDK format — <code>text</code> blocks mixed with <code>tool_use</code> blocks.<br>Frontend never sees the raw SQL tool calls. Only <code>emit_*</code> tools are visible. More on this later!</p> | |
| </section> | |
| <section> | |
| <h2>Message Ordering: Why Ordinals Matter</h2> | |
| <p class="small"><strong class="highlight-orange">Problem:</strong> Batch-inserted messages get the same <code>created_at</code> — can't sort by time. 🤦<br> | |
| <strong class="highlight-green">Solution:</strong> <code>ordinal</code> — a monotonically increasing integer assigned during streaming.</p> | |
| <pre><code class="language-text">ordinal 1: user "Show me conversion rates" | |
| ordinal 2: assistant [text + SQL tool_use + emit_chart tool_use] | |
| ordinal 3: user [tool_result for SQL] ← hidden! | |
| ordinal 4: assistant [more text + emit_kpis tool_use] | |
| ordinal 5: user [tool_result for emit_kpis] ← hidden! | |
| ordinal 6: assistant "Here's your analysis!" ← final</code></pre> | |
| <p class="tiny">parent_message_id exists for provenance tracking but isn't used for ordering anymore</p> | |
| </section> | |
| <section> | |
| <h2>Processing Status: The Conversation Lock</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| stateDiagram-v2 | |
| direction LR | |
| [*] --> idle: Created | |
| idle --> processing: POST /message | |
| processing --> idle: Complete / Cancel | |
| processing --> error: Stream fails | |
| error --> processing: User retries | |
| </pre> | |
| </div> | |
| <p class="small">Registry.Create() rejects if already processing — only ONE active stream per conversation.<br>The Registry also enforces this at the in-memory level. Belt AND suspenders. 🩳</p> | |
| </section> | |
| </section> | |
| <!-- ===================== TYPE ZOO ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act I, Scene 3</div> | |
| <h1>The Type Zoo</h1> | |
| <h3>(Backend vs Frontend: a tale of two type systems)</h3> | |
| <span class="emoji-huge">🦒🐘🦩</span> | |
| </section> | |
| <section> | |
| <h2>Backend Types (Go)</h2> | |
| <p class="file-ref">back/aichat/types/types.go</p> | |
| <div class="two-col"> | |
| <div class="box box-green"> | |
| <h3>Typed IDs</h3> | |
| <pre><code class="language-go">type ChatConversationID struct { | |
| uuid.UUID | |
| } | |
| type ChatMessageID struct { | |
| uuid.UUID | |
| } | |
| // No bare UUIDs! No strings! | |
| // Compiler-enforced safety!</code></pre> | |
| </div> | |
| <div class="box box-blue"> | |
| <h3>Message Context</h3> | |
| <pre><code class="language-go">type MessageContext struct { | |
| MessageID string `json:"message_id"` | |
| Ordinal int `json:"ordinal"` | |
| } | |
| // Embedded in EVERY SSE event | |
| // Frontend uses this to correlate | |
| // events to messages</code></pre> | |
| </div> | |
| </div> | |
| <div class="two-col" style="margin-top: 10px;"> | |
| <div class="box box-orange"> | |
| <pre><code class="language-go">// "idle" | "processing" | "error" | |
| type ProcessingStatus string</code></pre> | |
| </div> | |
| <div class="box box-purple"> | |
| <pre><code class="language-go">// "standard" | "weekly_summary" | |
| type ConversationKind string</code></pre> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>Frontend Types (TypeScript)</h2> | |
| <p class="file-ref">front/src/ai-chat/useConversationStream.ts + front/src/api/ai-chat.ts</p> | |
| <div class="two-col"> | |
| <div class="box box-green"> | |
| <h3>API Type (wire format)</h3> | |
| <pre><code class="language-typescript">type AiChatMessage = { | |
| id: string; | |
| conversation_id: string; | |
| role: string; | |
| content: unknown; // 👀 !!! | |
| visible_to_user: boolean; | |
| };</code></pre> | |
| <p class="tiny"><code>content: unknown</code> — could be a string, could be <code>{"blocks": [...]}</code>, could be your hopes and dreams.</p> | |
| </div> | |
| <div class="box box-blue"> | |
| <h3>UI Type (display format)</h3> | |
| <pre><code class="language-typescript">type ChatMessage = { | |
| id: string; | |
| ordinal: number; | |
| role: "user" | "assistant"; | |
| content: string; | |
| blocks?: ParsedBlock[]; | |
| isStreaming?: boolean; | |
| isWeeklySummary?: boolean; | |
| };</code></pre> | |
| <p class="tiny">ChatMessage ≠ AiChatMessage. One is the API model, one is the UI model. On purpose. (Mostly.)</p> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>The Block Type Rosetta Stone</h2> | |
| <table style="font-size: 0.45em;"> | |
| <thead> | |
| <tr> | |
| <th>Backend BlockKind</th> | |
| <th>SSE Event</th> | |
| <th>emit_* Tool</th> | |
| <th>Frontend ParsedBlock</th> | |
| <th>React Component</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr><td class="highlight-green">text</td><td>text_delta / text_done</td><td>emit_text</td><td><code>{ kind: "text" }</code></td><td>MarkdownContent</td></tr> | |
| <tr><td class="highlight-green">table</td><td>block</td><td>emit_table</td><td><code>{ kind: "table" }</code></td><td>TableResponse</td></tr> | |
| <tr><td class="highlight-green">chart</td><td>block</td><td>emit_chart</td><td><code>{ kind: "chart" }</code></td><td>ChartResponse</td></tr> | |
| <tr><td class="highlight-green">funnel</td><td>block</td><td>emit_funnel</td><td><code>{ kind: "funnel" }</code></td><td>FunnelChart</td></tr> | |
| <tr><td class="highlight-green">kpis</td><td>block</td><td>emit_kpis</td><td><code>{ kind: "kpis" }</code></td><td>KpiResponse</td></tr> | |
| <tr><td class="highlight-green">suggestions</td><td>block</td><td>emit_suggestions</td><td><code>{ kind: "suggestions" }</code></td><td>SuggestionsResponse</td></tr> | |
| <tr><td class="highlight-green">thinking</td><td>block</td><td>emit_thinking</td><td><code>{ kind: "thinking" }</code></td><td>ThinkingBlock</td></tr> | |
| <tr><td class="highlight-green">survey</td><td>block</td><td>emit_survey</td><td><code>{ kind: "survey" }</code></td><td>SurveyProposalCard</td></tr> | |
| <tr><td class="highlight-green">survey_card</td><td>block</td><td>emit_survey_card</td><td><code>{ kind: "survey_card" }</code></td><td>SurveyCardResponse</td></tr> | |
| </tbody> | |
| </table> | |
| <p class="small">Every block type flows through the exact same pipeline: Claude calls an <code>emit_*</code> tool → backend emits SSE <code>block</code> event → frontend parses into <code>ParsedBlock</code> → React renders the component. Symmetry! 🎯</p> | |
| </section> | |
| </section> | |
| <!-- ===================== ACT II: THE BACKEND ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act II</div> | |
| <h1>Router & Auth</h1> | |
| <h3>(The bouncer at the door 🚪)</h3> | |
| <span class="emoji-huge">🔐</span> | |
| </section> | |
| <section> | |
| <h2>Route Registration</h2> | |
| <p class="file-ref">back/apiv2/server/router/router.go</p> | |
| <pre><code class="language-go">// The new hotness: direct HTTP routes | |
| aiChat := r.PathPrefix("/ai-chat").Subrouter() | |
| aiChat.Use(firebaseAuth) // Firebase JWT validation | |
| aiChat.HandleFunc( | |
| "/conversations/{conversationID}/stream", | |
| aichat.HandleConversationStream, | |
| ).Methods("GET") | |
| aiChat.HandleFunc( | |
| "/conversations/{conversationID}/message", | |
| aichat.HandleSendMessage, | |
| ).Methods("POST") | |
| aiChat.HandleFunc( | |
| "/conversations/{conversationID}/cancel", | |
| aichat.HandleCancelConversation, | |
| ).Methods("POST")</code></pre> | |
| <p class="small">The streaming endpoints bypass the old router pattern entirely — standard Go HTTP handlers with <code>gorilla/mux</code> path params.</p> | |
| </section> | |
| <section> | |
| <h2>The Triple Auth Gate</h2> | |
| <p class="file-ref">back/app/aichat/auth.go</p> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| flowchart LR | |
| R["Request"] --> A{"FP Employee\nor Admin?"} | |
| A -->|No| D1["❌ 403"] | |
| A -->|Yes| B{"Feature flag\nenabled?"} | |
| B -->|No| D2["❌ 404\n(hidden)"] | |
| B -->|Yes| C{"Account\napproved?"} | |
| C -->|No| D3["❌ 403"] | |
| C -->|Yes| E["✅ Proceed"] | |
| style D1 fill:#da3633,color:#fff | |
| style D2 fill:#da3633,color:#fff | |
| style D3 fill:#da3633,color:#fff | |
| style E fill:#238636,color:#fff | |
| </pre> | |
| </div> | |
| <p class="small">Three checks, three ways to fail. And if the feature flag is off, it returns 404 (not 403) so you don't even know the feature exists. Sneaky! 🕵️</p> | |
| </section> | |
| </section> | |
| <!-- ===================== THE AGENT LOOP ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act II, Scene 2</div> | |
| <h1>The Agent Loop</h1> | |
| <h3>(Where the real magic happens 🎩✨)</h3> | |
| <span class="emoji-huge">🔄</span> | |
| <p class="file-ref">back/aichat/agentloop/streaming.go</p> | |
| </section> | |
| <section> | |
| <h2>The Loop Structure</h2> | |
| <p>This is the beating heart of the system. Up to <span class="highlight-red">100 iterations</span> of Claude calls:</p> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| flowchart LR | |
| S["Start:\nagentloop.Run()"] --> B["Build tools"] | |
| B --> L{"Iter < 100?"} | |
| L -->|Yes| API["Call Claude\n(streaming)"] | |
| API --> EMIT["Emit SSE:\ntext_delta\nblock events"] | |
| EMIT --> STOP{"Stop\nReason?"} | |
| STOP -->|"tool_use"| TOOLS["Process\ntool calls"] | |
| TOOLS --> L | |
| STOP -->|"end_turn"| DONE["✅ Done:\nbuild final\ncontent"] | |
| L -->|"No"| DONE | |
| style S fill:#8b5cf6,color:#fff | |
| style API fill:#1f6feb,color:#fff | |
| style TOOLS fill:#da3633,color:#fff | |
| style DONE fill:#238636,color:#fff | |
| </pre> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>Streaming Event Processing</h2> | |
| <p class="file-ref">back/aichat/agentloop/streaming.go — the blockTracker</p> | |
| <pre><code class="language-go">// The block tracker accumulates streaming tokens | |
| type blockTracker struct { | |
| blockTypes map[int64]string // index → "text" or "tool_use" | |
| toolNames map[int64]string // index → tool name | |
| toolIDs map[int64]string // index → tool call ID | |
| partialJSON map[int64]*strings.Builder // index → accumulated JSON | |
| textAccum *strings.Builder // accumulated text | |
| } | |
| // Anthropic SDK streaming events map to actions: | |
| // "content_block_start" → record block type (text vs tool_use) | |
| // "content_block_delta" → accumulate text or JSON | |
| // → text: emit text_delta SSE event IMMEDIATELY | |
| // → json: buffer silently (can't emit partial JSON) | |
| // "content_block_stop" → finalize block | |
| // → text: emit text_done SSE event | |
| // → tool_use: parse complete JSON, process tool call</code></pre> | |
| <p class="small"><strong class="highlight-orange">Key:</strong> Text tokens stream in real time. Tool JSON is accumulated silently until complete — can't parse half a JSON object. That's why text is smooth but charts pop in all at once.</p> | |
| </section> | |
| <section> | |
| <h2>What Happens During a Tool Call</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant C as Claude API | |
| participant AL as Agent Loop | |
| participant MCP as Snowflake MCP | |
| participant DB as Account DB | |
| participant E as SSE Emitter | |
| C->>AL: content_block_stop (tool_use: SQL_Execution_Tool) | |
| AL->>MCP: CallTool("SQL_Execution_Tool", sql) | |
| MCP-->>AL: Query results (≤ 8KB) | |
| Note over AL: Add tool_result to message history | |
| C->>AL: content_block_stop (tool_use: emit_chart) | |
| AL->>E: SendBlock("chart", chartJSON) | |
| Note over E: SSE: event: block\ndata: {"kind":"chart",...} | |
| Note over AL: Return empty tool_result\n(response tools = fire-and-forget) | |
| C->>AL: content_block_stop (tool_use: Freshpaint_Get_Account_Overview) | |
| AL->>DB: handleGetAccountOverview(accountCtx) | |
| DB-->>AL: Account info JSON | |
| Note over AL: Add tool_result to message history | |
| </pre> | |
| </div> | |
| <p class="small">Three kinds of tools, three different behaviors. SQL and account tools feed data back to Claude. Response tools (emit_*) push data directly to the user and give Claude an empty acknowledgement.</p> | |
| </section> | |
| <section> | |
| <h2>The Final Content Assembly</h2> | |
| <p class="file-ref">back/aichat/agentloop/streaming.go — buildFinalContent()</p> | |
| <p>After ALL loop iterations, the accumulated blocks get compiled into one JSON blob:</p> | |
| <pre><code class="language-json">{ | |
| "blocks": [ | |
| { "dataType": "thinking", "summary": "Analyzing conversion data..." }, | |
| { "dataType": "text", "content": "Here's what I found..." }, | |
| { "dataType": "chart", "chartType": "line", "title": "CVR Over Time", | |
| "xAxisLabel": "Week", "series": [{"name": "CVR", "data": [...]}] }, | |
| { "dataType": "kpis", "items": [ | |
| {"label": "Total Sessions", "value": "14,230", "trend": "+12%"}, | |
| {"label": "CVR", "value": "3.2%", "trend": "-0.5%"} | |
| ]}, | |
| { "dataType": "suggestions", "suggestions": [ | |
| "What's driving the CVR decline?", | |
| "Show me the top converting segments" | |
| ]} | |
| ] | |
| }</code></pre> | |
| <p class="small">This JSON becomes the <code>Content</code> field in the persisted CROChatMessage and is also sent in the final <code>done</code> SSE event.</p> | |
| </section> | |
| </section> | |
| <!-- ===================== TOOL SYSTEM ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act II, Scene 3</div> | |
| <h1>The Tool System</h1> | |
| <h3>(Claude's superpowers 🦸)</h3> | |
| <span class="emoji-huge">🔧</span> | |
| </section> | |
| <section> | |
| <h2>Three Tool Categories</h2> | |
| <div class="three-col"> | |
| <div class="box box-blue"> | |
| <h3>🔍 Query Tools</h3> | |
| <p style="font-size: 0.6em;">Feed data TO Claude</p> | |
| <ul style="font-size: 0.55em;"> | |
| <li><code>SQL_Execution_Tool</code><br>→ Snowflake MCP</li> | |
| <li><code>query_funnel</code><br>→ Backend funnel engine</li> | |
| </ul> | |
| <p style="font-size: 0.5em;">Results go back into message history for Claude to analyze</p> | |
| </div> | |
| <div class="box box-green"> | |
| <h3>📋 Account Tools</h3> | |
| <p style="font-size: 0.6em;">Introspect Freshpaint</p> | |
| <ul style="font-size: 0.55em;"> | |
| <li><code>Get_Account_Overview</code></li> | |
| <li><code>List_Destinations</code></li> | |
| <li><code>Get_Destination_Detail</code></li> | |
| <li><code>List_Event_Definitions</code></li> | |
| <li><code>Get/List_Surveys</code></li> | |
| </ul> | |
| <p style="font-size: 0.5em;">Results also go back to Claude</p> | |
| </div> | |
| <div class="box box-orange"> | |
| <h3>📺 Response Tools</h3> | |
| <p style="font-size: 0.6em;">Push UI TO the user</p> | |
| <p style="font-size: 0.5em;"><code>emit_text</code>, <code>emit_table</code>, <code>emit_chart</code>,<br><code>emit_funnel</code>, <code>emit_kpis</code>, <code>emit_suggestions</code>,<br><code>emit_thinking</code>, <code>emit_survey[_card]</code></p> | |
| <p style="font-size: 0.5em;">Results go to user's screen via SSE. Claude gets an empty ACK.</p> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>The Snowflake MCP Connection</h2> | |
| <p class="file-ref">back/aichat/snowflake/mcp.go</p> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant AL as Agent Loop | |
| participant MCP as MCPClient | |
| participant SF as Snowflake MCP | |
| AL->>MCP: CallTool("SQL_Execution_Tool", sql) | |
| MCP->>MCP: LookupCredentials (pgp_sym_decrypt) | |
| MCP->>SF: POST JSON-RPC 2.0 tools/call | |
| SF-->>MCP: Query results | |
| MCP->>MCP: Truncate to 8KB if needed | |
| MCP-->>AL: Tool result content | |
| </pre> | |
| </div> | |
| <p class="small">Credentials encrypted with <code>pgp_sym_encrypt</code> in Postgres. Results hard-capped at 8KB — Claude gets a "try LIMIT" hint if truncated.</p> | |
| </section> | |
| <section> | |
| <h2>Response Tools: The "emit_" Pattern</h2> | |
| <p class="file-ref">back/aichat/tools/response.go</p> | |
| <pre><code class="language-go">// Response tools are defined from embedded JSON schemas | |
| //go:embed schema/tool_emit_chart.schema.json | |
| var emitChartSchemaJSON []byte | |
| // The magic: IsResponseTool checks for the "emit_" prefix | |
| func IsResponseTool(name string) bool { | |
| return strings.HasPrefix(name, "emit_") | |
| } | |
| // When Claude calls an emit_* tool, the backend: | |
| // 1. Immediately emits an SSE "block" event to the user | |
| // 2. Returns an empty tool result to Claude | |
| // 3. Adds the block to the final content accumulator | |
| // It's tools all the way down 🐢</code></pre> | |
| <p class="small"><strong>Key insight:</strong> Claude doesn't generate HTML — it calls typed tools. The frontend renders each block type. The 64KB system prompt teaches Claude when to use each emit tool.</p> | |
| </section> | |
| </section> | |
| <!-- ===================== SSE MACHINERY ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act II, Scene 4</div> | |
| <h1>The SSE Machinery</h1> | |
| <h3>(The plumbing that makes it all flow 🚿)</h3> | |
| <span class="emoji-huge">📡</span> | |
| </section> | |
| <section> | |
| <h2>The Three-Phase GET Handler</h2> | |
| <p class="file-ref">back/app/aichat/conversation_handler.go — HandleConversationStream()</p> | |
| <div class="three-col" style="font-size: 0.55em;"> | |
| <div class="box box-green"> | |
| <h3>Phase 1: Replay</h3> | |
| <ol> | |
| <li>Load persisted messages from DB</li> | |
| <li>Filter for user visibility</li> | |
| <li>Emit: <code>conversation_info</code></li> | |
| <li>Emit: <code>message</code> x N</li> | |
| <li>Emit: <code>replay_done</code></li> | |
| </ol> | |
| </div> | |
| <div class="box box-blue"> | |
| <h3>Phase 2: Active Stream</h3> | |
| <ul> | |
| <li><code>Registry.Get(id)</code></li> | |
| <li>If stream exists + done → return</li> | |
| <li>If stream exists + active → <code>drainStream()</code></li> | |
| <li>If no stream → Phase 3</li> | |
| </ul> | |
| </div> | |
| <div class="box box-orange"> | |
| <h3>Phase 3: Poll</h3> | |
| <ul> | |
| <li>Fast poll: <strong>500ms</strong> — check registry for new stream</li> | |
| <li>Slow keepalive: <strong>30s</strong> — prevent timeout</li> | |
| <li>When stream appears → <code>drainStream()</code></li> | |
| </ul> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>The Stream Registry</h2> | |
| <p class="file-ref">back/aichat/registry/registry.go</p> | |
| <p>The in-memory event bus that connects POST (producer) to GET (consumer):</p> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| flowchart LR | |
| subgraph POST["POST /message (Producer)"] | |
| AGENT["Agent Loop"] -->|"Emit(event)"| STREAM["ConversationStream\n• events[] buffer\n• subscribers map\n• done bool"] | |
| end | |
| subgraph GETS["GET /stream (Consumers)"] | |
| STREAM -->|"Subscribe()"| S1["Subscriber 1\n(original tab)"] | |
| STREAM -->|"Subscribe()"| S2["Subscriber 2\n(reconnected tab)"] | |
| STREAM -->|"Subscribe()"| S3["Subscriber 3\n(new tab)"] | |
| end | |
| STREAM -->|"Complete()"| REAPER["Reaper\n(cleanup after 5 min)"] | |
| style POST fill:#1f6feb,color:#fff | |
| style GETS fill:#238636,color:#fff | |
| style REAPER fill:#da3633,color:#fff | |
| </pre> | |
| </div> | |
| <p class="small"><strong>Emit()</strong> appends to buffer + fans out. <strong>Subscribe()</strong> returns ALL buffered events + channel. <strong>Complete()</strong> closes channels. <strong>Reaper</strong> cleans up after 5 min TTL.</p> | |
| </section> | |
| <section> | |
| <h2>The POST → GET Race Condition</h2> | |
| <p>What happens when GET arrives before the POST's goroutine creates the stream?</p> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant FE as Frontend | |
| participant POST as POST Handler | |
| participant GET as GET Handler | |
| participant REG as Registry | |
| FE->>POST: POST /message | |
| POST-->>FE: 200 OK {message_id, ordinal} | |
| FE->>GET: GET /stream (reconnect) | |
| GET->>REG: Registry.Get(id) | |
| REG-->>GET: nil (not created yet!) | |
| Note over GET: Phase 3: poll every 500ms | |
| POST->>REG: Registry.Create(id) | |
| GET->>REG: Registry.Get(id) [500ms later] | |
| REG-->>GET: ConversationStream ✅ | |
| GET->>REG: Subscribe() | |
| </pre> | |
| </div> | |
| <p class="small">The 500ms fast poll in Phase 3 exists specifically to handle this race.<br>Without it, the GET would miss the stream entirely. 🏎️💨</p> | |
| </section> | |
| <section> | |
| <h2>SSE Wire Format</h2> | |
| <p class="file-ref">back/aichat/emitter/internal/sse/sse.go</p> | |
| <p>What actually goes over the wire:</p> | |
| <pre><code class="language-text">event: conversation_info | |
| data: {"kind":"standard"} | |
| event: message | |
| data: {"message_id":"abc-123","ordinal":1,"role":"user","content":"{\"blocks\":[...]}"} | |
| event: message | |
| data: {"message_id":"def-456","ordinal":2,"role":"assistant","content":"{\"blocks\":[...]}"} | |
| event: replay_done | |
| data: {"kind":"standard"} | |
| event: text_delta | |
| data: {"message_id":"ghi-789","ordinal":3,"delta":"Here"} | |
| event: text_delta | |
| data: {"message_id":"ghi-789","ordinal":3,"delta":"'s what"} | |
| event: text_delta | |
| data: {"message_id":"ghi-789","ordinal":3,"delta":" I found..."} | |
| event: block | |
| data: {"message_id":"ghi-789","ordinal":3,"kind":"chart","data":{"chartType":"bar",...}} | |
| event: done | |
| data: {"message_id":"ghi-789","ordinal":3,"content":"{\"blocks\":[...]}"} | |
| event: keepalive | |
| data: {}</code></pre> | |
| </section> | |
| </section> | |
| <!-- ===================== ACT III: THE FRONTEND ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act III</div> | |
| <h1>The SSE Client</h1> | |
| <h3>(Parsing bytes into meaning 📖)</h3> | |
| <span class="emoji-huge">🌐</span> | |
| <p class="file-ref">front/src/ai-chat/sseClient.ts — 310 lines</p> | |
| </section> | |
| <section> | |
| <h2>Connection Lifecycle</h2> | |
| <pre><code class="language-typescript">// openConversationStream uses Fetch API + ReadableStream (NOT EventSource!) | |
| const response = await fetch(url, { | |
| headers: { Authorization: `Bearer ${token}` }, | |
| signal: abortController.signal, | |
| }); | |
| const reader = response.body!.getReader(); | |
| const decoder = new TextDecoder(); | |
| let remainder = ""; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value, { stream: true }); | |
| const { events, remainder: rest } = parseSSEChunk(remainder + chunk); | |
| remainder = rest; | |
| for (const event of events) { | |
| dispatchSSEEvent(event, handlers); // Route to the right handler | |
| } | |
| }</code></pre> | |
| <p class="small"><strong class="highlight-orange">Why Fetch instead of EventSource?</strong> EventSource doesn't support custom headers (no <code>Authorization: Bearer</code>). So we use Fetch + ReadableStream and parse SSE manually. Classic.</p> | |
| </section> | |
| <section> | |
| <h2>SSE Parsing & Dispatch</h2> | |
| <pre><code class="language-typescript">// parseSSEChunk: Splits raw text into event objects | |
| // Input: "event: text_delta\ndata: {\"delta\":\"Hello\"}\n\n" | |
| // Output: [{ event: "text_delta", data: "{\"delta\":\"Hello\"}" }] | |
| // dispatchSSEEvent: Routes events to handlers | |
| function dispatchSSEEvent(event, handlers) { | |
| const data = JSON.parse(event.data); | |
| switch (event.event) { | |
| case "conversation_info": return handlers.onConversationInfo(data); | |
| case "message": return handlers.onMessage(data); | |
| case "replay_done": return handlers.onReplayDone(data); | |
| case "text_delta": return handlers.onTextDelta(data); | |
| case "text_done": return handlers.onTextDone(data); | |
| case "block": return handlers.onBlock(data); | |
| case "done": return handlers.onDone(data); | |
| case "status": return handlers.onStatus(data); | |
| case "error": return handlers.onError(data); | |
| case "keepalive": break; // 🥱 | |
| } | |
| }</code></pre> | |
| </section> | |
| <section> | |
| <h2>Reconnection Strategy</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| flowchart LR | |
| DISC["Connection\nLost"] --> TERM{"Terminal\nevent?"} | |
| TERM -->|"yes"| STOP["❌ Stop"] | |
| TERM -->|"no"| RETRY{"Retries\n< 5?"} | |
| RETRY -->|"no"| GIVEUP["❌ Give up"] | |
| RETRY -->|"yes"| WAIT["Backoff:\n1s→2s→4s→30s"] | |
| WAIT --> RECONNECT["🔄 Reconnect"] | |
| RECONNECT --> DATA{"Data?"} | |
| DATA -->|"yes"| RESET["Reset\ncounters"] | |
| DATA -->|"no"| RETRY | |
| style STOP fill:#da3633,color:#fff | |
| style GIVEUP fill:#da3633,color:#fff | |
| style RECONNECT fill:#238636,color:#fff | |
| style RESET fill:#238636,color:#fff | |
| </pre> | |
| </div> | |
| <p class="small">Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s (capped).<br>Max 5 retries without receiving any data. Resets on success. Elegant! 🎩</p> | |
| </section> | |
| </section> | |
| <!-- ===================== THE STREAM HOOK ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act III, Scene 2</div> | |
| <h1>useConversationStream</h1> | |
| <h3>(The 537-line hook that does everything 😅)</h3> | |
| <span class="emoji-huge">🪝</span> | |
| <p class="file-ref">front/src/ai-chat/useConversationStream.ts</p> | |
| </section> | |
| <section> | |
| <h2>Hook State Machine</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| stateDiagram-v2 | |
| direction LR | |
| [*] --> Loading: Mount | |
| Loading --> Replaying: SSE opens | |
| Replaying --> Replaying: onMessage | |
| Replaying --> Ready: onReplayDone | |
| Ready --> Streaming: sendPrompt() | |
| Streaming --> Streaming: onTextDelta / onBlock | |
| Streaming --> Ready: onDone / onError / cancel | |
| </pre> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>The Send Flow</h2> | |
| <pre><code class="language-typescript">async function sendPrompt(prompt: string, kind: ConversationKind) { | |
| // 1. Create optimistic user message | |
| const userMsg: ChatMessage = { | |
| id: crypto.randomUUID(), | |
| ordinal: Infinity, // Will be replaced by server ordinal | |
| role: "user", | |
| content: prompt, | |
| }; | |
| // 2. Create streaming placeholder for assistant | |
| const placeholder: ChatMessage = { | |
| id: crypto.randomUUID(), | |
| ordinal: Infinity, | |
| role: "assistant", | |
| content: "", | |
| blocks: [], | |
| isStreaming: true, // Shows pulsing dots | |
| }; | |
| // 3. Add both to messages immediately (optimistic UI!) | |
| setMessages(prev => [...prev, userMsg, placeholder]); | |
| // 4. POST to server — get back the real message_id + ordinal | |
| const ack = await sendMessage(conversationId, envId, { ... }); | |
| // 5. Reconnect SSE to pick up the new stream | |
| conn.reconnect(); | |
| }</code></pre> | |
| </section> | |
| <section> | |
| <h2>Text Streaming Accumulation</h2> | |
| <pre><code class="language-typescript">// The textAccumRef — tracks streaming text state | |
| const textAccumRef = useRef({ | |
| messageId: "", | |
| content: "", // Growing string of text | |
| hasActiveTextBlock: false, | |
| }); | |
| // onTextDelta: Called for every token from Claude | |
| function onTextDelta(data: { message_id, ordinal, delta }) { | |
| const accum = textAccumRef.current; | |
| accum.content += data.delta; // Append the new token | |
| if (!accum.hasActiveTextBlock) { | |
| // First delta for this block — create a new text block | |
| appendBlockToMessage(data.message_id, { | |
| kind: "text", | |
| content: accum.content, | |
| }); | |
| accum.hasActiveTextBlock = true; | |
| } else { | |
| // Update the LAST block's content (in-place mutation for perf) | |
| updateLastBlock(data.message_id, (block) => ({ | |
| ...block, | |
| content: accum.content, | |
| })); | |
| } | |
| } | |
| // onTextDone: Reset the accumulator | |
| function onTextDone(data) { | |
| textAccumRef.current = { messageId: "", content: "", hasActiveTextBlock: false }; | |
| }</code></pre> | |
| <p class="small">This is why text streams smoothly — each token appends to the accumulator, and the last text block is updated in place. React re-renders with the growing string.</p> | |
| </section> | |
| <section> | |
| <h2>Block Event Handling</h2> | |
| <pre><code class="language-typescript">// onBlock: Structured data blocks arrive all at once | |
| function onBlock(data: { message_id, ordinal, kind, data: unknown }) { | |
| const parsed = parseSSEBlock(data.kind, data.data); | |
| // ↑ "chart" ↑ {chartType: "bar", ...} | |
| if (!parsed) return; // Invalid block, skip | |
| // Append the parsed block to the streaming message | |
| const idx = findOrAssignMessageIndex(data.message_id, data.ordinal); | |
| setMessages(prev => { | |
| const updated = [...prev]; | |
| const msg = { ...updated[idx] }; | |
| msg.blocks = [...(msg.blocks || []), parsed]; | |
| updated[idx] = msg; | |
| return updated; | |
| }); | |
| } | |
| // onDone: Stream complete — set final content, stop streaming indicator | |
| function onDone(data: { message_id, ordinal, content }) { | |
| const finalBlocks = parseStructuredResponse(data.content); | |
| // Replace streaming blocks with the canonical final version | |
| setMessages(prev => /* update message with final blocks, isStreaming=false */); | |
| }</code></pre> | |
| <div class="box box-green" style="font-size: 0.6em;"> | |
| <p><strong>Key detail:</strong> When <code>onDone</code> fires, the message gets its FINAL content from the server, which may differ slightly from the incrementally-built streaming version. This ensures consistency between what's displayed and what's persisted.</p> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>The Message Merge Magic</h2> | |
| <p>During replay, consecutive assistant messages get merged:</p> | |
| <pre><code class="language-typescript">// Why merge? Because multi-turn tool calls create this in the DB: | |
| // ordinal 2: assistant [text + SQL tool_use] | |
| // ordinal 3: user [tool_result] ← hidden | |
| // ordinal 4: assistant [text + emit_chart tool_use] | |
| // ordinal 5: user [tool_result] ← hidden | |
| // ordinal 6: assistant [final text] | |
| // | |
| // After filtering out hidden messages: | |
| // ordinal 2: assistant [text] | |
| // ordinal 4: assistant [chart] | |
| // ordinal 6: assistant [text] | |
| // | |
| // These LOOK like 3 separate messages but the user saw ONE response! | |
| // mergeConsecutiveAssistantMessages() combines them: | |
| // → assistant [text + chart + text] ← one message, all blocks | |
| function mergeConsecutiveAssistantMessages(msgs: ChatMessage[]): ChatMessage[] { | |
| // Walk through messages, combine consecutive assistant msgs | |
| // Keep the FIRST message's id, accumulate all blocks | |
| }</code></pre> | |
| <p class="small">This is called in <code>onReplayDone</code> after all historical messages are buffered.<br>Without it, reloading the page would show a confusing split conversation. 🧩</p> | |
| </section> | |
| </section> | |
| <!-- ===================== BLOCK PARSING ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act III, Scene 3</div> | |
| <h1>Block Parsing</h1> | |
| <h3>(Turning JSON into React components 🧪)</h3> | |
| <span class="emoji-huge">🧬</span> | |
| </section> | |
| <section> | |
| <h2>Two Parsing Paths</h2> | |
| <p class="file-ref">front/src/ai-chat/parseStructuredResponse.ts</p> | |
| <div class="two-col"> | |
| <div class="box box-green"> | |
| <h3>Streaming: parseSSEBlock()</h3> | |
| <pre><code class="language-typescript">parseSSEBlock(kind, data) | |
| // kind from SSE event name | |
| // Zod schema WITHOUT dataType | |
| // → ParsedBlock | null</code></pre> | |
| </div> | |
| <div class="box box-blue"> | |
| <h3>Replay: parseStructuredResponse()</h3> | |
| <pre><code class="language-typescript">parseStructuredResponse(raw) | |
| // raw: '{"blocks":[...]}' | |
| // Zod schema WITH dataType | |
| // → ParsedBlock[] | null</code></pre> | |
| </div> | |
| </div> | |
| <p class="small"><strong class="highlight-orange">Why two paths?</strong> During streaming, <code>kind</code> comes from the SSE event name (no discriminator needed). During replay, blocks are in a JSON array and need <code>dataType</code> to tell them apart. Same data, different packaging. 📦</p> | |
| </section> | |
| <section> | |
| <h2>Zod Schema Validation</h2> | |
| <p class="file-ref">front/src/ai-chat/schema/assistantResponse.ts</p> | |
| <pre><code class="language-typescript">// Every block type has a Zod schema with runtime validation | |
| const ChartBlockSchema = z.object({ | |
| dataType: z.literal("chart"), | |
| chartType: z.enum(["bar", "line", "area"]), | |
| title: z.string(), | |
| xAxisLabel: z.string(), | |
| yAxisLabel: z.string().optional(), | |
| series: z.array(z.object({ | |
| name: z.string(), | |
| data: z.array(z.union([z.number(), z.null()])), | |
| })), | |
| categories: z.array(z.string()).optional(), | |
| dataSource: z.string().optional(), | |
| }); | |
| // Response types strip the discriminator for component props: | |
| type ChartResponseBlock = Omit<ChartBlock, "dataType"> | |
| // The full schema is a discriminated union: | |
| const BlockSchema = z.discriminatedUnion("dataType", [ | |
| TextBlockSchema, TableBlockSchema, ChartBlockSchema, | |
| FunnelBlockSchema, KpiBlockSchema, SuggestionsBlockSchema, | |
| ThinkingBlockSchema, SurveyBlockSchema, SurveyCardBlockSchema, | |
| ]);</code></pre> | |
| <p class="small">Zod catches malformed blocks at runtime before they crash a React component.<br>If parsing fails, the block is silently dropped. Better no chart than a broken chart. 📊💥</p> | |
| </section> | |
| </section> | |
| <!-- ===================== COMPONENT TREE ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Act III, Scene 4</div> | |
| <h1>The Component Tree</h1> | |
| <h3>(Where blocks become pixels 🖼️)</h3> | |
| <span class="emoji-huge">🌳</span> | |
| </section> | |
| <section> | |
| <h2>Component Hierarchy</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| flowchart LR | |
| PAGE["AiChatPage"] --> GATE["ApprovalGate"] | |
| GATE --> SIDEBAR["Sidebar"] | |
| GATE --> CHAT["ChatArea"] | |
| CHAT --> LOOP["messages.map()"] | |
| LOOP --> USER["User Bubble"] | |
| LOOP --> ASST["AssistantMessage"] | |
| ASST --> BLOCKS["ThinkingBlock\nMarkdownContent\nTableResponse\nChartResponse\nFunnelChart\nKpiResponse\nSuggestionsResponse\nSurveyProposalCard\nPulsingDots"] | |
| style PAGE fill:#8b5cf6,color:#fff | |
| style CHAT fill:#1f6feb,color:#fff | |
| style ASST fill:#238636,color:#fff | |
| style BLOCKS fill:#d29922,color:#000 | |
| </pre> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>AssistantMessage: The Block Router</h2> | |
| <p class="file-ref">front/src/ai-chat/components/AssistantMessage.tsx</p> | |
| <pre><code class="language-typescript">// This component is beautifully simple — it just maps blocks to components | |
| function AssistantMessage({ blocks, isStreaming, onSuggestionSelect, ... }) { | |
| const thinkingBlocks = blocks.filter(b => b.kind === "thinking"); | |
| const contentBlocks = blocks.filter(b => b.kind !== "thinking"); | |
| return ( | |
| <> | |
| {thinkingBlocks.length > 0 && ( | |
| <ThinkingBlock steps={thinkingBlocks} isStreaming={isStreaming} /> | |
| )} | |
| {contentBlocks.map((block, i) => { | |
| switch (block.kind) { | |
| case "text": return <MarkdownContent content={block.content} />; | |
| case "table": return <TableResponse data={block.data} />; | |
| case "chart": return <ChartResponse data={block.data} />; | |
| case "funnel": return <FunnelChart data={block.data} />; | |
| case "kpis": return <KpiResponse data={block.data} />; | |
| case "suggestions": return <SuggestionsResponse ... />; | |
| case "survey": return <SurveyProposalCard ... />; | |
| case "survey_card": return <SurveyCardResponse ... />; | |
| } | |
| })} | |
| {isStreaming && <PulsingDots />} | |
| </> | |
| ); | |
| }</code></pre> | |
| <p class="small">Clean switch statement. Each block type gets its own component. No inheritance, no polymorphism, just a good old-fashioned switch. Sometimes simple is best. ✨</p> | |
| </section> | |
| </section> | |
| <!-- ===================== GRAND FINALE ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Grand Finale</div> | |
| <h1>The Complete Data Flow</h1> | |
| <h3>(Every step, from keypress to pixels 🎬)</h3> | |
| <span class="emoji-huge">🏁</span> | |
| </section> | |
| <section> | |
| <h2>Step 1: User Types a Question</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant U as User (Browser) | |
| participant CA as ChatArea | |
| participant H as useConversationStream | |
| participant SSE as sseClient | |
| U->>CA: Types "Show me conversion rates" | |
| U->>CA: Presses Enter | |
| CA->>H: sendPrompt("Show me conversion rates", "standard") | |
| Note over H: Create optimistic messages: | |
| Note over H: 1. User msg (ordinal=∞) | |
| Note over H: 2. Assistant placeholder (ordinal=∞, isStreaming=true) | |
| H->>CA: setMessages([...prev, userMsg, placeholder]) | |
| Note over CA: React re-renders with user bubble + pulsing dots | |
| H->>SSE: sendMessage(POST /message) | |
| SSE-->>H: { message_id: "abc", ordinal: 3 } | |
| H->>SSE: conn.reconnect() | |
| Note over SSE: Closes old connection, opens new GET /stream | |
| </pre> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>Step 2: Backend Processes the Request</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant FE as Frontend | |
| participant POST as POST Handler | |
| participant DB as PostgreSQL | |
| participant REG as Registry | |
| participant AL as Agent Loop | |
| FE->>POST: POST /message {content, env_id, kind} | |
| POST->>POST: Auth check (triple gate) | |
| POST->>DB: Persist user message (ordinal assigned) | |
| POST->>DB: UpdateProcessingStatus(processing) | |
| POST-->>FE: 200 OK {message_id, ordinal: 3} | |
| Note over POST: Launch background goroutine | |
| POST->>REG: Registry.Create(conversationID) | |
| Note over REG: New ConversationStream created | |
| POST->>AL: agentloop.Run() with registryEmitter | |
| Note over AL: The agent loop begins... | |
| </pre> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>Step 3a: Claude Streams Text & Queries Data</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant AL as Agent Loop | |
| participant C as Claude (Sonnet 4.6) | |
| participant SF as Snowflake MCP | |
| participant REG as Registry → SSE | |
| AL->>C: Messages + Tools + System Prompt (64KB) | |
| C-->>AL: text_delta "Let me " | |
| AL->>REG: emit text_delta | |
| C-->>AL: text_delta "check..." | |
| AL->>REG: emit text_delta | |
| C-->>AL: content_block_stop (text) | |
| AL->>REG: emit text_done | |
| C-->>AL: tool_use: SQL_Execution_Tool | |
| AL->>SF: CallTool(sql) | |
| SF-->>AL: Results (≤ 8KB) | |
| AL->>C: Continue with tool_result | |
| </pre> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>Step 3b: Claude Emits UI Blocks</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant AL as Agent Loop | |
| participant C as Claude (Sonnet 4.6) | |
| participant REG as Registry → SSE | |
| C-->>AL: tool_use: emit_chart | |
| AL->>REG: emit block {kind:"chart"} | |
| C-->>AL: tool_use: emit_kpis | |
| AL->>REG: emit block {kind:"kpis"} | |
| C-->>AL: tool_use: emit_suggestions | |
| AL->>REG: emit block {kind:"suggestions"} | |
| C-->>AL: end_turn | |
| AL->>REG: emit done {content: final JSON} | |
| </pre> | |
| </div> | |
| <p class="small">Query tools feed data back to Claude. Response tools (emit_*) push blocks directly to the user's screen.</p> | |
| </section> | |
| <section> | |
| <h2>Step 4a: Frontend Streams Text</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant SSE as SSE Client | |
| participant H as useConversationStream | |
| participant R as React | |
| SSE->>H: onTextDelta "Let me " | |
| H->>H: textAccumRef += delta | |
| H->>R: MarkdownContent re-renders | |
| SSE->>H: onTextDelta "check..." | |
| H->>R: Update last text block | |
| SSE->>H: onTextDone | |
| H->>H: Reset accumulator | |
| </pre> | |
| </div> | |
| <p class="small">Each text_delta appends to the accumulator. The last text block is updated in-place. React re-renders with the growing string.</p> | |
| </section> | |
| <section> | |
| <h2>Step 4b: Frontend Receives Blocks & Done</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant SSE as SSE Client | |
| participant H as useConversationStream | |
| participant P as parseSSEBlock | |
| participant R as React Components | |
| SSE->>H: onBlock {kind:"chart", data:{...}} | |
| H->>P: parseSSEBlock("chart", data) | |
| P-->>H: ParsedBlock | |
| H->>R: ChartResponse appears! 📊 | |
| SSE->>H: onBlock {kind:"kpis", data:{...}} | |
| H->>R: KpiResponse appears! 📈 | |
| SSE->>H: onDone {content: finalJSON} | |
| H->>H: Replace streaming blocks with final | |
| H->>H: isStreaming = false | |
| H->>R: PulsingDots disappears ✅ | |
| </pre> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>Step 5: Persistence (Backend Cleanup)</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant AL as Agent Loop | |
| participant POST as POST Handler | |
| participant DB as PostgreSQL | |
| participant REG as Registry | |
| AL-->>POST: Returns {content, messages, nil} | |
| POST->>DB: AddMessagesToConversation(messages) | |
| POST->>DB: UpdateProcessingStatus(idle) | |
| POST->>REG: stream.Complete() | |
| Note over REG: Stream buffered 5 min, then reaped | |
| </pre> | |
| </div> | |
| <p class="small">Messages are persisted AFTER the stream completes, not during.<br>If the server crashes mid-stream, you lose the in-progress response. But the next GET /stream will replay whatever was already saved. Tradeoff accepted. 🤷</p> | |
| </section> | |
| </section> | |
| <!-- ===================== CONTENT FILTERING ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Bonus</div> | |
| <h1>Content Filtering</h1> | |
| <h3>(What the user sees vs what's really there 👀)</h3> | |
| <span class="emoji-huge">🔍</span> | |
| </section> | |
| <section> | |
| <h2>Backend: contentfilter</h2> | |
| <p class="file-ref">back/aichat/contentfilter/contentfilter.go</p> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| flowchart LR | |
| subgraph DB["What's in the DB"] | |
| M1["user msg\n'Show me CVR'"] | |
| M2["assistant msg\ntext + SQL_Execution_Tool\n+ emit_chart"] | |
| M3["user msg\n(tool_result for SQL)\nvisible_to_user=false"] | |
| M4["assistant msg\ntext + emit_kpis"] | |
| M5["user msg\n(tool_result for kpis)\nvisible_to_user=false"] | |
| M6["assistant msg\nfinal text"] | |
| M7["system msg\n(turn_complete)\nvisible_to_user=false"] | |
| end | |
| subgraph FILTER["After Filtering"] | |
| F1["user msg\n'Show me CVR'"] | |
| F2["assistant msg\ntext + emit_chart\n(SQL tool stripped!)"] | |
| F4["assistant msg\ntext + emit_kpis"] | |
| F6["assistant msg\nfinal text"] | |
| end | |
| DB --> |"FilterMessagesForUser()"| FILTER | |
| style M3 fill:#da3633,color:#fff | |
| style M5 fill:#da3633,color:#fff | |
| style M7 fill:#da3633,color:#fff | |
| </pre> | |
| </div> | |
| <pre><code class="language-go">// A block is visible if it's text OR an emit_* tool call | |
| func IsUserVisibleBlock(block map[string]any) bool { | |
| blockType, _ := block["type"].(string) | |
| if blockType == "text" { return true } | |
| name, _ := block["name"].(string) | |
| return blockType == "tool_use" && strings.HasPrefix(name, "emit_") | |
| }</code></pre> | |
| </section> | |
| <section> | |
| <h2>Frontend: extractAssistantBlocks()</h2> | |
| <p class="file-ref">front/src/ai-chat/useConversationStream.ts</p> | |
| <pre><code class="language-typescript">// During replay, the frontend also filters content | |
| function extractAssistantBlocks(content: unknown): ParsedBlock[] { | |
| const blocks = (content as { blocks: unknown[] }).blocks; | |
| const result: ParsedBlock[] = []; | |
| for (const block of blocks) { | |
| // Plain text → always show | |
| if (block.type === "text") { | |
| result.push({ kind: "text", content: block.text }); | |
| continue; | |
| } | |
| // Tool calls → only show emit_* tools | |
| if (block.type !== "tool_use") continue; | |
| if (!block.name.startsWith("emit_")) continue; // 👈 THE FILTER | |
| // Parse the tool input as a block | |
| const kind = block.name.slice("emit_".length); // "emit_chart" → "chart" | |
| const parsed = parseSSEBlock(kind, block.input); | |
| if (parsed) result.push(parsed); | |
| } | |
| return result; | |
| }</code></pre> | |
| <div class="box box-orange" style="font-size: 0.65em;"> | |
| <p><strong>Double filtering:</strong> Backend filters before sending replayed messages, AND the frontend filters when parsing. Belt and suspenders again. 🩳🩳<br> | |
| The frontend filter handles the case where the backend sends the raw persisted content (which includes all tool calls) in the <code>done</code> event's final content.</p> | |
| </div> | |
| </section> | |
| </section> | |
| <!-- ===================== CANCELLATION ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Bonus</div> | |
| <h1>Cancellation Flow</h1> | |
| <h3>(When you change your mind mid-thought 🛑)</h3> | |
| <span class="emoji-huge">✋</span> | |
| </section> | |
| <section> | |
| <h2>How Cancellation Works End-to-End</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| sequenceDiagram | |
| participant U as User | |
| participant FE as Frontend | |
| participant API as POST /cancel | |
| participant REG as Registry | |
| participant AL as Agent Loop | |
| U->>FE: Clicks "Stop" | |
| FE->>FE: isStreaming = false | |
| FE->>API: POST /cancel | |
| API->>REG: stream.Cancel() | |
| REG->>AL: context.Cancel() | |
| AL->>AL: break loop + save cancelled msg | |
| AL-->>REG: Return → stream.Complete() | |
| </pre> | |
| </div> | |
| <p class="small">The frontend updates immediately (optimistic UI), then sends the cancel request.<br>If the cancel request fails, no big deal — the user already sees the response as stopped.<br>The backend cleans up in the background. 🧹</p> | |
| </section> | |
| </section> | |
| <!-- ===================== FILE MAP ===================== --> | |
| <section> | |
| <section> | |
| <div class="chapter-title">Reference</div> | |
| <h1>Complete File Map</h1> | |
| <h3>(Your GPS for the codebase 🗺️)</h3> | |
| <span class="emoji-huge">📂</span> | |
| </section> | |
| <section> | |
| <h2>Backend Files</h2> | |
| <table style="font-size: 0.35em;"> | |
| <thead> | |
| <tr><th>File</th><th>What It Does</th><th>Key Functions</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr><td>back/app/aichat/conversation_handler.go</td><td>HTTP handlers (stream, send, cancel)</td><td>HandleConversationStream, HandleSendMessage</td></tr> | |
| <tr><td>back/app/aichat/request_handler.go</td><td>Old router pattern handlers</td><td>handleListConversations, handleSaveConversation</td></tr> | |
| <tr><td>back/app/aichat/auth.go</td><td>Triple auth gate</td><td>RequireAIChatAccess</td></tr> | |
| <tr><td>back/aichat/types/types.go</td><td>Data models</td><td>CROChatConversation, CROChatMessage, MessageContext</td></tr> | |
| <tr><td>back/aichat/db/db.go</td><td>Database operations</td><td>GetConversationWithMessages, AddMessagesToConversation</td></tr> | |
| <tr><td>back/aichat/registry/registry.go</td><td>In-memory event bus</td><td>ConversationStream, StreamRegistry</td></tr> | |
| <tr><td>back/aichat/emitter/emitter.go</td><td>SSE event types & interface</td><td>Emitter interface, BlockKind, EventName</td></tr> | |
| <tr><td>back/aichat/agentloop/streaming.go</td><td>Claude API integration loop</td><td>Run(), blockTracker, buildFinalContent()</td></tr> | |
| <tr><td>back/aichat/agentloop/codec.go</td><td>Message serialization</td><td>MarshalContentBlocks, ReconstructMessageParams</td></tr> | |
| <tr><td>back/aichat/messages/accumulator.go</td><td>Message builder during streaming</td><td>Accumulator, AddAssistantMessageWithID</td></tr> | |
| <tr><td>back/aichat/messages/replay.go</td><td>Replay persisted messages as SSE</td><td>ReplayMessagesAsSSE</td></tr> | |
| <tr><td>back/aichat/tools/tools.go</td><td>Tool definitions + 64KB system prompt</td><td>SQLToolDefinition, AccountToolDefinitions</td></tr> | |
| <tr><td>back/aichat/tools/response.go</td><td>Response tool definitions (emit_*)</td><td>ResponseToolDefinitions, IsResponseTool</td></tr> | |
| <tr><td>back/aichat/tools/account.go</td><td>Account introspection tools</td><td>AccountContext, handleGetAccountOverview</td></tr> | |
| <tr><td>back/aichat/contentfilter/contentfilter.go</td><td>Filter internal tool calls</td><td>FilterMessagesForUser, IsUserVisibleBlock</td></tr> | |
| <tr><td>back/aichat/snowflake/mcp.go</td><td>Snowflake MCP client</td><td>MCPClient.CallTool, ResolveMCPEndpoint</td></tr> | |
| </tbody> | |
| </table> | |
| </section> | |
| <section> | |
| <h2>Frontend Files</h2> | |
| <table style="font-size: 0.45em;"> | |
| <thead> | |
| <tr><th>File</th><th>What It Does</th><th>Lines</th></tr> | |
| </thead> | |
| <tbody> | |
| <tr><td>front/src/ai-chat/useConversationStream.ts</td><td>Main state hook (SSE handlers, message mgmt)</td><td>537</td></tr> | |
| <tr><td>front/src/ai-chat/sseClient.ts</td><td>SSE connection, parsing, reconnection</td><td>310</td></tr> | |
| <tr><td>front/src/ai-chat/parseStructuredResponse.ts</td><td>Block parsing (streaming + replay)</td><td>151</td></tr> | |
| <tr><td>front/src/ai-chat/schema/assistantResponse.ts</td><td>Zod schemas for all block types</td><td>298</td></tr> | |
| <tr><td>front/src/ai-chat/AiChatPage.tsx</td><td>Page container</td><td>152</td></tr> | |
| <tr><td>front/src/ai-chat/AiChatPage/ChatArea.tsx</td><td>Message display + input</td><td>~500</td></tr> | |
| <tr><td>front/src/ai-chat/components/AssistantMessage.tsx</td><td>Block → component router</td><td>94</td></tr> | |
| <tr><td>front/src/ai-chat/components/MarkdownContent.tsx</td><td>Markdown text rendering</td><td>~120</td></tr> | |
| <tr><td>front/src/ai-chat/components/ChartResponse.tsx</td><td>Highcharts visualization</td><td>114</td></tr> | |
| <tr><td>front/src/ai-chat/components/TableResponse.tsx</td><td>Data table rendering</td><td>39</td></tr> | |
| <tr><td>front/src/ai-chat/components/FunnelChart.tsx</td><td>Funnel viz + save</td><td>110</td></tr> | |
| <tr><td>front/src/ai-chat/components/KpiResponse.tsx</td><td>KPI metric cards</td><td>44</td></tr> | |
| <tr><td>front/src/ai-chat/components/SuggestionsResponse.tsx</td><td>Follow-up prompt buttons</td><td>37</td></tr> | |
| <tr><td>front/src/ai-chat/components/SurveyProposalCard.tsx</td><td>Survey proposal carousel</td><td>~100</td></tr> | |
| <tr><td>front/src/ai-chat/components/ThinkingBlock.tsx</td><td>Collapsible thinking display</td><td>~60</td></tr> | |
| <tr><td>front/src/api/ai-chat.ts</td><td>API types</td><td>~40</td></tr> | |
| </tbody> | |
| </table> | |
| </section> | |
| </section> | |
| <!-- ===================== SUMMARY ===================== --> | |
| <section> | |
| <section> | |
| <h1>Key Takeaways</h1> | |
| <span class="emoji-huge">🧠</span> | |
| </section> | |
| <section> | |
| <h2>The Big Ideas</h2> | |
| <div class="two-col"> | |
| <div> | |
| <div class="box box-green" style="font-size: 0.6em;"> | |
| <h3>1. Tools as UI</h3> | |
| <p>Claude doesn't generate HTML. It calls <code>emit_*</code> tools with typed schemas. The frontend renders typed blocks. Clean separation.</p> | |
| </div> | |
| <div class="box box-blue" style="font-size: 0.6em;"> | |
| <h3>2. Registry as Event Bus</h3> | |
| <p>The in-memory ConversationStream buffers events and fans out to multiple subscribers. POST produces, GET consumes. Late joiners get replay.</p> | |
| </div> | |
| <div class="box box-orange" style="font-size: 0.6em;"> | |
| <h3>3. Optimistic UI</h3> | |
| <p>Frontend creates placeholder messages immediately. Server ordinals arrive later. Messages merge on replay. The user never waits.</p> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="box box-purple" style="font-size: 0.6em;"> | |
| <h3>4. Double Content Filtering</h3> | |
| <p>Internal tool calls (SQL, account queries) are stripped server-side AND client-side. Users only see text + emit_* blocks.</p> | |
| </div> | |
| <div class="box box-red" style="font-size: 0.6em;"> | |
| <h3>5. Two Parsing Paths</h3> | |
| <p>Streaming blocks come via SSE events (kind from event name). Replayed blocks come via JSON (kind from dataType discriminator). Same destination, different routes.</p> | |
| </div> | |
| <div class="box" style="font-size: 0.6em;"> | |
| <h3>6. Deferred Persistence</h3> | |
| <p>Messages stream to the user in real time but aren't saved until the loop completes. The trade-off: crash = lost response, but no partial database state.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section> | |
| <h2>The Full Picture (One Last Time)</h2> | |
| <div class="mermaid-container"> | |
| <pre class="mermaid"> | |
| flowchart LR | |
| subgraph FE["Frontend"] | |
| INPUT["ChatArea"] --> SEND["sendPrompt"] | |
| SSE_IN["SSE Client"] --> HOOK["useConversation\nStream"] | |
| HOOK --> PARSE["parseSSEBlock"] | |
| PARSE --> RENDER["AssistantMessage"] | |
| end | |
| subgraph BE["Backend"] | |
| POST["POST /message"] --> AGENT["agentloop.Run()"] | |
| AGENT --> CLAUDE["Claude 4.6"] | |
| CLAUDE --> TOOLS["Tools"] | |
| TOOLS --> AGENT | |
| AGENT --> REG["Registry"] | |
| GET["GET /stream"] --> REG | |
| end | |
| SEND --> POST | |
| REG --> SSE_IN | |
| style FE fill:#0d1117,stroke:#238636,color:#fff | |
| style BE fill:#0d1117,stroke:#1f6feb,color:#fff | |
| </pre> | |
| </div> | |
| </section> | |
| <section> | |
| <h1>You Made It! 🎉</h1> | |
| <h3>Now go forth and simplify this glorious mess</h3> | |
| <p class="small">Total codebase: ~9,682 lines of Go + ~6,712 lines of TypeScript<br> | |
| Total SSE event types: 10<br> | |
| Total block types: 9<br> | |
| Total tool categories: 3<br> | |
| Total ways to parse a message: at least 4<br> | |
| Total ways to lose your sanity: ∞</p> | |
| <p class="tiny" style="margin-top: 40px;">Built with love, caffeine, and 4 parallel research agents by Claude Opus 4.6<br> | |
| April 2026 — Freshpaint Engineering</p> | |
| </section> | |
| </section> | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/plugin/highlight/highlight.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script> | |
| <script> | |
| // Initialize Mermaid | |
| mermaid.initialize({ | |
| startOnLoad: false, | |
| theme: 'dark', | |
| themeVariables: { | |
| darkMode: true, | |
| background: '#161b22', | |
| primaryColor: '#1f6feb', | |
| primaryTextColor: '#e6edf3', | |
| primaryBorderColor: '#30363d', | |
| lineColor: '#58a6ff', | |
| secondaryColor: '#238636', | |
| tertiaryColor: '#d29922', | |
| fontFamily: 'SF Mono, Fira Code, monospace', | |
| fontSize: '14px', | |
| }, | |
| flowchart: { curve: 'basis', padding: 15 }, | |
| sequence: { actorMargin: 50, noteMargin: 10 }, | |
| }); | |
| // Render Mermaid diagrams | |
| async function renderMermaid() { | |
| const elements = document.querySelectorAll('.mermaid'); | |
| for (let i = 0; i < elements.length; i++) { | |
| const el = elements[i]; | |
| if (el.getAttribute('data-processed')) continue; | |
| const graphDef = el.textContent.trim(); | |
| try { | |
| const { svg } = await mermaid.render(`mermaid-${i}`, graphDef); | |
| el.innerHTML = svg; | |
| el.setAttribute('data-processed', 'true'); | |
| } catch (e) { | |
| console.warn('Mermaid render failed for diagram', i, e); | |
| } | |
| } | |
| } | |
| // Auto-shrink slides whose content exceeds the configured height | |
| function autoShrinkSlides() { | |
| const configH = 720; | |
| const usableH = configH * 0.92; // 4% margin each side = 662px | |
| const slides = Reveal.getSlides(); | |
| slides.forEach(slide => { | |
| // Skip title/chapter slides (only have h1/h2 + emoji) | |
| if (slide.children.length <= 3 && !slide.querySelector('pre, .mermaid-container, table, .box, .two-col, .three-col')) return; | |
| // Measure natural content height including mermaid SVGs | |
| let totalH = 0; | |
| Array.from(slide.children).forEach(child => { | |
| const s = window.getComputedStyle(child); | |
| const mt = parseFloat(s.marginTop) || 0; | |
| const mb = parseFloat(s.marginBottom) || 0; | |
| // For mermaid containers, measure the SVG inside | |
| const svg = child.querySelector('svg'); | |
| const childH = svg ? Math.max(child.offsetHeight, svg.getBoundingClientRect().height) : child.offsetHeight; | |
| totalH += childH + mt + mb; | |
| }); | |
| const naturalH = Math.max(totalH, slide.scrollHeight); | |
| // Remove any previous shrink class | |
| slide.classList.remove('shrink-mild', 'shrink-moderate', 'shrink-heavy'); | |
| if (naturalH > usableH + 5) { | |
| const ratio = usableH / naturalH; | |
| if (ratio < 0.68) slide.classList.add('shrink-heavy'); | |
| else if (ratio < 0.85) slide.classList.add('shrink-moderate'); | |
| else slide.classList.add('shrink-mild'); | |
| } | |
| }); | |
| } | |
| // Initialize Reveal.js | |
| Reveal.initialize({ | |
| hash: true, | |
| slideNumber: true, | |
| transition: 'slide', | |
| transitionSpeed: 'fast', | |
| backgroundTransition: 'fade', | |
| center: true, | |
| width: 1280, | |
| height: 720, | |
| margin: 0.04, | |
| plugins: [RevealHighlight], | |
| }).then(() => { | |
| renderMermaid(); | |
| // Re-render on slide change (for lazy-loaded slides) | |
| Reveal.on('slidechanged', () => { | |
| setTimeout(renderMermaid, 100); | |
| }); | |
| // Auto-shrink after mermaid renders (give it time) | |
| setTimeout(autoShrinkSlides, 1500); | |
| // Re-shrink after any mermaid re-renders | |
| Reveal.on('slidechanged', () => { | |
| setTimeout(autoShrinkSlides, 200); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment