Questions about features, how-tos, or use cases? There are thousands of discussions
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxAILogic.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxAILogic.ts
new file mode 100644
index 0000000000000..e522baf23852b
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxAILogic.ts
@@ -0,0 +1,115 @@
+import { actions, kea, path, reducers } from 'kea'
+import { loaders } from 'kea-loaders'
+
+import type { sidePanelMaxAILogicType } from './sidePanelMaxAILogicType'
+import { sidePanelMaxAPI } from './sidePanelMaxAPI'
+
+export interface ChatMessage {
+ role: 'user' | 'assistant'
+ content: string
+ timestamp: string
+ isRateLimited?: boolean
+}
+
+interface MaxResponse {
+ content: string | { text: string; type: string }
+ isRateLimited?: boolean
+}
+
+export const sidePanelMaxAILogic = kea([
+ path(['scenes', 'navigation', 'sidepanel', 'sidePanelMaxAILogic']),
+
+ actions({
+ submitMessage: (message: string) => ({ message }),
+ clearChatHistory: true,
+ appendAssistantMessage: (content: string) => ({ content }),
+ setSearchingThinking: (isSearching: boolean) => ({ isSearching }),
+ setRateLimited: (isLimited: boolean) => ({ isLimited }),
+ }),
+
+ reducers({
+ currentMessages: [
+ [] as ChatMessage[],
+ {
+ submitMessage: (state, { message }) =>
+ message.trim()
+ ? [
+ ...state,
+ {
+ role: 'user',
+ content: message,
+ timestamp: new Date().toISOString(),
+ },
+ ]
+ : state,
+ appendAssistantMessage: (state, { content }) => [
+ ...state,
+ {
+ role: 'assistant',
+ content,
+ timestamp: new Date().toISOString(),
+ isRateLimited: content.includes('Rate limit exceeded') || content.includes('rate-limited'),
+ },
+ ],
+ clearChatHistory: () => [],
+ },
+ ],
+ isSearchingThinking: [
+ false,
+ {
+ setSearchingThinking: (_, { isSearching }) => isSearching,
+ },
+ ],
+ isRateLimited: [
+ false,
+ {
+ setRateLimited: (_, { isLimited }) => isLimited,
+ },
+ ],
+ }),
+
+ loaders(({ actions, values }) => ({
+ assistantResponse: [
+ null as string | null,
+ {
+ submitMessage: async ({ message }, breakpoint) => {
+ try {
+ actions.setSearchingThinking(true)
+ if (!values.isRateLimited) {
+ actions.setRateLimited(false)
+ }
+ const response = (await sidePanelMaxAPI.sendMessage(message)) as MaxResponse
+ await breakpoint(100)
+
+ const content = typeof response.content === 'string' ? response.content : response.content.text
+
+ if (response.isRateLimited) {
+ actions.setRateLimited(true)
+ } else {
+ actions.setRateLimited(false)
+ }
+
+ actions.appendAssistantMessage(content)
+ return content
+ } catch (error: unknown) {
+ if (
+ error &&
+ typeof error === 'object' &&
+ 'message' in error &&
+ typeof error.message === 'string' &&
+ (error.message.includes('429') || error.message.includes('rate limit'))
+ ) {
+ actions.setRateLimited(true)
+ // Keep searching state true while rate limited
+ } else {
+ actions.setSearchingThinking(false)
+ actions.setRateLimited(false)
+ }
+ console.error('Error sending message:', error)
+ return null
+ }
+ },
+ },
+ ],
+ })),
+])
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxAPI.ts b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxAPI.ts
new file mode 100644
index 0000000000000..ff3acdb55aaac
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxAPI.ts
@@ -0,0 +1,27 @@
+const MAX_API_HOST = 'http://localhost:3000' // Default port used in sidebar_max_AI.py
+
+let currentSessionId: string | null = null
+
+export const sidePanelMaxAPI = {
+ async sendMessage(message: string): Promise<{ content: string }> {
+ const response = await fetch(`${MAX_API_HOST}/chat`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ message,
+ role: 'user',
+ session_id: currentSessionId,
+ }),
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed to send message to Max')
+ }
+
+ const data = await response.json()
+ currentSessionId = data.session_id // Store the session ID for next request
+ return { content: data.content }
+ },
+}
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxChatInterface.scss b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxChatInterface.scss
new file mode 100644
index 0000000000000..75b206b2aea44
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxChatInterface.scss
@@ -0,0 +1,61 @@
+.SidePanelMaxChatInterface {
+ &__label {
+ margin-top: 0.5rem;
+ margin-right: 0.5rem;
+ font-size: 0.875rem;
+ color: var(--muted);
+ white-space: nowrap;
+ }
+
+ &__message-container {
+ display: flex;
+ width: 100%;
+
+ &--user {
+ justify-content: flex-end;
+ }
+
+ &--assistant {
+ justify-content: flex-start;
+ }
+ }
+
+ &__message-content {
+ width: 90%;
+ min-width: 90%;
+ max-width: 90%;
+ padding: 0.5rem;
+ word-break: break-word;
+ border-radius: 0.5rem;
+
+ &--assistant {
+ color: var(--default);
+ background: var(--bg-light);
+
+ .dark & {
+ background: var(--bg-depth);
+ }
+ }
+
+ &--user {
+ color: var(--default);
+ background: var(--bg-light);
+
+ .dark & {
+ background: var(--bg-side);
+ }
+ }
+ }
+
+ code {
+ padding: 0.125rem 0.25rem;
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ background: color-mix(in sRGB, var(--bg-light) 85%, var(--primary) 15%);
+ border-radius: 0.25rem;
+
+ .dark & {
+ background: color-mix(in sRGB, var(--bg-depth) 85%, var(--primary) 15%);
+ }
+ }
+}
diff --git a/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxChatInterface.tsx b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxChatInterface.tsx
new file mode 100644
index 0000000000000..ccb2fd5dd4770
--- /dev/null
+++ b/frontend/src/layout/navigation-3000/sidepanel/panels/sidePanelMaxChatInterface.tsx
@@ -0,0 +1,385 @@
+import { LemonButton, LemonCollapse, LemonDivider, LemonTextArea } from '@posthog/lemon-ui'
+import { Spinner } from '@posthog/lemon-ui'
+import { useActions, useValues } from 'kea'
+import { LemonMarkdown } from 'lib/lemon-ui/LemonMarkdown'
+import { useEffect, useRef, useState } from 'react'
+
+import { sidePanelMaxAILogic } from './sidePanelMaxAILogic'
+import { ChatMessage } from './sidePanelMaxAILogic'
+
+function extractThinkingBlock(content: string): Array {
+ const matches = Array.from(content.matchAll(new RegExp('(.*?)', 'gs')))
+ return matches.map((match) => match[1].trim())
+}
+
+function extractSearchReflection(content: string): Array {
+ const matches = Array.from(
+ content.matchAll(new RegExp('(.*?)', 'gs'))
+ )
+ return matches.map((match) => match[1].trim())
+}
+
+function extractSearchQualityScore(content: string): { hasQualityScore: boolean; content: string | null } {
+ const qualityMatch = content.match(new RegExp('(.*?)', 's'))
+ if (!qualityMatch) {
+ return { hasQualityScore: false, content: null }
+ }
+ return {
+ hasQualityScore: true,
+ content: qualityMatch[1].trim(),
+ }
+}
+
+function extractInfoValidation(content: string): { hasQualityScore: boolean; content: string | null } {
+ const qualityMatch = content.match(new RegExp('(.*?)', 's'))
+ if (!qualityMatch) {
+ return { hasQualityScore: false, content: null }
+ }
+ return {
+ hasQualityScore: true,
+ content: qualityMatch[1].trim(),
+ }
+}
+
+function extractURLValidation(content: string): { hasQualityScore: boolean; content: string | null } {
+ const qualityMatch = content.match(new RegExp('(.*?)', 's'))
+ if (!qualityMatch) {
+ return { hasQualityScore: false, content: null }
+ }
+ return {
+ hasQualityScore: true,
+ content: qualityMatch[1].trim(),
+ }
+}
+
+export function MaxChatInterface(): JSX.Element {
+ const { currentMessages, isSearchingThinking, isRateLimited } = useValues(sidePanelMaxAILogic)
+ const { submitMessage } = useActions(sidePanelMaxAILogic)
+ const [inputMessage, setInputMessage] = useState('')
+ const [isLoading, setIsLoading] = useState(true)
+
+ useEffect(() => {
+ submitMessage('__GREETING__')
+ }, [submitMessage])
+
+ const showInput =
+ !isLoading && currentMessages.length > 0 && currentMessages.some((msg) => msg.role === 'assistant')
+
+ useEffect(() => {
+ if (currentMessages.some((msg) => msg.role === 'assistant')) {
+ setIsLoading(false)
+ }
+ }, [currentMessages])
+
+ const handleSubmit = (e: React.FormEvent): void => {
+ e.preventDefault()
+ if (inputMessage.trim()) {
+ submitMessage(inputMessage)
+ setInputMessage('')
+ }
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent): void => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSubmit(e)
+ }
+ }
+
+ const messagesEndRef = useRef(null)
+
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [currentMessages])
+
+ const displayMessages = currentMessages.filter((message) => message.content !== '__GREETING__')
+
+ return (
+
+
+
+
Tips for chatting with Max:
+
+ - Max can't handle files or images (yet.)
+ -
+ Max can't see what page you're on, or the contents. Copy/paste error messages or queries to
+ share with Max.
+
+ - Max can make mistakes. Please double-check responses.
+
+
+ {isLoading ? (
+
+ Max is crawling out of his burrow and shaking off his quills...
+
+
+ ) : (
+ <>
+ {displayMessages.map((message: ChatMessage, idx: number) => (
+
+ {message.role === 'user' &&
You
}
+
+
+ {message.role === 'assistant' &&
Max
}
+
+ {message.role === 'assistant'
+ ? typeof message.content === 'string' &&
+ (message.content.includes('
started') ? (
+
+ Max is searching...
+
+
+ ) : message.content.includes('
completed') ? (
+
Max searched
+ ) : (
+ <>
+
+ {message.content
+ .replace(new RegExp('.*?', 's'), '')
+ .replace(
+ new RegExp(
+ '.*?',
+ 'gs'
+ ),
+ ''
+ )
+ .replace(
+ new RegExp(
+ '.*?',
+ 's'
+ ),
+ ''
+ )
+ .replace(
+ new RegExp(
+ '.*?',
+ 's'
+ ),
+ ''
+ )
+ .replace(
+ new RegExp(
+ '.*?',
+ 's'
+ ),
+ ''
+ )
+ .replace(new RegExp('|', 'g'), '')
+ .trim()}
+
+
+ {/* Only show analysis for non-greeting messages */}
+ {idx === 0
+ ? null
+ : (extractThinkingBlock(message.content).length > 0 ||
+ extractSearchReflection(message.content).length > 0 ||
+ extractSearchQualityScore(message.content)
+ .hasQualityScore ||
+ extractInfoValidation(message.content)
+ .hasQualityScore ||
+ extractURLValidation(message.content)
+ .hasQualityScore) && (
+
+ What was Max thinking?
+
+ ),
+ content: (
+
+ {/* Thinking blocks */}
+ {extractThinkingBlock(
+ message.content
+ ).map((content, index) => (
+
+ {content}
+
+ ),
+ },
+ ]}
+ />
+ ))}
+
+ {/* Search Reflection blocks */}
+ {extractSearchReflection(
+ message.content
+ ).map((content, index) => (
+
+ {content}
+
+ ),
+ },
+ ]}
+ />
+ ))}
+
+ {/* Search Quality Score */}
+ {extractSearchQualityScore(
+ message.content
+ ).hasQualityScore && (
+
+ {
+ extractSearchQualityScore(
+ message.content
+ )
+ .content
+ }
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+ {/* Info Validation */}
+ {extractInfoValidation(
+ message.content
+ ).hasQualityScore && (
+
+ {
+ extractInfoValidation(
+ message.content
+ )
+ .content
+ }
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+ {/* URL Validation */}
+ {extractURLValidation(
+ message.content
+ ).hasQualityScore && (
+
+ {
+ extractURLValidation(
+ message.content
+ )
+ .content
+ }
+
+ ),
+ },
+ ]}
+ />
+ )}
+
+ ),
+ },
+ ]}
+ />
+ )}
+ >
+ ))
+ : message.content}
+