From bbf3a9c321d8ae760cc3ebc97530ff90311fb2a6 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Fri, 12 Jun 2026 10:18:34 -0400 Subject: [PATCH 1/6] Rationalized consent modals, updated Home page descriptions. --- app/components/ConversionOnboarding.tsx | 37 +--------------- app/components/LLMChatInline.tsx | 55 ++--------------------- app/components/LLMCommentaryModal.tsx | 59 +++---------------------- app/explore/page.tsx | 33 +++++++++++++- app/globals.css | 25 +++++++++++ app/landing-client.tsx | 27 +++++++---- 6 files changed, 86 insertions(+), 150 deletions(-) diff --git a/app/components/ConversionOnboarding.tsx b/app/components/ConversionOnboarding.tsx index 6d976b2..3186e65 100644 --- a/app/components/ConversionOnboarding.tsx +++ b/app/components/ConversionOnboarding.tsx @@ -10,11 +10,9 @@ import { runAllAnalysisOnboarding, type OnboardingRunAllProgress } from "@/lib/r import { useGenotype } from "./UserDataUpload"; import { useResults } from "./ResultsContext"; import LLMCommentaryModal from "./LLMCommentaryModal"; -import NilAIConsentModal from "./NilAIConsentModal"; + import { callLLM, getLLMDescription } from "@/lib/llm-client"; import { - trackAIConsentDeclined, - trackAIConsentGiven, trackLLMQuestionAsked, trackOnboardingAction, trackOnboardingCompleted, @@ -55,7 +53,6 @@ interface ConversionOnboardingProps { mode?: FlowMode; } -const CONSENT_STORAGE_KEY = "nilai_llm_consent_accepted"; const SAMPLE_DATA_URL = "/api/sample-genotype"; const GUIDE_23ANDME_URL = "https://monadicdna.com/guide/23andme"; const GUIDE_ANCESTRY_URL = "https://monadicdna.com/guide/ancestry"; @@ -228,8 +225,6 @@ export default function ConversionOnboarding({ const [mounted, setMounted] = useState(false); const [currentStep, setCurrentStep] = useState("intro"); const [completionPath, setCompletionPath] = useState("own_dna"); - const [showConsentModal, setShowConsentModal] = useState(false); - const [hasConsent, setHasConsent] = useState(false); const [traitCandidates, setTraitCandidates] = useState([]); const [selectedTraitIds, setSelectedTraitIds] = useState([]); const [previewResponses, setPreviewResponses] = useState([]); @@ -242,7 +237,6 @@ export default function ConversionOnboarding({ const [detailResult, setDetailResult] = useState(null); const [expandedTraitId, setExpandedTraitId] = useState(null); const [commentaryResult, setCommentaryResult] = useState(null); - const [pendingQuestion, setPendingQuestion] = useState(null); const [activeQuestion, setActiveQuestion] = useState(null); const [runAllProgress, setRunAllProgress] = useState({ phase: "downloading", @@ -260,9 +254,6 @@ export default function ConversionOnboarding({ useEffect(() => { setMounted(true); - if (typeof window !== "undefined") { - setHasConsent(localStorage.getItem(CONSENT_STORAGE_KEY) === "true"); - } }, []); useEffect(() => { @@ -601,26 +592,6 @@ RESPONSE STRUCTURE: /> )} - { - localStorage.setItem(CONSENT_STORAGE_KEY, "true"); - setHasConsent(true); - setShowConsentModal(false); - trackAIConsentGiven(); - if (pendingQuestion) { - const nextQuestion = pendingQuestion; - setPendingQuestion(null); - void generateSecureResponses(nextQuestion); - return; - } - }} - onDecline={() => { - setPendingQuestion(null); - setShowConsentModal(false); - trackAIConsentDeclined(); - }} - />
@@ -1096,12 +1067,6 @@ RESPONSE STRUCTURE: return; } - if (!hasConsent) { - setPendingQuestion(question); - setShowConsentModal(true); - return; - } - void generateSecureResponses(question); }} disabled={responsesLoading && activeQuestion !== question} diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index ee4aea1..37b322e 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -2,13 +2,12 @@ import { useEffect, useState, useRef } from "react"; import { SavedResult } from "@/lib/results-manager"; -import NilAIConsentModal from "./NilAIConsentModal"; import { useResults } from "./ResultsContext"; import { useCustomization } from "./CustomizationContext"; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { callLLM, callLLMStream, getLLMDescription, MessageContentPart } from "@/lib/llm-client"; -import { trackLLMQuestionAsked, trackAIConsentGiven, trackAIConsentDeclined, trackAIConsentModalShown, trackExampleQuestionClicked, trackFollowupQuestionClicked } from "@/lib/analytics"; +import { trackLLMQuestionAsked, trackExampleQuestionClicked, trackFollowupQuestionClicked } from "@/lib/analytics"; type AttachmentType = 'text' | 'pdf' | 'csv' | 'tsv' | 'image'; @@ -31,7 +30,6 @@ type Message = { followupQuestions?: string[]; }; -const CONSENT_STORAGE_KEY = "nilai_llm_chat_consent_accepted"; const MAX_CONTEXT_RESULTS = 500; const MAX_TEXT_FILE_SIZE = 2 * 1024 * 1024; // 2MB for text/csv/tsv const MAX_PDF_FILE_SIZE = 5 * 1024 * 1024; // 5MB for PDF @@ -61,15 +59,13 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } useEffect(() => { if (!initialInput) return; setInputValue(initialInput); - const t = setTimeout(() => handleSendMessage(false, initialInput), 0); + const t = setTimeout(() => handleSendMessage(initialInput), 0); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialInput]); const [isLoading, setIsLoading] = useState(false); const [loadingStatus, setLoadingStatus] = useState(""); const [error, setError] = useState(null); - const [showConsentModal, setShowConsentModal] = useState(false); - const [hasConsent, setHasConsent] = useState(false); const [expandedMessageIndex, setExpandedMessageIndex] = useState(null); const [attachedFiles, setAttachedFiles] = useState([]); const [attachmentError, setAttachmentError] = useState(null); @@ -80,44 +76,15 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } useEffect(() => { setMounted(true); - - // Check consent - const consent = localStorage.getItem(CONSENT_STORAGE_KEY); - if (consent === 'true') { - setHasConsent(true); - } }, []); // Determine if this is the first message or a follow-up const isFirstMessage = messages.length === 0; - useEffect(() => { - if (typeof window !== "undefined") { - const consent = localStorage.getItem(CONSENT_STORAGE_KEY); - setHasConsent(consent === "true"); - } - }, []); - // Removed auto-scroll so user doesn't have to scroll up to read responses // Also removed auto-focus to prevent scrolling to bottom on tab load - const handleConsentAccept = () => { - if (typeof window !== "undefined") { - localStorage.setItem(CONSENT_STORAGE_KEY, "true"); - setHasConsent(true); - setShowConsentModal(false); - trackAIConsentGiven(); - void handleSendMessage(true); - } - }; - - const handleConsentDecline = () => { - setShowConsentModal(false); - trackAIConsentDeclined(); - }; - - const handleExampleClick = (question: string) => { setInputValue(question); inputRef.current?.focus(); @@ -126,7 +93,7 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } const handleFollowupClick = (question: string) => { trackFollowupQuestionClicked(); - void handleSendMessage(false, question); + void handleSendMessage(question); }; const handleCopyMessage = async (content: string) => { @@ -307,17 +274,10 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } return parts; }; - const handleSendMessage = async (skipConsentCheck = false, queryOverride?: string) => { + const handleSendMessage = async (queryOverride?: string) => { const query = (queryOverride ?? inputValue).trim(); if (!query) return; - // Check consent before sending first message - if (!skipConsentCheck && !hasConsent) { - setShowConsentModal(true); - trackAIConsentModalShown(); - return; - } - setInputValue(""); setIsLoading(true); setError(null); @@ -884,13 +844,6 @@ Write questions from the user's perspective — as if the user is asking you. No return ( <> - {showConsentModal && ( - - )}
{messages.length > 0 && (
diff --git a/app/components/LLMCommentaryModal.tsx b/app/components/LLMCommentaryModal.tsx index c57d183..3d5913c 100644 --- a/app/components/LLMCommentaryModal.tsx +++ b/app/components/LLMCommentaryModal.tsx @@ -3,12 +3,12 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { SavedResult } from "@/lib/results-manager"; -import NilAIConsentModal from "./NilAIConsentModal"; + import StudyQualityIndicators from "./StudyQualityIndicators"; import { useResults } from "./ResultsContext"; import { useCustomization } from "./CustomizationContext"; import { callLLM, getLLMDescription } from "@/lib/llm-client"; -import { trackAIAnalysisRun } from "@/lib/analytics"; + type LLMCommentaryModalProps = { isOpen: boolean; @@ -16,11 +16,8 @@ type LLMCommentaryModalProps = { currentResult: SavedResult; allResults: SavedResult[]; // Deprecated - will use SQL query instead skipPersonalizationPrompt?: boolean; - skipConsent?: boolean; }; -const CONSENT_STORAGE_KEY = "nilai_llm_consent_accepted"; - // Helper function to format risk scores consistently function formatRiskScore(score: number, level: string, effectType?: 'OR' | 'beta'): string { if (level === 'neutral') return effectType === 'beta' ? 'baseline' : '1.0x'; @@ -36,7 +33,6 @@ export default function LLMCommentaryModal({ currentResult, allResults, // Deprecated parameter skipPersonalizationPrompt = false, - skipConsent = false, }: LLMCommentaryModalProps) { const resultsContext = useResults(); const { getTopResultsByRelevance } = resultsContext; @@ -45,9 +41,7 @@ export default function LLMCommentaryModal({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [delegationStatus, setDelegationStatus] = useState(""); - const [showConsentModal, setShowConsentModal] = useState(false); const [showPersonalizationPrompt, setShowPersonalizationPrompt] = useState(false); - const [hasConsent, setHasConsent] = useState(false); const [studyMetadata, setStudyMetadata] = useState(null); const [loadingPhase, setLoadingPhase] = useState<'query' | 'metadata' | 'token' | 'llm' | 'done'>('query'); const [resultsCount, setResultsCount] = useState(0); @@ -56,52 +50,17 @@ export default function LLMCommentaryModal({ const [hasCustomization, setHasCustomization] = useState(false); const [usedSemanticSearch, setUsedSemanticSearch] = useState(false); - useEffect(() => { - // Check if user has previously consented - if (typeof window !== "undefined") { - const consent = localStorage.getItem(CONSENT_STORAGE_KEY); - setHasConsent(consent === "true"); - } - }, []); - useEffect(() => { if (isOpen) { - if (skipConsent) { + if (skipPersonalizationPrompt || customizationStatus === 'unlocked') { setShowPersonalizationPrompt(false); - setShowConsentModal(false); fetchCommentary(); - return; - } - - if (skipPersonalizationPrompt) { - setShowPersonalizationPrompt(false); - setShowConsentModal(true); - return; - } - - if (customizationStatus === 'not-set' || customizationStatus === 'locked') { - setShowPersonalizationPrompt(true); } else { - setShowConsentModal(true); + setShowPersonalizationPrompt(true); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, customizationStatus, skipPersonalizationPrompt, skipConsent]); - - const handleConsentAccept = () => { - if (typeof window !== "undefined") { - localStorage.setItem(CONSENT_STORAGE_KEY, "true"); - setHasConsent(true); - setShowConsentModal(false); - trackAIAnalysisRun(); - fetchCommentary(); - } - }; - - const handleConsentDecline = () => { - setShowConsentModal(false); - onClose(); - }; + }, [isOpen, customizationStatus, skipPersonalizationPrompt]); const fetchCommentary = async () => { console.log('[fetchCommentary] Starting...'); @@ -798,13 +757,7 @@ Keep your response concise (400-600 words), educational, and reassuring where ap : null; } - const modalContent = showConsentModal ? ( - - ) : ( + const modalContent = (
)} + {/* Personalization notice */} + {customizationStatus === 'not-set' && ( +
+ 🧬 +
+ Add your health history for personalized results.{' '} + The "Based on your health history" section matches your genetic results to conditions you or your family have. Click Personalize in the menu above to set it up. +
+
+ )} + {customizationStatus === 'locked' && ( +
+ 🔒 +
+ Unlock personalization to see condition-matched results.{' '} + Your health history is saved but locked. Click Personalize in the menu above and enter your password to unlock it. +
+
+ )} + {customizationStatus === 'unlocked' && healthMatches.length === 0 && + (customization?.personalConditions?.length ?? 0) === 0 && + (customization?.familyConditions?.length ?? 0) === 0 && ( +
+ 🧬 +
+ No health conditions added yet.{' '} + Add personal and family conditions in Personalize to see traits matched to your health history. +
+
+ )} + {/* Health history matches */} {healthMatches.length > 0 && (
diff --git a/app/globals.css b/app/globals.css index c267fd1..b716127 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2568,6 +2568,31 @@ tbody tr:hover { .explore-highlight-item--neutral:hover { background: rgba(0, 0, 0, 0.06); } .explore-highlight-item--neutral .explore-highlight-score { color: #6b7280; } +/* Personalization notice */ +.explore-personalization-notice { + display: flex; + align-items: flex-start; + gap: 0.75rem; + background: var(--surface-elevated, rgba(255,255,255,0.04)); + border: 1px solid var(--border-subtle, rgba(255,255,255,0.1)); + border-radius: 8px; + padding: 0.875rem 1rem; + margin-bottom: 1.5rem; + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; +} + +.explore-personalization-notice-icon { + font-size: 1.1rem; + flex-shrink: 0; + margin-top: 0.05rem; +} + +.explore-personalization-notice strong { + color: var(--text-primary, inherit); +} + /* Health history section */ .explore-health-section { margin-bottom: 1.5rem; diff --git a/app/landing-client.tsx b/app/landing-client.tsx index ba67fb6..a5b9772 100644 --- a/app/landing-client.tsx +++ b/app/landing-client.tsx @@ -35,31 +35,34 @@ function formatBytes(bytes: number): string { const SCHEDULE_CALL_URL = "https://calendar.app.google/eVDN4d44GreUjR8p8"; +const PRIVACY_POLICY_URL = "https://monadicdna.com/privacy"; + const featureCopy = [ { label: "Explore", href: "/explore", - text: "Explore your results trait by trait. Browse elevated and protective associations ranked by effect size, and see which traits connect to conditions in your personal and family health history.", + text: "Get an overview of what your results actually mean. See your strongest genetic signals, both elevated risks and protective findings, and find which ones connect to conditions in your personal or family health history.", }, { label: "DNA Chat", href: "/dna-chat", - text: "Ask questions about your genetic results in plain English. Get explanations of specific traits, genes, and what population studies say about your variants.", + text: "Ask questions about your genetic data in plain English. Get clear explanations of specific findings, genes, and what the research says. It works like a conversation with a knowledgeable friend.", }, { label: "Browse", href: "/browse", - text: "Browse studies from the GWAS Catalog with advanced filtering by trait, sample size, and significance. View a heatmap of your SNP matches across selected studies.", + text: "Search and filter thousands of genetic research studies. See which ones matched your DNA, how strong the effect is, and read the published science behind each result.", }, { label: "Analyze", href: "/overview-report", - text: "Generate AI-written reports that synthesize patterns across your results, surface hypotheses about your biology, and connect findings to your health history. Premium feature.", + text: "Generate AI-written reports from your full set of results. Reports find patterns, connect findings to your health history, and build a picture of your genetic biology. Premium feature.", }, { label: "Privacy first", - href: null, - text: "Your DNA stays in your browser. We do not store, transmit, or sell your raw genetic data. AI runs in Trusted Execution Environments for maximum anonymity.", + href: PRIVACY_POLICY_URL, + text: "Your DNA never leaves your device. We don't store, share, or sell your genetic data. All AI analysis runs in a private computing environment so your data stays yours.", + external: true, }, ]; @@ -164,9 +167,15 @@ export default function LandingClient() {

{item.href ? ( - - {item.label} - + item.external ? ( + + {item.label} + + ) : ( + + {item.label} + + ) ) : ( item.label )} From 869fbb56b74fa7ee87664453311421099c6746c8 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Fri, 12 Jun 2026 16:06:12 -0400 Subject: [PATCH 2/6] Added Research fucntionality to DNA Chat. --- app/components/LLMChatInline.tsx | 241 ++++++++++++++++++++++++------- app/globals.css | 93 +++++++++++- lib/research-service.ts | 158 ++++++++++++++++++++ 3 files changed, 441 insertions(+), 51 deletions(-) create mode 100644 lib/research-service.ts diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index 37b322e..5b6a278 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -1,13 +1,17 @@ "use client"; import { useEffect, useState, useRef } from "react"; +import { useRouter } from "next/navigation"; import { SavedResult } from "@/lib/results-manager"; import { useResults } from "./ResultsContext"; import { useCustomization } from "./CustomizationContext"; +import { useAuth, AuthButton } from "./AuthProvider"; +import { hasValidPromoAccess } from "@/lib/promo-access"; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { callLLM, callLLMStream, getLLMDescription, MessageContentPart } from "@/lib/llm-client"; import { trackLLMQuestionAsked, trackExampleQuestionClicked, trackFollowupQuestionClicked } from "@/lib/analytics"; +import { runResearchPipeline, type ResearchAngle } from "@/lib/research-service"; type AttachmentType = 'text' | 'pdf' | 'csv' | 'tsv' | 'image'; @@ -28,6 +32,7 @@ type Message = { studiesUsed?: SavedResult[]; attachments?: Attachment[]; followupQuestions?: string[]; + researchMeta?: ResearchAngle[]; }; const MAX_CONTEXT_RESULTS = 500; @@ -49,9 +54,12 @@ const EXAMPLE_QUESTIONS = [ export default function AIChatInline({ initialInput }: { initialInput?: string } = {}) { + const router = useRouter(); const resultsContext = useResults(); const { getTopResultsByRelevance } = resultsContext; const { customization, status: customizationStatus } = useCustomization(); + const { isAuthenticated, hasActiveSubscription, openAuthModal, initializeDynamic, isDynamicInitialized } = useAuth(); + const [hasPromoAccess, setHasPromoAccess] = useState(false); const [mounted, setMounted] = useState(false); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(""); @@ -70,6 +78,7 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } const [attachedFiles, setAttachedFiles] = useState([]); const [attachmentError, setAttachmentError] = useState(null); const [expandedAttachmentIndex, setExpandedAttachmentIndex] = useState(null); + const [expandedResearchIndex, setExpandedResearchIndex] = useState(null); const inputRef = useRef(null); const fileInputRef = useRef(null); @@ -78,6 +87,28 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } setMounted(true); }, []); + useEffect(() => { + const refresh = () => setHasPromoAccess(hasValidPromoAccess()); + refresh(); + window.addEventListener('premiumAccessUpdated', refresh); + return () => window.removeEventListener('premiumAccessUpdated', refresh); + }, []); + + useEffect(() => { + if (!isDynamicInitialized) initializeDynamic(); + }, [initializeDynamic, isDynamicInitialized]); + + const hasPremiumAccess = hasActiveSubscription || hasPromoAccess; + + const requirePremium = (): boolean => { + if (!hasPremiumAccess && !hasValidPromoAccess()) { + if (!isAuthenticated) { openAuthModal(); return false; } + router.push('/subscribe'); + return false; + } + return true; + }; + // Determine if this is the first message or a follow-up const isFirstMessage = messages.length === 0; @@ -274,6 +305,48 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } return parts; }; + const buildUserContext = (): string => { + if (!customization) return ''; + const parts: string[] = []; + if (customization.ethnicities.length > 0) { + parts.push(`Ethnicities: ${customization.ethnicities.join(', ')}`); + } + if (customization.countriesOfOrigin.length > 0) { + parts.push(`Countries of ancestral origin: ${customization.countriesOfOrigin.join(', ')}`); + } + if (customization.genderAtBirth) { + parts.push(`Gender assigned at birth: ${customization.genderAtBirth}`); + } + if (customization.age) { + parts.push(`Age: ${customization.age}`); + } + if (customization.smokingHistory) { + const smokingLabel = customization.smokingHistory === 'still-smoking' ? 'Currently smoking' : + customization.smokingHistory === 'past-smoker' ? 'Former smoker' : + 'Never smoked'; + parts.push(`Smoking history: ${smokingLabel}`); + } + if (customization.alcoholUse) { + const alcoholLabel = customization.alcoholUse.charAt(0).toUpperCase() + customization.alcoholUse.slice(1); + parts.push(`Alcohol use: ${alcoholLabel}`); + } + if (customization.medications && customization.medications.length > 0) { + parts.push(`Current medications/supplements: ${customization.medications.join(', ')}`); + } + if (customization.diet) { + const dietLabel = customization.diet === 'regular' ? 'Regular diet (no restrictions)' : + customization.diet.charAt(0).toUpperCase() + customization.diet.slice(1) + ' diet'; + parts.push(`Dietary preferences: ${dietLabel}`); + } + if (customization.personalConditions && customization.personalConditions.length > 0) { + parts.push(`Personal medical history: ${customization.personalConditions.join(', ')}`); + } + if (customization.familyConditions && customization.familyConditions.length > 0) { + parts.push(`Family medical history: ${customization.familyConditions.join(', ')}`); + } + return parts.join('\n'); + }; + const handleSendMessage = async (queryOverride?: string) => { const query = (queryOverride ?? inputValue).trim(); if (!query) return; @@ -331,55 +404,10 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } console.log(`[LLM Chat] Including ${relevantResults.length} results in LLM context`); - let userContext = ''; - if (customization) { - const parts = []; - if (customization.ethnicities.length > 0) { - parts.push(`Ethnicities: ${customization.ethnicities.join(', ')}`); - } - if (customization.countriesOfOrigin.length > 0) { - parts.push(`Countries of ancestral origin: ${customization.countriesOfOrigin.join(', ')}`); - } - if (customization.genderAtBirth) { - parts.push(`Gender assigned at birth: ${customization.genderAtBirth}`); - } - if (customization.age) { - parts.push(`Age: ${customization.age}`); - } - if (customization.smokingHistory) { - const smokingLabel = customization.smokingHistory === 'still-smoking' ? 'Currently smoking' : - customization.smokingHistory === 'past-smoker' ? 'Former smoker' : - 'Never smoked'; - parts.push(`Smoking history: ${smokingLabel}`); - } - if (customization.alcoholUse) { - const alcoholLabel = customization.alcoholUse.charAt(0).toUpperCase() + customization.alcoholUse.slice(1); - parts.push(`Alcohol use: ${alcoholLabel}`); - } - if (customization.medications && customization.medications.length > 0) { - parts.push(`Current medications/supplements: ${customization.medications.join(', ')}`); - } - if (customization.diet) { - const dietLabel = customization.diet === 'regular' ? 'Regular diet (no restrictions)' : - customization.diet.charAt(0).toUpperCase() + customization.diet.slice(1) + ' diet'; - parts.push(`Dietary preferences: ${dietLabel}`); - } - if (customization.personalConditions && customization.personalConditions.length > 0) { - parts.push(`Personal medical history: ${customization.personalConditions.join(', ')}`); - } - if (customization.familyConditions && customization.familyConditions.length > 0) { - parts.push(`Family medical history: ${customization.familyConditions.join(', ')}`); - } - - if (parts.length > 0) { - userContext = ` - -USER BACKGROUND (CONFIDENTIAL - USE TO PERSONALIZE INTERPRETATION): -${parts.join('\n')} - -Consider how this user's background, lifestyle factors (smoking, alcohol, diet), and current medications may affect their risk profile and the applicability of these study findings.`; - } - } + const userContextSummary = buildUserContext(); + const userContext = userContextSummary + ? `\n\nUSER BACKGROUND (CONFIDENTIAL - USE TO PERSONALIZE INTERPRETATION):\n${userContextSummary}\n\nConsider how this user's background, lifestyle factors (smoking, alcohol, diet), and current medications may affect their risk profile and the applicability of these study findings.` + : ''; const llmDescription = getLLMDescription(); @@ -667,6 +695,84 @@ Write questions from the user's perspective — as if the user is asking you. No } }; + const handleResearch = async () => { + const query = inputValue.trim(); + if (!query || isLoading) return; + if (!requirePremium()) return; + + setInputValue(''); + setIsLoading(true); + setError(null); + setAttachmentError(null); + + trackLLMQuestionAsked({ isFollowUp: messages.length > 0 }); + + const userMessage: Message = { role: 'user', content: query, timestamp: new Date() }; + const assistantMessage: Message = { role: 'assistant', content: '', timestamp: new Date() }; + setMessages(prev => [...prev, userMessage, assistantMessage]); + + try { + const llmDescription = getLLMDescription(); + const customizationSummary = buildUserContext(); + + let capturedAngles: ResearchAngle[] | undefined; + + const stream = runResearchPipeline( + query, + customizationSummary, + resultsContext.savedResults.length, + getTopResultsByRelevance, + setLoadingStatus, + llmDescription, + (angles) => { capturedAngles = angles; }, + ); + + let accumulatedContent = ''; + let isFirstChunk = true; + + for await (const chunk of stream) { + if (isFirstChunk) { + setIsLoading(false); + setLoadingStatus(''); + isFirstChunk = false; + } + accumulatedContent += chunk; + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { ...updated[updated.length - 1], content: accumulatedContent }; + return updated; + }); + } + + if (!accumulatedContent) throw new Error('No response from research pipeline.'); + + const followupMatch = accumulatedContent.split(/\n+FOLLOWUP:\n/); + const displayContent = followupMatch[0].trim(); + const followupQuestions = followupMatch[1] + ? followupMatch[1].split('\n').filter(l => l.startsWith('- ')).map(l => l.slice(2).trim()).filter(Boolean) + : []; + + setMessages(prev => { + const updated = [...prev]; + updated[updated.length - 1] = { ...updated[updated.length - 1], content: displayContent, followupQuestions, researchMeta: capturedAngles }; + return updated; + }); + } catch (err) { + console.error('[Research] Error:', err); + let errorMessage = err instanceof Error ? err.message : 'Research failed.'; + if (errorMessage.includes('429')) errorMessage = 'Rate limit exceeded. Please wait and try again.'; + setError(errorMessage); + setMessages(prev => { + const lastMsg = prev[prev.length - 1]; + if (lastMsg?.role === 'assistant' && !lastMsg.content) return prev.slice(0, -1); + return prev; + }); + } finally { + setIsLoading(false); + setLoadingStatus(''); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); @@ -939,6 +1045,28 @@ Write questions from the user's perspective — as if the user is asking you. No )}

)} + {message.role === 'assistant' && message.researchMeta && message.researchMeta.length > 0 && ( +
+ + {expandedResearchIndex === idx && ( +
+ {message.researchMeta.map((angle, aidx) => ( +
+
{angle.keyword}
+
+ {angle.resultsCount} studies searched +
+
+ ))} +
+ )} +
+ )} {message.role === 'assistant' && isLastAssistantMessage && !isLoading && (message.followupQuestions?.length ?? 0) > 0 && (
Try asking:
@@ -1100,6 +1228,9 @@ Write questions from the user's perspective — as if the user is asking you. No
)}
+
+ +
+
+

+ Send answers your question directly. Research searches 10 genetic angles and synthesizes a deeper answer. +

diff --git a/app/globals.css b/app/globals.css index b716127..5f72b54 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4907,6 +4907,55 @@ details[open] .summary-arrow { cursor: not-allowed; } +.chat-research-button { + padding: 0 1.25rem; + background: #7c3aed; + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.chat-research-button:hover:not(:disabled) { + background: #6d28d9; + transform: translateY(-1px); +} + +.chat-research-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.chat-research-premium-badge { + display: inline-block; + margin-left: 0.4rem; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + padding: 0.1em 0.35em; + vertical-align: middle; +} + +.chat-research-hint { + margin: 0.3rem 0 0; + font-size: 0.78rem; + color: var(--text-secondary); + text-align: right; +} + +.chat-dynamic-widget { + display: flex; + align-items: center; + margin-right: auto; +} + .chat-footer-disclaimer { padding: 0.5rem 1.5rem; background: rgba(245, 158, 11, 0.05); @@ -5565,6 +5614,30 @@ details[open] .summary-arrow { cursor: not-allowed; } +.ai-chat-inline .chat-research-button { + padding: 0.6rem 1.25rem; + background: #7c3aed; + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.ai-chat-inline .chat-research-button:hover:not(:disabled) { + background: #6d28d9; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3); +} + +.ai-chat-inline .chat-research-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* Attachment UI Styles */ .ai-chat-inline .chat-buttons { display: flex; @@ -6520,7 +6593,8 @@ details[open] .summary-arrow { } .dna-chat-section .ai-chat-inline .chat-attachment-button, -.dna-chat-section .ai-chat-inline .chat-send-button { +.dna-chat-section .ai-chat-inline .chat-send-button, +.dna-chat-section .ai-chat-inline .chat-research-button { min-height: 38px; border-radius: 6px; font-size: 0.86rem; @@ -6535,6 +6609,11 @@ details[open] .summary-arrow { padding: 0.55rem 1rem; } +.dna-chat-section .ai-chat-inline .chat-research-button { + min-width: 106px; + padding: 0.55rem 1rem; +} + .dna-chat-section .chat-footer-disclaimer { padding: 0.35rem 0.85rem; font-size: 0.68rem; @@ -6860,7 +6939,8 @@ details[open] .summary-arrow { } .dna-chat-section .ai-chat-inline .chat-attachment-button, -.dna-chat-section .ai-chat-inline .chat-send-button { +.dna-chat-section .ai-chat-inline .chat-send-button, +.dna-chat-section .ai-chat-inline .chat-research-button { min-height: 40px; font-weight: 800; } @@ -6869,6 +6949,15 @@ details[open] .summary-arrow { background: #0f766e; } +.dna-chat-section .ai-chat-inline .chat-research-button { + background: #7c3aed; +} + +.dna-chat-section .ai-chat-inline .chat-research-button:hover:not(:disabled) { + background: #6d28d9; + box-shadow: 0 8px 20px rgba(124, 58, 237, 0.24); +} + .dna-chat-section .ai-chat-inline .chat-send-button:hover:not(:disabled) { background: #115e59; box-shadow: 0 8px 20px rgba(15, 118, 110, 0.24); diff --git a/lib/research-service.ts b/lib/research-service.ts new file mode 100644 index 0000000..9e7dbb8 --- /dev/null +++ b/lib/research-service.ts @@ -0,0 +1,158 @@ +import { callLLM, callLLMStream, LLMMessage } from './llm-client'; +import { SavedResult } from './results-manager'; + +export type ResearchAngle = { keyword: string; resultsCount: number }; + +const ITERATIONS = 10; +const RAG_LIMIT = 100; + +function formatRiskScore(score: number, level: string, effectType?: 'OR' | 'beta'): string { + if (level === 'neutral') return effectType === 'beta' ? 'baseline' : '1.0x'; + if (effectType === 'beta') return `β=${score >= 0 ? '+' : ''}${score.toFixed(3)} units`; + return `${score.toFixed(2)}x`; +} + +function formatResults(results: SavedResult[]): string { + return results + .map((r, idx) => + `${idx + 1}. ${r.traitName} (${r.studyTitle}): + - Your genotype: ${r.userGenotype} + - Risk allele: ${r.riskAllele} + - Risk score: ${formatRiskScore(r.riskScore, r.riskLevel, r.effectType)} (${r.riskLevel}) + - SNP: ${r.matchedSnp}` + ) + .join('\n\n'); +} + +export async function* runResearchPipeline( + query: string, + customizationSummary: string, + totalResults: number, + getTopResultsByRelevance: (q: string, limit: number) => Promise, + onStatus: (status: string) => void, + llmDescription: string, + onMeta?: (angles: ResearchAngle[]) => void, +): AsyncGenerator { + // Step 1: Extract keyword angles + onStatus('Extracting research angles...'); + + const keywordMessages: LLMMessage[] = [ + { + role: 'system', + content: `You extract GWAS database search keywords from a user's question about their genetics. + +RULES: +- Output exactly ${ITERATIONS} keywords or short phrases, one per line, no numbering, no explanation. +- Keywords must reflect the TOPIC of the question — the biological traits, phenotypes, or mechanisms being asked about. +- Do NOT use the user's medical conditions as keywords unless the question is directly asking about those conditions. +- The user context is provided only to help you pick the most relevant angles within the question's topic. It must not redirect the keywords toward unrelated health conditions. +- Be specific. "Bitter taste receptor TAS2R" is better than "taste preferences". + +EXAMPLE: If the question is "What foods will I like?" extract keywords about food preference genetics (taste receptors, flavor perception, olfaction, dietary patterns, food aversion). NOT about diabetes or autoimmune disease.`, + }, + { + role: 'user', + content: `User question: "${query}"\n\nUser context (for prioritization only, do not redirect keywords):\n${customizationSummary || 'No personalization set.'}`, + }, + ]; + + const keywordResponse = await callLLM(keywordMessages, { maxTokens: 150, temperature: 0.5 }); + + const keywords = keywordResponse.content + .split('\n') + .map(k => k.trim()) + .filter(k => k.length > 0) + .slice(0, ITERATIONS); + + if (keywords.length === 0) keywords.push(query); + + // Step 2: Sequential RAG + focused analysis for each angle (avoids hammering nilAI) + const analyses: { keyword: string; analysis: string; resultsCount: number }[] = []; + + for (let i = 0; i < keywords.length; i++) { + const keyword = keywords[i]; + onStatus(`Angle ${i + 1}/${keywords.length}: ${keyword}...`); + + const results = await getTopResultsByRelevance(keyword, RAG_LIMIT); + + if (results.length === 0) { + analyses.push({ keyword, analysis: 'No relevant findings for this angle.', resultsCount: 0 }); + continue; + } + + const analysisMessages: LLMMessage[] = [ + { + role: 'system', + content: `You analyze GWAS genetic findings for a specific research angle. ${llmDescription} + +USER CONTEXT: +${customizationSummary || 'No personalization set.'} + +ORIGINAL USER QUESTION: "${query}" + +RESEARCH ANGLE: "${keyword}" + +GWAS FINDINGS: +${formatResults(results)} + +Write a focused 150-word analysis of what these findings mean for this user, specific to the "${keyword}" angle. Reference actual findings by trait name and OR/beta values. Be concrete and personalized. No disclaimers needed here.`, + }, + { + role: 'user', + content: `Analyze the "${keyword}" angle.`, + }, + ]; + + const response = await callLLM(analysisMessages, { maxTokens: 350, temperature: 0.6 }); + analyses.push({ keyword, analysis: response.content, resultsCount: results.length }); + } + + onMeta?.(analyses.map(a => ({ keyword: a.keyword, resultsCount: a.resultsCount }))); + + // Step 3: Stream comprehensive synthesis + onStatus('Synthesizing findings...'); + + const analysesText = analyses + .map(a => `**Angle: ${a.keyword}** (${a.resultsCount} studies)\n${a.analysis}`) + .join('\n\n'); + + const synthesisMessages: LLMMessage[] = [ + { + role: 'system', + content: `You synthesize multi-angle genetic research into a comprehensive personalized answer. ${llmDescription} + +USER CONTEXT: +${customizationSummary || 'No personalization set.'} + +Total results in user profile: ${totalResults.toLocaleString()} + +RESEARCH ANALYSES (${analyses.length} angles): +${analysesText} + +Synthesize these analyses into a comprehensive answer to the user's question. Identify patterns across angles, highlight convergent findings, note what stands out given the user's context. Use the standard structure: overview, genetic landscape, personal implications, action steps. Target 600-900 words. Plain language. Complete your full response. + +GWAS results show statistical associations at the population level, not deterministic outcomes. Educational purposes only. + +After the response, append exactly this block: + +FOLLOWUP: +- [question from the user's perspective connecting a finding directly to their personal situation] +- [question digging deeper on the most impactful finding across the analyses] +- [question connecting two of the research angles or asking how they interact]`, + }, + { + role: 'user', + content: query, + }, + ]; + + const stream = callLLMStream(synthesisMessages, { + maxTokens: 3000, + temperature: 0.7, + reasoningEffort: 'medium', + }); + + for await (const chunk of stream) { + yield chunk; + } +} From 1b062a1ab3ec9987b17ea1d311d33f39314d9544 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Fri, 12 Jun 2026 16:23:28 -0400 Subject: [PATCH 3/6] Changed paywall features. --- app/components/LLMChatInline.tsx | 17 ++++----- app/components/MenuBar.tsx | 7 ++-- app/components/PremiumFeatureHeader.tsx | 10 ++++-- app/dna-chat/page.tsx | 7 ++++ app/globals.css | 47 ++++++++++++++++++++++--- app/landing-client.tsx | 2 +- app/overview-report/page.tsx | 15 +++++--- 7 files changed, 75 insertions(+), 30 deletions(-) diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index 5b6a278..453200b 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { SavedResult } from "@/lib/results-manager"; import { useResults } from "./ResultsContext"; import { useCustomization } from "./CustomizationContext"; -import { useAuth, AuthButton } from "./AuthProvider"; +import { useAuth } from "./AuthProvider"; import { hasValidPromoAccess } from "@/lib/promo-access"; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -58,7 +58,7 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } const resultsContext = useResults(); const { getTopResultsByRelevance } = resultsContext; const { customization, status: customizationStatus } = useCustomization(); - const { isAuthenticated, hasActiveSubscription, openAuthModal, initializeDynamic, isDynamicInitialized } = useAuth(); + const { isAuthenticated, hasActiveSubscription, openAuthModal } = useAuth(); const [hasPromoAccess, setHasPromoAccess] = useState(false); const [mounted, setMounted] = useState(false); const [messages, setMessages] = useState([]); @@ -94,10 +94,6 @@ export default function AIChatInline({ initialInput }: { initialInput?: string } return () => window.removeEventListener('premiumAccessUpdated', refresh); }, []); - useEffect(() => { - if (!isDynamicInitialized) initializeDynamic(); - }, [initializeDynamic, isDynamicInitialized]); - const hasPremiumAccess = hasActiveSubscription || hasPromoAccess; const requirePremium = (): boolean => { @@ -967,7 +963,7 @@ Write questions from the user's perspective — as if the user is asking you. No
{messages.length === 0 && (
- {} +

Try asking something about your results:

    {EXAMPLE_QUESTIONS.map((question) => ( @@ -1228,9 +1224,6 @@ Write questions from the user's perspective — as if the user is asking you. No
)}
-
- -
+
+

- Send answers your question directly. Research searches 10 genetic angles and synthesizes a deeper answer. + Send — quick answer. Research — explores 10 genetic angles in depth.

diff --git a/app/components/MenuBar.tsx b/app/components/MenuBar.tsx index 7ed9f79..f586946 100644 --- a/app/components/MenuBar.tsx +++ b/app/components/MenuBar.tsx @@ -426,13 +426,10 @@ export default function MenuBar() { - - Analyze - Premium - + Analyze
diff --git a/app/components/PremiumFeatureHeader.tsx b/app/components/PremiumFeatureHeader.tsx index aac1dbb..8653c38 100644 --- a/app/components/PremiumFeatureHeader.tsx +++ b/app/components/PremiumFeatureHeader.tsx @@ -9,11 +9,15 @@ import { trackOverviewReportTabViewed } from "@/lib/analytics"; type PremiumFeatureHeaderProps = { featureName: string; description: string; + gateTitle?: string; // overrides default "featureName is a premium tab" / "Premium subscription required" + gateDescription?: string; // overrides default "Subscribe for $4.99/month to access featureName." }; export default function PremiumFeatureHeader({ featureName, description, + gateTitle, + gateDescription, }: PremiumFeatureHeaderProps) { const { isAuthenticated, @@ -60,7 +64,7 @@ export default function PremiumFeatureHeader({ {!isAuthenticated && !hasPromoAccess ? (
- {featureName} is a premium tab + {gateTitle ?? `${featureName} is a premium tab`} {description}
+ {sampleProgressText && ( +

+ {sampleProgressText} +

+ )} + {sampleError && ( +

{sampleError}

+ )} +

+ No DNA file needed. Your data never leaves your device.{' '} + trackGetStartedClicked("schedule_video_call")} + > + Need help? Book a free call. + +

+
+
{featureCopy.map((item) => (

@@ -184,45 +221,6 @@ export default function LandingClient() {

))}
- - {error &&

{error}

} - -
-
- - - No DNA file? Load an example to explore the app. - -
- {sampleProgressText && ( -

- {sampleProgressText} -

- )} - {sampleError && ( -

{sampleError}

- )} -
- -

- New to the app?{' '} - trackGetStartedClicked("schedule_video_call")} - > - Book a free help call. - -

From f4c1eb7dd606ed6db0a87e0a28c6d371f6bce222 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Fri, 12 Jun 2026 16:36:46 -0400 Subject: [PATCH 5/6] MAde section headers pop more. --- app/components/MenuBar.tsx | 2 +- app/globals.css | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/components/MenuBar.tsx b/app/components/MenuBar.tsx index f586946..6e3c648 100644 --- a/app/components/MenuBar.tsx +++ b/app/components/MenuBar.tsx @@ -312,7 +312,7 @@ export default function MenuBar() { textDecoration: "none", color: active ? "var(--primary-color, #667eea)" : "inherit", borderBottom: active ? "2px solid var(--primary-color, #667eea)" : "none", - fontWeight: active ? 600 : 400 + fontWeight: active ? 700 : 500 }); const isDNAChatActive = pathname === "/dna-chat" || pathname === "/llm-chat"; diff --git a/app/globals.css b/app/globals.css index ab8d1b4..02ee244 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3634,8 +3634,9 @@ details[open] .summary-arrow { } .nav-link { - padding: 0.35rem 0.25rem !important; - font-size: 0.75rem; + padding: 0.35rem 0.5rem !important; + font-size: 0.875rem; + font-weight: 500; } .nav-premium-badge { From 6de019346619130fd99b1813375c97693f1c5f13 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Fri, 12 Jun 2026 16:51:04 -0400 Subject: [PATCH 6/6] Fixed build errors. --- app/components/ConversionOnboarding.tsx | 8 -------- app/components/LLMCommentaryModal.tsx | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/components/ConversionOnboarding.tsx b/app/components/ConversionOnboarding.tsx index 3186e65..41128c0 100644 --- a/app/components/ConversionOnboarding.tsx +++ b/app/components/ConversionOnboarding.tsx @@ -274,9 +274,7 @@ export default function ConversionOnboarding({ setDetailResult(null); setExpandedTraitId(null); setCommentaryResult(null); - setPendingQuestion(null); setActiveQuestion(null); - setShowConsentModal(false); setRunAllProgress({ phase: "downloading", loaded: 0, @@ -300,11 +298,6 @@ export default function ConversionOnboarding({ } }, [completionPath, currentStep, isOpen, mode, mounted]); - useEffect(() => { - if (typeof window === "undefined" || commentaryResult) return; - setHasConsent(localStorage.getItem(CONSENT_STORAGE_KEY) === "true"); - }, [commentaryResult]); - const selectedTraitResults = useMemo( () => traitCandidates.filter((result) => selectedTraitIds.includes(result.studyId)), [selectedTraitIds, traitCandidates] @@ -568,7 +561,6 @@ RESPONSE STRUCTURE: setResponseError(null); setResponsesLoading(false); setDetailResult(null); - setPendingQuestion(null); setActiveQuestion(null); setCurrentStep("responses"); diff --git a/app/components/LLMCommentaryModal.tsx b/app/components/LLMCommentaryModal.tsx index 3d5913c..435cb83 100644 --- a/app/components/LLMCommentaryModal.tsx +++ b/app/components/LLMCommentaryModal.tsx @@ -692,7 +692,7 @@ Keep your response concise (400-600 words), educational, and reassuring where ap const handlePersonalizationPromptContinue = () => { setShowPersonalizationPrompt(false); - setShowConsentModal(true); + fetchCommentary(); }; if (!isOpen) return null;