From 62a52b78c004c4a62650f0cb658268eb2220d0d7 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Tue, 25 Nov 2025 16:53:10 -0500 Subject: [PATCH 01/11] Bunch of prompt engineering. --- app/components/OverviewReportModal.tsx | 445 ++++++++++++++++++------- app/globals.css | 139 +++++++- app/page.tsx | 2 +- lib/overview-report-analyzer.ts | 14 +- lib/overview-report-service.ts | 8 +- 5 files changed, 472 insertions(+), 136 deletions(-) diff --git a/app/components/OverviewReportModal.tsx b/app/components/OverviewReportModal.tsx index 2f04efa..7c9421d 100644 --- a/app/components/OverviewReportModal.tsx +++ b/app/components/OverviewReportModal.tsx @@ -265,7 +265,7 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM - Comprehensive Genetic Overview Report + Comprehensive Genetic Overview Report - Monadic DNA Explorer -
- Generated: ${new Date().toLocaleString()}
- Results Analyzed: ${savedResults.length.toLocaleString()} high-confidence genetic variants
- Batches Processed: ${progress.groupSummaries.length}
- Powered by: OpenAI GPT-4o +
+

Comprehensive Genetic Overview Report

+
Generated by Monadic DNA Explorer
+ +
+

About Monadic DNA Explorer

+

Monadic DNA Explorer is a personal genomics analysis tool that processes publicly available GWAS (Genome-Wide Association Studies) data to generate personalized genetic insights. This report synthesizes findings from ${savedResults.length.toLocaleString()} high-confidence genetic variants that matched the user's genetic profile out of over 1 million available traits.

+

Analysis Method: This report uses a map-reduce LLM approach, where genetic data is divided into ${progress.groupSummaries.length} batches, each analyzed independently before being synthesized into comprehensive insights.

+

Data Sources: Analysis is based on peer-reviewed GWAS studies from the GWAS Catalog and other publicly available genomic databases.

+
+ +
+

โš ๏ธ Important Medical Disclaimer

+

This report is for educational and informational purposes only. It is not intended to diagnose, treat, cure, or prevent any disease or medical condition.

+

The information provided should not be used as a substitute for professional medical advice, diagnosis, or treatment. Always seek the advice of a qualified healthcare provider with any questions regarding a medical condition or genetic findings.

+

Genetic analysis is complex and constantly evolving. Results should be interpreted by qualified medical professionals in the context of complete medical history, family history, and clinical examination.

+
+ +
+ ${finalReportHTML} ${mapReportsHTML} -
- Generated by GWASifier โ€ข For Educational Purposes Only + +
+

Generated by Monadic DNA Explorer

+

https://monadicdna.com

+

For Educational Purposes Only โ€ข Not Medical Advice

@@ -425,49 +522,67 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM return (
e.stopPropagation()}> -
-

๐Ÿ“Š Comprehensive Genetic Overview Report (Experimental)

- -
+
{isBlocked ? (
-

๐Ÿ”’ Premium Feature

-

+

๐Ÿ”’ Premium Feature

+

Overview Report requires an active premium subscription.

-

+

Subscribe for $4.99/month to unlock comprehensive LLM-powered analysis of all your genetic results.

) : progress.phase === 'idle' ? ( -
+
-

+

+ ๐Ÿ“Š Comprehensive Genetic Overview Report (Experimental) +

+

Generate a comprehensive overview report analyzing all {savedResults.length.toLocaleString()} of your high-confidence genetic results.

-

- This report uses advanced LLM to identify patterns, themes, and actionable insights across your entire genetic profile. +

+ This report uses AI to identify patterns, themes, and actionable insights across your entire genetic profile.

-

What's included:

-
    -
  • Analysis by major health categories (cardiovascular, metabolic, neurological, etc.)
  • +

    What's included:

    +
      +
    • Analysis by major categories (traits, conditions, physiological factors, etc.)
    • Identification of genetic strengths and areas to monitor
    • Personalized action plan based on your background
    • Cross-system insights and connections
    • @@ -476,20 +591,34 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM
+

+ โš ๏ธ Experimental Feature: This feature uses a map/reduce approach where your data is split into batches, each analyzed separately by an LLM, then synthesized into a final report. Generation time is highly variable and difficult to predict. This process involves multiple LLM calls and may take several minutes to complete. +

+
+ +
-

- โฑ๏ธ Generation time: Approximately 3-4 minutes -
- ๐Ÿ”’ Privacy: All processing happens in your browser - data never leaves your device except via nilAI TEE -
- ๐Ÿ“Š Analysis depth: {savedResults.length.toLocaleString()} genetic variants analyzed in 5 groups -
- ๐Ÿค– LLM calls: 6 total (5 analysis + 1 synthesis) +

+ ๐Ÿ”’ Privacy: Your genetic data is processed securely through nilAI's confidential computing environment. Analysis requests are encrypted and processed in a trusted execution environment that prevents any access to your data. +

+

+ ๐Ÿ“Š Analysis depth: {savedResults.length.toLocaleString()} genetic variants +

+

+ ๐Ÿค– LLM calls: {(() => { + const highConfResults = savedResults.filter(r => + r.sampleSize >= 5000 && r.relevanceScore >= 9 + ); + const batchCount = Math.max(4, Math.min(32, Math.ceil(highConfResults.length / 3000))); + return `${batchCount + 1} total (${batchCount} batch analyses + 1 synthesis)`; + })()}

@@ -497,13 +626,13 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM className="primary-button" onClick={handleGenerate} disabled={savedResults.length === 0} - style={{ width: '100%', padding: '1rem', fontSize: '1rem' }} + style={{ width: '100%', padding: '1rem', fontSize: '1rem', marginTop: '1.5rem' }} > Generate Overview Report {savedResults.length < 10000 && ( -

+

โš ๏ธ You have {savedResults.length.toLocaleString()} results. For best results, run "Run All" to analyze more studies.

)} @@ -533,9 +662,13 @@ export default function OverviewReportModal({ isOpen, onClose }: OverviewReportM
) : progress.phase === 'complete' && progress.finalReport ? ( -
+
-
+
+ Generated: {new Date().toLocaleString()}
+ Results Analyzed: {savedResults.length.toLocaleString()} high-confidence genetic variants
+ Batches Processed: {progress.groupSummaries.length} +
+ +
{progress.finalReport}
+ + {progress.groupSummaries.length > 0 && ( +
+

+ Appendix: Detailed Batch Analysis +

+

+ The following sections contain the detailed analysis from each batch of genetic variants that were synthesized into the main report above. +

+ + {progress.groupSummaries.map((gs, idx) => ( +
+

+ Batch {gs.groupNumber} +

+
+ + {gs.summary} + +
+
+ ))} +
+ )} + +
+ Generated by GWASifier โ€ข For Educational Purposes Only +
) : ( -
-
-

+
+
+

{progress.phase === 'map' ? '๐Ÿ” Analyzing Results' : '๐Ÿค– Synthesizing Report'} -

-

{progress.message}

+

+

{progress.message}

-

+

{progress.progress}%

-

+

โฑ๏ธ {formatTime(elapsedTime)}

{progress.currentGroup && progress.totalGroups && ( -

+

Batch {progress.currentGroup} of {progress.totalGroups}

)} {progress.estimatedTimeRemaining !== undefined && progress.estimatedTimeRemaining > 0 ? ( -

+

ETA: {formatTime(progress.estimatedTimeRemaining)} remaining {progress.averageTimePerGroup && ( - + (~{progress.averageTimePerGroup.toFixed(0)}s per batch) )}

) : ( -

- Analyzing your genetic data... This may take 60-90 minutes. +

+ Analyzing your genetic data... This may take up to 10 minutes.

)} -
- {/* Show intermediate reports as they come in */} - {progress.groupSummaries.length > 0 && ( -
-

- ๐Ÿ“ Intermediate Analysis ({progress.groupSummaries.length} batches completed) -

+ {/* Show intermediate reports as they come in */} + {progress.groupSummaries.length > 0 && (
- {progress.groupSummaries.map((gs, idx) => ( - - ))} +

+ ๐Ÿ“ Intermediate Analysis ({progress.groupSummaries.length} batches completed) +

+
+ {progress.groupSummaries.map((gs, idx) => ( + + ))} +
-
- )} + )} +
{/* Batch Detail Modal */} {selectedBatchIndex !== null && progress.groupSummaries[selectedBatchIndex] && ( diff --git a/app/globals.css b/app/globals.css index d67d558..10aa87b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -17,6 +17,12 @@ --accent-green: #10B981; --accent-yellow: #F59E0B; --accent-red: #EF4444; + --modal-bg: #FFFFFF; + --section-bg: #EFF6FF; + --warning-bg: #FEF3C7; + --warning-border: #FDE68A; + --info-bg: #EFF6FF; + --info-border: #BFDBFE; } :root[data-theme="dark"] { @@ -32,6 +38,12 @@ --text-primary: #FBFBFB; --text-secondary: rgba(251, 251, 251, 0.7); --text-muted: rgba(251, 251, 251, 0.5); + --modal-bg: rgba(10, 10, 10, 0.95); + --section-bg: rgba(31, 41, 55, 0.5); + --warning-bg: rgba(120, 53, 15, 0.3); + --warning-border: rgba(245, 158, 11, 0.5); + --info-bg: rgba(31, 41, 55, 0.6); + --info-border: rgba(59, 130, 246, 0.4); } * { @@ -5266,44 +5278,69 @@ details[open] .summary-arrow { } .markdown-content { - line-height: 1.7; + line-height: 1.8; + font-size: 1rem; } .markdown-content h1 { color: var(--text-primary); - border-bottom: 2px solid var(--accent-blue); - padding-bottom: 0.5rem; + border-bottom: 3px solid var(--accent-blue); + padding-bottom: 0.75rem; + margin-top: 2rem; margin-bottom: 1.5rem; + font-size: 2rem; + font-weight: 700; +} + +.markdown-content h1:first-child { + margin-top: 0; } .markdown-content h2 { color: var(--text-primary); - margin-top: 2rem; + margin-top: 2.5rem; margin-bottom: 1rem; font-size: 1.5rem; + font-weight: 600; + border-bottom: 2px solid var(--border-color); + padding-bottom: 0.5rem; } .markdown-content h3 { color: var(--text-primary); - margin-top: 1.5rem; + margin-top: 2rem; margin-bottom: 0.75rem; font-size: 1.25rem; + font-weight: 600; +} + +.markdown-content h4 { + color: var(--text-primary); + margin-top: 1.5rem; + margin-bottom: 0.5rem; + font-size: 1.1rem; + font-weight: 600; } .markdown-content p { - margin: 1rem 0; - color: var(--text-secondary); + margin: 1.25rem 0; + color: var(--text-primary); + line-height: 1.8; } .markdown-content ul, .markdown-content ol { - margin: 1rem 0; + margin: 1.25rem 0; padding-left: 2rem; - color: var(--text-secondary); + color: var(--text-primary); } .markdown-content li { + margin: 0.75rem 0; + line-height: 1.7; +} + +.markdown-content li > p { margin: 0.5rem 0; - line-height: 1.6; } .markdown-content strong { @@ -5311,6 +5348,88 @@ details[open] .summary-arrow { font-weight: 600; } +.markdown-content em { + font-style: italic; + color: var(--text-secondary); +} + +.markdown-content code { + background: var(--surface-bg); + border: 1px solid var(--border-color); + padding: 0.2rem 0.4rem; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', 'Consolas', monospace; + font-size: 0.9em; + color: var(--text-primary); +} + +.markdown-content pre { + background: var(--surface-bg); + border: 1px solid var(--border-color); + padding: 1rem; + border-radius: 6px; + overflow-x: auto; + margin: 1.5rem 0; +} + +.markdown-content pre code { + background: none; + border: none; + padding: 0; + font-size: 0.9rem; +} + +.markdown-content blockquote { + border-left: 4px solid var(--accent-blue); + padding-left: 1rem; + margin: 1.5rem 0; + color: var(--text-secondary); + font-style: italic; +} + +.markdown-content a { + color: var(--accent-blue); + text-decoration: none; + border-bottom: 1px solid transparent; + transition: border-color 0.2s; +} + +.markdown-content a:hover { + border-bottom-color: var(--accent-blue); +} + +.markdown-content hr { + border: none; + border-top: 2px solid var(--border-color); + margin: 2rem 0; +} + +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin: 1.5rem 0; + font-size: 0.95rem; +} + +.markdown-content table th { + background: var(--surface-bg); + color: var(--text-primary); + font-weight: 600; + padding: 0.75rem; + text-align: left; + border: 1px solid var(--border-color); +} + +.markdown-content table td { + padding: 0.75rem; + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +.markdown-content table tr:nth-child(even) { + background: var(--surface-bg); +} + .generation-progress { min-height: 300px; } diff --git a/app/page.tsx b/app/page.tsx index 4e69180..d7a5c81 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -469,7 +469,7 @@ function MainContent() { }); return () => controller.abort(); - }, [debouncedSearch, filters.trait, filters.minSampleSize, filters.maxPValue, filters.excludeLowQuality, filters.excludeMissingGenotype, filters.requireUserSNPs, filters.sort, filters.sortDirection, filters.limit, filters.confidenceBand, filters.offset, genotypeData]); + }, [debouncedSearch, filters.trait, filters.minSampleSize, filters.maxPValue, filters.excludeLowQuality, filters.excludeMissingGenotype, filters.requireUserSNPs, filters.sort, filters.sortDirection, filters.limit, filters.confidenceBand, filters.offset, filters.searchMode, genotypeData]); const qualitySummary = useMemo(() => { return studies.reduce( diff --git a/lib/overview-report-analyzer.ts b/lib/overview-report-analyzer.ts index 4d7b8ff..0fe8443 100644 --- a/lib/overview-report-analyzer.ts +++ b/lib/overview-report-analyzer.ts @@ -200,12 +200,13 @@ export function generateMapPrompt( userContext: string ): string { return `Here are genetic traits from GWAS Catalog matched by the Monadic DNA Explorer tool. This is the map phase. - Please analyze health, lifestyle, appearance, personality and fun facts for the reduce phase. + Please analyze health, lifestyle, appearance, personality, rare disease and fun facts for the reduce phase. Fun facts should actually be fun and not serious medical stuff. Do not include tutorial, recommendations, next steps. The output is not meant for the user. Rather, the next reduce phase will be handled by an LLM. Remember to base relevance regardless of risk level, i.e. include increased, decreased or neutral entries, so the user gets a holistic picture. Output text with no tables or any fancy formatting. - Do not comment on SNPs and genes I do not have. + CRITICAL: Do not comment on SNPs and genes I do not have. Stick to the results and do not speculate about the effect of any SNPs or genes. + Back up each assertion with a specific reference to SNPs actually present in the dataset. USER:${userContext} @@ -254,14 +255,15 @@ export function generateReducePrompt( return `Here are batched analyses of genetic traits from GWAS Catalog matched by the Monadic DNA Explorer tool. I am ${userContext} -Please analyze and produce a five page report (health, lifestyle, appearance, personality, fun facts) suitable for personal genomics users. +Please summarize and produce a report (health, diet, lifestyle, appearance, personality, fun facts, rare disease) suitable for personal genomics users. Fun facts should actually be fun and not serious medical stuff. -Make sure you mention the most salient SNPs and genes. - Minimize specific medical recommendation or testing recommendations as we do not want to flood the medical system with unnecessary costs. -Output text with no tables or any fancy formatting. Do not comment on SNPs and genes I do not have. +Output text with no tables or any fancy formatting. Section headings are ok. + +CRITICAL: Do not comment on SNPs and genes I do not have. Stick to the results and do not speculate about the effect of any SNPs or genes. + ${summariesText}`; } diff --git a/lib/overview-report-service.ts b/lib/overview-report-service.ts index 3809651..1eb0522 100644 --- a/lib/overview-report-service.ts +++ b/lib/overview-report-service.ts @@ -243,7 +243,7 @@ export async function generateOverviewReport( // Use HIGH reasoning effort for complex pattern recognition across variants // TESTING: Temporarily limit max tokens to 1,300 const response = await callLLM([{ role: 'user', content: mapPrompt }], { - temperature: 0.7, + temperature: 0.4, reasoningEffort: 'low', maxTokens: 13000, }); @@ -311,9 +311,9 @@ export async function generateOverviewReport( // Use LOW reasoning effort to stay under token limit // No maxTokens limit - let model generate comprehensive reports const response = await callLLM([{ role: 'user', content: reducePrompt }], { - temperature: 0.7, - reasoningEffort: 'high', - maxTokens: 13000, + temperature: 0.2, + reasoningEffort: 'medium', + maxTokens: 25000, }); const finalReport = response.content; From 0d143a5893f3085b5f161ea14b306b798198a9a1 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Wed, 26 Nov 2025 15:03:08 -0500 Subject: [PATCH 02/11] More prompt engineerring. Added gpt-oss-120b. as an option. --- app/components/LLMConfigModal.tsx | 39 ++++++++++++++++++--- lib/llm-client.ts | 57 +++++++++++++++++-------------- lib/llm-config.ts | 20 +++++++++-- 3 files changed, 85 insertions(+), 31 deletions(-) diff --git a/app/components/LLMConfigModal.tsx b/app/components/LLMConfigModal.tsx index cb4c51e..bb67fc4 100644 --- a/app/components/LLMConfigModal.tsx +++ b/app/components/LLMConfigModal.tsx @@ -108,18 +108,49 @@ export default function LLMConfigModal({ isOpen, onClose, onSave }: LLMConfigMod <> + + + + ) : config.provider === 'nilai' ? ( + <> + + ) : ( - + <> + + + + )}

- {config.provider === 'ollama' - ? 'Select the model format that matches your Ollama installation.' - : 'Currently only gpt-oss-20b is supported across all providers.'} + {config.provider === 'ollama' + ? 'Select the model format that matches your Ollama installation or choose Custom to enter your own.' + : 'Select a preset model or choose Custom to enter your own model name.'}

+ {config.model === 'custom' && ( +
+ + + setConfig({ ...config, customModel: e.target.value }) + } + placeholder="Enter model name (e.g., gpt-oss-20b, openai/gpt-oss-120b)" + /> +

+ Enter the exact model identifier for your provider. +

+
+ )} + {config.provider === 'ollama' && ( <>
diff --git a/lib/llm-client.ts b/lib/llm-client.ts index 6a9599d..01d891e 100644 --- a/lib/llm-client.ts +++ b/lib/llm-client.ts @@ -42,6 +42,7 @@ export async function callLLM( options: LLMOptions = {} ): Promise { const config = getLLMConfig(); + const modelId = getModelIdentifier(config); const { maxTokens, temperature = 0.7, reasoningEffort = 'medium' } = options; // Calculate prompt length for logging @@ -64,7 +65,7 @@ export async function callLLM( โ•‘ LLM REQUEST START โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• โ•‘ Provider: ${config.provider} -โ•‘ Model: ${config.model} +โ•‘ Model: ${modelId} โ•‘ Max Output Tokens: ${maxTokens !== undefined ? maxTokens.toLocaleString() : 'unlimited (model max)'} โ•‘ Reasoning Effort: ${reasoningEffort} โ•‘ Temperature: ${temperature} @@ -93,18 +94,18 @@ export async function callLLM( try { switch (config.provider) { case 'nilai': - response = await callNilAI(messages, maxTokens, temperature, reasoningEffort); + response = await callNilAI(messages, maxTokens, temperature, reasoningEffort, modelId); break; case 'ollama': - response = await callOllama(messages, maxTokens, temperature, reasoningEffort, config.ollamaAddress, config.ollamaPort, config.model); + response = await callOllama(messages, maxTokens, temperature, reasoningEffort, config.ollamaAddress, config.ollamaPort, modelId); break; case 'huggingface': if (!config.huggingfaceApiKey) { throw new Error('HuggingFace API key not configured'); } - response = await callHuggingFace(messages, maxTokens, temperature, reasoningEffort, config.huggingfaceApiKey); + response = await callHuggingFace(messages, maxTokens, temperature, reasoningEffort, config.huggingfaceApiKey, modelId); break; default: @@ -125,7 +126,7 @@ export async function callLLM( โ•‘ LLM RESPONSE SUCCESS โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• โ•‘ Provider: ${config.provider} -โ•‘ Model: ${config.model} +โ•‘ Model: ${modelId} โ•‘ Time Taken: ${elapsedSeconds}s (${elapsedMs}ms) โ•‘ Reasoning Effort: ${reasoningEffort} โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• @@ -148,7 +149,7 @@ export async function callLLM( โ•‘ LLM REQUEST FAILED โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• โ•‘ Provider: ${config.provider} -โ•‘ Model: ${config.model} +โ•‘ Model: ${modelId} โ•‘ Time Taken: ${elapsedSeconds}s before failure โ•‘ Error: ${error instanceof Error ? error.message : String(error)} โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•`); @@ -164,7 +165,8 @@ async function callNilAI( messages: LLMMessage[], maxTokens: number | undefined, temperature: number, - reasoningEffort: 'low' | 'medium' | 'high' + reasoningEffort: 'low' | 'medium' | 'high', + modelId: string ): Promise { // Initialize client const client = new NilaiOpenAIClient({ @@ -192,7 +194,7 @@ async function callNilAI( // Call nilAI const response = await client.chat.completions.create({ - model: 'openai/gpt-oss-20b', + model: modelId, messages: messages as any, max_tokens: maxTokens || 131072, // Default to model max if not specified temperature, @@ -220,10 +222,10 @@ async function callOllama( reasoningEffort: 'low' | 'medium' | 'high', address?: string, port?: number, - model?: string + modelId?: string ): Promise { const baseURL = `http://${address || 'localhost'}:${port || 11434}`; - const modelName = model || 'gpt-oss:latest'; + const modelName = modelId || 'gpt-oss:latest'; // Extract prompt from messages const prompt = messages.map(m => `${m.role}: ${m.content}`).join('\n\n'); @@ -282,7 +284,8 @@ async function callHuggingFace( maxTokens: number | undefined, temperature: number, reasoningEffort: 'low' | 'medium' | 'high', - apiKey: string + apiKey: string, + modelId: string ): Promise { const response = await fetch('https://router.huggingface.co/v1/chat/completions', { method: 'POST', @@ -291,7 +294,7 @@ async function callHuggingFace( 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: 'openai/gpt-oss-20b:together', + model: modelId, messages, max_tokens: maxTokens || 131072, // Default to model max if not specified temperature, @@ -330,26 +333,27 @@ export async function* callLLMStream( options: LLMOptions = {} ): AsyncGenerator { const config = getLLMConfig(); + const modelId = getModelIdentifier(config); const { maxTokens, temperature = 0.7, reasoningEffort = 'medium' } = options; - console.log(`[LLM Stream] Starting stream with provider: ${config.provider}, model: ${config.model}`); + console.log(`[LLM Stream] Starting stream with provider: ${config.provider}, model: ${modelId}`); const startTime = Date.now(); try { switch (config.provider) { case 'nilai': - yield* streamNilAI(messages, maxTokens, temperature, reasoningEffort); + yield* streamNilAI(messages, maxTokens, temperature, reasoningEffort, modelId); break; case 'ollama': - yield* streamOllama(messages, maxTokens, temperature, reasoningEffort, config.ollamaAddress, config.ollamaPort, config.model); + yield* streamOllama(messages, maxTokens, temperature, reasoningEffort, config.ollamaAddress, config.ollamaPort, modelId); break; case 'huggingface': if (!config.huggingfaceApiKey) { throw new Error('HuggingFace API key not configured'); } - yield* streamHuggingFace(messages, maxTokens, temperature, reasoningEffort, config.huggingfaceApiKey); + yield* streamHuggingFace(messages, maxTokens, temperature, reasoningEffort, config.huggingfaceApiKey, modelId); break; default: @@ -372,7 +376,8 @@ async function* streamNilAI( messages: LLMMessage[], maxTokens: number | undefined, temperature: number, - reasoningEffort: 'low' | 'medium' | 'high' + reasoningEffort: 'low' | 'medium' | 'high', + modelId: string ): AsyncGenerator { const client = new NilaiOpenAIClient({ baseURL: 'https://nilai-f910.nillion.network/nuc/v1/', @@ -398,7 +403,7 @@ async function* streamNilAI( // Call nilAI with streaming const stream = await client.chat.completions.create({ - model: 'openai/gpt-oss-20b', + model: modelId, messages: messages as any, max_tokens: maxTokens || 131072, temperature, @@ -425,10 +430,10 @@ async function* streamOllama( reasoningEffort: 'low' | 'medium' | 'high', address?: string, port?: number, - model?: string + modelId?: string ): AsyncGenerator { const baseURL = `http://${address || 'localhost'}:${port || 11434}`; - const modelName = model || 'gpt-oss:latest'; + const modelName = modelId || 'gpt-oss:latest'; const response = await fetch(`${baseURL}/api/chat`, { method: 'POST', @@ -489,7 +494,8 @@ async function* streamHuggingFace( maxTokens: number | undefined, temperature: number, reasoningEffort: 'low' | 'medium' | 'high', - apiKey: string + apiKey: string, + modelId: string ): AsyncGenerator { const response = await fetch('https://router.huggingface.co/v1/chat/completions', { method: 'POST', @@ -498,7 +504,7 @@ async function* streamHuggingFace( 'Content-Type': 'application/json', }, body: JSON.stringify({ - model: 'openai/gpt-oss-20b:together', + model: modelId, messages, max_tokens: maxTokens || 131072, temperature, @@ -552,14 +558,15 @@ async function* streamHuggingFace( */ export function getLLMDescription(): string { const config = getLLMConfig(); + const modelId = getModelIdentifier(config); switch (config.provider) { case 'nilai': - return `๐Ÿ›ก๏ธ Powered by Nillion nilAI using ${config.model} in TEE (Trusted Execution Environment)`; + return `๐Ÿ›ก๏ธ Powered by Nillion nilAI using ${modelId} in TEE (Trusted Execution Environment)`; case 'ollama': - return `๐Ÿ–ฅ๏ธ Using local Ollama (${config.model}) at ${config.ollamaAddress || 'localhost'}:${config.ollamaPort || 11434}`; + return `๐Ÿ–ฅ๏ธ Using local Ollama (${modelId}) at ${config.ollamaAddress || 'localhost'}:${config.ollamaPort || 11434}`; case 'huggingface': - return `โ˜๏ธ Using HuggingFace Router (${config.model})`; + return `โ˜๏ธ Using HuggingFace Router (${modelId})`; default: return 'LLM analysis'; } diff --git a/lib/llm-config.ts b/lib/llm-config.ts index e0eb060..f20b8fe 100644 --- a/lib/llm-config.ts +++ b/lib/llm-config.ts @@ -10,6 +10,7 @@ export type LLMProvider = 'nilai' | 'ollama' | 'huggingface'; export interface LLMConfig { provider: LLMProvider; model: string; + customModel?: string; ollamaAddress?: string; ollamaPort?: number; huggingfaceApiKey?: string; @@ -80,13 +81,24 @@ export function getProviderDisplayName(provider: LLMProvider): string { * Get the full model identifier for API calls */ export function getModelIdentifier(config: LLMConfig): string { + // If custom model is selected, use the custom model name + if (config.model === 'custom' && config.customModel) { + return config.customModel; + } + switch (config.provider) { case 'nilai': - return 'openai/gpt-oss-20b'; + return config.model === 'gpt-oss-20b' ? 'openai/gpt-oss-20b' : config.model; case 'ollama': return config.model; case 'huggingface': - return 'openai/gpt-oss-20b:together'; + // For HuggingFace, append :together suffix if not already present + if (config.model === 'gpt-oss-20b') { + return 'openai/gpt-oss-20b:together'; + } else if (config.model === 'openai/gpt-oss-120b') { + return 'openai/gpt-oss-120b:together'; + } + return config.model.includes(':') ? config.model : `${config.model}:together`; } } @@ -137,6 +149,10 @@ export function isConfigValid(config: LLMConfig): { valid: boolean; error?: stri return { valid: false, error: 'HuggingFace API key is required' }; } + if (config.model === 'custom' && !config.customModel?.trim()) { + return { valid: false, error: 'Custom model name is required when "Custom..." is selected' }; + } + // Ollama address and port have defaults ('localhost' and 11434) so always valid // No additional validation needed From 43e171da443ccd4922154793415134cd309dc356 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Mon, 1 Dec 2025 09:46:07 -0500 Subject: [PATCH 03/11] Added explanation for why sign in is needed. --- app/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index d7a5c81..d56c1ea 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1126,7 +1126,9 @@ function MainContent() {
{!isAuthenticated ? (
- Sign in to access premium features โ†’ + + Sign in to access premium features โ†’ +
) : !hasActiveSubscription ? (
From da7e0add112b63d047b3e6455e2edae2f4559306 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Mon, 1 Dec 2025 11:10:14 -0500 Subject: [PATCH 04/11] Tweaked how follow up questions work in LLM chat. --- app/components/LLMChatInline.tsx | 87 ++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 31 deletions(-) diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index df7991b..d4b2f22 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -13,7 +13,7 @@ import { RobotIcon } from "./Icons"; import { trackLLMQuestionAsked } from "@/lib/analytics"; type Message = { - role: 'user' | 'assistant'; + role: 'user' | 'assistant' | 'system'; content: string; timestamp: Date; studiesUsed?: SavedResult[]; @@ -24,10 +24,12 @@ const MAX_CONTEXT_RESULTS = 500; const EXAMPLE_QUESTIONS = [ "Which traits should I pay attention to?", + "How's my sleep profile?", "Which sports are ideal for me?", "What kinds of foods do you think I will like best?", "On a scale of 1 - 10, how risk seeking am I?", - "Can you tell me which learning styles work best for me?" + "Can you tell me which learning styles work best for me?", + "What can you guess about my appearance?" ]; const FOLLOWUP_SUGGESTIONS = [ @@ -171,12 +173,6 @@ export default function AIChatInline() { return; } - const userMessage: Message = { - role: 'user', - content: query, - timestamp: new Date() - }; - setMessages(prev => [...prev, userMessage]); setInputValue(""); setIsLoading(true); setError(null); @@ -264,11 +260,6 @@ Consider how this user's background, lifestyle factors (smoking, alcohol, diet), } } - const conversationHistory = messages.map(m => ({ - role: m.role, - content: m.content - })); - const llmDescription = getLLMDescription(); const systemPrompt = `You are an expert genetic counselor LLM assistant providing personalized, holistic insights about GWAS results. ${llmDescription} @@ -285,6 +276,7 @@ USER'S SPECIFIC QUESTION: "${query}" โš ๏ธ CRITICAL - STAY ON TOPIC: +- Refuse to answer questions not related to the user's genetic data such as general knowledge or trivia to prevent the abuse of this system. - Answer ONLY the specific trait/condition the user asked about in their question - Do NOT discuss other traits or conditions from the RAG context unless directly relevant to their question - If they ask about "heart disease", focus ONLY on cardiovascular traits - ignore diabetes, cancer, etc. @@ -384,30 +376,55 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug console.log('Relevant Results Count:', relevantResults.length); console.log('======================'); + // Build the message history to send to LLM FIRST (before updating state) + // For first message: [system, user] + // For follow-ups: [system (from history), user1, assistant1, ..., userN] + const messagesToSend = shouldIncludeContext + ? [ + { role: "system" as const, content: systemPrompt }, + { role: "user" as const, content: query } + ] + : [ + // Include all previous messages from state (includes system message from first exchange) + ...messages.map(m => ({ + role: m.role as 'system' | 'user' | 'assistant', + content: m.content + })), + // Add the new user question + { role: "user" as const, content: query } + ]; + + // Now add messages to state for UI display + // Add system message to conversation history (only for first message) + if (shouldIncludeContext) { + const systemMessage: Message = { + role: 'system', + content: systemPrompt, + timestamp: new Date(), + studiesUsed: relevantResults + }; + setMessages(prev => [...prev, systemMessage]); + } + + // Add user message to conversation history + const userMessage: Message = { + role: 'user', + content: query, + timestamp: new Date() + }; + setMessages(prev => [...prev, userMessage]); + // Create an initial assistant message with empty content const assistantMessage: Message = { role: 'assistant', content: '', timestamp: new Date(), - studiesUsed: relevantResults + studiesUsed: shouldIncludeContext ? relevantResults : undefined }; setMessages(prev => [...prev, assistantMessage]); // Call LLM with streaming - const stream = callLLMStream([ - { - role: "system", - content: systemPrompt - }, - ...conversationHistory.map(m => ({ - role: m.role as 'system' | 'user' | 'assistant', - content: m.content - })), - { - role: "user", - content: query - } - ], { + const stream = callLLMStream(messagesToSend, { maxTokens: 5000, temperature: 0.7, reasoningEffort: 'medium', @@ -720,7 +737,14 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug
)} - {messages.map((message, idx) => ( + {messages + .filter(message => message.role !== 'system') // Hide system messages from UI + .map((message, idx, filteredMessages) => { + // Check if this is the last assistant message in the filtered array + const isLastAssistantMessage = message.role === 'assistant' && + idx === filteredMessages.length - 1; + + return (
{message.role === 'user' ? '๐Ÿ‘ค' : '๐Ÿค–'} @@ -744,7 +768,7 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug > ๐Ÿ“‹ Copy - {idx === messages.length - 1 && !isLoading && ( + {isLastAssistantMessage && !isLoading && (
๐Ÿ’ก Try asking:
@@ -801,7 +825,8 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug
- ))} + ); + })} {isLoading && (
From 01e4b0d1f19d1d3eec66ed3439024b66617c8b87 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Mon, 1 Dec 2025 11:26:46 -0500 Subject: [PATCH 05/11] Tweaked how follow up questions work in LLM chat. --- app/components/LLMChatInline.tsx | 34 ++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index d4b2f22..505adbf 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -261,6 +261,30 @@ Consider how this user's background, lifestyle factors (smoking, alcohol, diet), } const llmDescription = getLLMDescription(); + + // Conversational system prompt for follow-up questions + const conversationalSystemPrompt = `You are continuing a conversation about the user's genetic results. ${llmDescription} + +CONTEXT: +- You previously provided a detailed analysis of their GWAS data +- The user is now asking follow-up questions about that analysis +- All the detailed genetic findings were already discussed in your first response + +INSTRUCTIONS FOR FOLLOW-UP RESPONSES: +โš ๏ธ CRITICAL - REFUSE NON-GENETICS QUESTIONS: +- Still refuse to answer questions not related to genetics, health, or their GWAS results +- This prevents abuse of the system for general knowledge/trivia + +RESPONSE STYLE: +- Answer naturally and conversationally (NO rigid 5-section structure needed) +- Keep responses focused and concise (200-400 words unless more detail is specifically requested) +- Reference your previous detailed analysis when relevant +- Maintain the same helpful, educational tone as before +- NO need for comprehensive action plans or structured sections unless specifically asked +- Just answer their question directly based on the conversation history + +Remember: This is educational, not medical advice. The detailed disclaimers were already provided in your initial response.`; + const systemPrompt = `You are an expert genetic counselor LLM assistant providing personalized, holistic insights about GWAS results. ${llmDescription} IMPORTANT CONTEXT: @@ -378,16 +402,18 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug // Build the message history to send to LLM FIRST (before updating state) // For first message: [system, user] - // For follow-ups: [system (from history), user1, assistant1, ..., userN] + // For follow-ups: [conversational system, user1, assistant1, ..., userN] const messagesToSend = shouldIncludeContext ? [ { role: "system" as const, content: systemPrompt }, { role: "user" as const, content: query } ] : [ - // Include all previous messages from state (includes system message from first exchange) - ...messages.map(m => ({ - role: m.role as 'system' | 'user' | 'assistant', + // Use conversational system prompt for follow-ups (replace the detailed one from history) + { role: "system" as const, content: conversationalSystemPrompt }, + // Include all user/assistant messages from history (filter out old system message) + ...messages.filter(m => m.role !== 'system').map(m => ({ + role: m.role as 'user' | 'assistant', content: m.content })), // Add the new user question From 1fcd31101c2fdfd4c270c8a6bdaf70ad1bb276dd Mon Sep 17 00:00:00 2001 From: Vishakh Date: Mon, 1 Dec 2025 16:22:07 -0500 Subject: [PATCH 06/11] Tweaked how follow up questions work in LLM chat. --- app/components/LLMChatInline.tsx | 41 +++++++++++++------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index 505adbf..c02bcbf 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -271,9 +271,10 @@ CONTEXT: - All the detailed genetic findings were already discussed in your first response INSTRUCTIONS FOR FOLLOW-UP RESPONSES: -โš ๏ธ CRITICAL - REFUSE NON-GENETICS QUESTIONS: -- Still refuse to answer questions not related to genetics, health, or their GWAS results -- This prevents abuse of the system for general knowledge/trivia +โš ๏ธ CRITICAL - REFUSE QUESTIONS NOT RELATED TO USER DATA: +- Still refuse to answer questions not related to the user's results +- Just answer their question directly based on the conversation history +- CRITICAL: Remind the user any recommendations are based on LLM training data and may be subject to hallucinations and errors so they should conduct a physician if they have real health concerns. RESPONSE STYLE: - Answer naturally and conversationally (NO rigid 5-section structure needed) @@ -281,11 +282,10 @@ RESPONSE STYLE: - Reference your previous detailed analysis when relevant - Maintain the same helpful, educational tone as before - NO need for comprehensive action plans or structured sections unless specifically asked -- Just answer their question directly based on the conversation history Remember: This is educational, not medical advice. The detailed disclaimers were already provided in your initial response.`; - const systemPrompt = `You are an expert genetic counselor LLM assistant providing personalized, holistic insights about GWAS results. ${llmDescription} + const systemPrompt = `You are an expert providing personalized, holistic insights about GWAS results. ${llmDescription} IMPORTANT CONTEXT: - The user has uploaded their DNA file and analyzed it against thousands of GWAS studies @@ -303,8 +303,6 @@ USER'S SPECIFIC QUESTION: - Refuse to answer questions not related to the user's genetic data such as general knowledge or trivia to prevent the abuse of this system. - Answer ONLY the specific trait/condition the user asked about in their question - Do NOT discuss other traits or conditions from the RAG context unless directly relevant to their question -- If they ask about "heart disease", focus ONLY on cardiovascular traits - ignore diabetes, cancer, etc. -- If they ask about "diabetes", focus ONLY on metabolic/diabetes traits - ignore heart, cancer, etc. - If this is a follow-up question, continue the conversation about the SAME topic from previous messages - Do NOT use the RAG context to go off on tangents about unrelated health topics - The RAG context is provided for reference, but answer ONLY what the user specifically asked about @@ -316,24 +314,11 @@ CRITICAL INSTRUCTIONS - COMPLETE RESPONSES: 4. If running low on space, wrap up your current section properly and provide a brief conclusion 5. Every response MUST have a clear ending with actionable takeaways -HOW TO PRESENT FINDINGS - AVOID STUDY-BY-STUDY LISTS: -โŒ DO NOT create tables listing individual SNPs/studies one by one -โŒ DO NOT list rs numbers with individual interpretations -โŒ DO NOT organize findings by individual genetic variants -โŒ DO NOT restate the user's personal information (age, ethnicity, medical history, smoking, alcohol, diet, etc.) - they already know it - -โœ… INSTEAD, synthesize findings into THEMES and PATTERNS: -- Group related variants into biological themes (e.g., "Cardiovascular Protection", "Metabolic Risk", "Inflammatory Response") -- Describe the OVERALL pattern across multiple variants (e.g., "You have 8 protective variants and 3 risk variants for heart disease, suggesting...") -- Focus on the BIG PICTURE and what the collection of findings means together -- Mention specific genes/pathways only when illustrating a broader point - PERSONALIZED HOLISTIC ADVICE FRAMEWORK: 1. Synthesize ALL findings into a coherent story about their health landscape 2. Explain how their genetic profile interacts with their background factors (without restating what those factors are) 3. Identify both strengths (protective factors) and areas to monitor (risk factors) -4. Connect different body systems (e.g., how cardiovascular + metabolic + inflammatory factors relate) -5. Provide specific, actionable recommendations tailored to THEIR situation +4. Provide specific, actionable recommendations tailored to THEIR situation โš ๏ธ CRITICAL GWAS LIMITATIONS & MEDICAL RECOMMENDATIONS: @@ -344,9 +329,9 @@ UNDERSTANDING GWAS LIMITATIONS: - Environment, lifestyle, and chance play MUCH LARGER roles than genetics - This app is for EDUCATIONAL PURPOSES ONLY - not clinical diagnosis - Results should NEVER be used to make medical decisions without professional consultation +- Any health recommendations are based on LLM training data and may be subject to hallucinations and errors so they should conduct a physician if they have real health concerns. MEDICAL REFERRAL THRESHOLD - EXTREMELY HIGH BAR: -- Focus 95% of recommendations on lifestyle, diet, exercise, sleep, stress management, and self-monitoring - ONLY suggest medical consultation if MULTIPLE high-risk variants + family history + existing symptoms align - NEVER routinely say "consult a genetic counselor" or "see your doctor" or "get tested" - Do NOT recommend medical tests, lab work, or screening unless findings are TRULY exceptional (e.g., multiple high-risk variants for serious hereditary conditions) @@ -757,9 +742,15 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug ))} -

- โš ๏ธ This is for educational purposes only. Always consult healthcare professionals for medical advice. -

+
+

โš ๏ธ Important Disclaimer:

+
    +
  • LLMs can report incorrect information based on their training data
  • +
  • LLMs can hallucinate and make up information that sounds plausible but is false
  • +
  • LLMs can sound authoritative and confident even though they are not medical experts
  • +
  • This is for educational purposes only. Always consult healthcare professionals for medical advice.
  • +
+
)} From cc059a89899ec90a9d4ebd145930087388d87571 Mon Sep 17 00:00:00 2001 From: Vishakh Date: Tue, 2 Dec 2025 10:19:35 -0500 Subject: [PATCH 07/11] Added file attachment feature to LLM Chat --- app/components/LLMChatInline.tsx | 289 ++++++++++++++++++++++++- app/components/OverviewReportModal.tsx | 2 +- app/globals.css | 172 +++++++++++++++ package-lock.json | 41 ---- 4 files changed, 451 insertions(+), 53 deletions(-) diff --git a/app/components/LLMChatInline.tsx b/app/components/LLMChatInline.tsx index c02bcbf..65ff6b3 100644 --- a/app/components/LLMChatInline.tsx +++ b/app/components/LLMChatInline.tsx @@ -12,15 +12,28 @@ import { callLLM, callLLMStream, getLLMDescription } from "@/lib/llm-client"; import { RobotIcon } from "./Icons"; import { trackLLMQuestionAsked } from "@/lib/analytics"; +type AttachmentType = 'text' | 'pdf' | 'csv' | 'tsv'; + +type Attachment = { + name: string; + content: string; + type: AttachmentType; + size: number; // in bytes +}; + type Message = { role: 'user' | 'assistant' | 'system'; content: string; timestamp: Date; studiesUsed?: SavedResult[]; + attachments?: Attachment[]; }; const CONSENT_STORAGE_KEY = "nilai_llm_chat_consent_accepted"; const MAX_CONTEXT_RESULTS = 500; +const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB in bytes +const ALLOWED_FILE_TYPES = ['.txt', '.pdf', '.csv', '.tsv']; +const MAX_ATTACHMENTS = 5; const EXAMPLE_QUESTIONS = [ "Which traits should I pay attention to?", @@ -57,8 +70,12 @@ export default function AIChatInline() { const [hasPromoAccess, setHasPromoAccess] = useState(false); const [showPersonalizationPrompt, setShowPersonalizationPrompt] = useState(false); const [expandedMessageIndex, setExpandedMessageIndex] = useState(null); + const [attachedFiles, setAttachedFiles] = useState([]); + const [attachmentError, setAttachmentError] = useState(null); + const [expandedAttachmentIndex, setExpandedAttachmentIndex] = useState(null); const inputRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { setMounted(true); @@ -144,6 +161,156 @@ export default function AIChatInline() { return `${score.toFixed(2)}x`; }; + const handleAttachmentClick = () => { + setAttachmentError(null); + fileInputRef.current?.click(); + }; + + const validateFile = (file: File): string | null => { + // Check file size + if (file.size > MAX_FILE_SIZE) { + return `File "${file.name}" is too large. Maximum size is 1MB.`; + } + + // Check file type + const extension = '.' + file.name.split('.').pop()?.toLowerCase(); + if (!ALLOWED_FILE_TYPES.includes(extension)) { + return `File "${file.name}" has an unsupported format. Allowed: ${ALLOWED_FILE_TYPES.join(', ')}`; + } + + return null; + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + setAttachmentError(null); + + // Check total number of attachments + if (attachedFiles.length + files.length > MAX_ATTACHMENTS) { + setAttachmentError(`Maximum ${MAX_ATTACHMENTS} files can be attached at once.`); + return; + } + + const newFiles: File[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const error = validateFile(file); + if (error) { + setAttachmentError(error); + return; + } + newFiles.push(file); + } + + setAttachedFiles(prev => [...prev, ...newFiles]); + // Reset input so same file can be selected again + e.target.value = ''; + }; + + const handleRemoveAttachment = (index: number) => { + setAttachedFiles(prev => prev.filter((_, i) => i !== index)); + setAttachmentError(null); + }; + + const processAttachments = async (files: File[]): Promise => { + const attachments: Attachment[] = []; + + for (const file of files) { + const extension = file.name.split('.').pop()?.toLowerCase(); + + try { + if (extension === 'pdf') { + // For PDF, we'll need to use pdfjs-dist + const content = await extractTextFromPDF(file); + attachments.push({ + name: file.name, + content, + type: 'pdf', + size: file.size + }); + } else { + // For text, csv, tsv - read as text + const content = await file.text(); + attachments.push({ + name: file.name, + content, + type: extension as AttachmentType, + size: file.size + }); + } + } catch (err) { + console.error(`Failed to process file ${file.name}:`, err); + throw new Error(`Failed to process file "${file.name}"`); + } + } + + return attachments; + }; + + const extractTextFromPDF = async (file: File): Promise => { + try { + // Load PDF.js from CDN dynamically to avoid webpack issues + if (typeof window !== 'undefined' && !(window as any).pdfjsLib) { + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('Failed to load PDF.js')); + document.head.appendChild(script); + }); + } + + const pdfjsLib = (window as any).pdfjsLib; + if (!pdfjsLib) { + throw new Error('PDF.js library not loaded'); + } + + // Set worker + pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; + + // Read file as ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // Load PDF document + const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer }); + const pdf = await loadingTask.promise; + + let fullText = ''; + + // Extract text from each page + for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { + const page = await pdf.getPage(pageNum); + const textContent = await page.getTextContent(); + const pageText = textContent.items + .map((item: any) => item.str) + .join(' '); + fullText += pageText + '\n\n'; + } + + return fullText.trim() || 'PDF file contains no extractable text'; + } catch (error) { + console.error('Failed to extract text from PDF:', error); + throw new Error(`Failed to extract text from PDF file: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + const formatAttachmentsForMessage = (attachments: Attachment[], query: string): string => { + let message = `User question:\n${query}`; + + if (attachments.length > 0) { + message += '\n\n---\n'; + for (const attachment of attachments) { + const sizeKB = (attachment.size / 1024).toFixed(1); + message += `\nAttached file: ${attachment.name} (${attachment.type.toUpperCase()}, ${sizeKB}KB):\n${attachment.content}\n`; + } + message += '---'; + } + + return message; + }; + const handleSendMessage = async () => { const query = inputValue.trim(); if (!query) return; @@ -176,10 +343,26 @@ export default function AIChatInline() { setInputValue(""); setIsLoading(true); setError(null); + setAttachmentError(null); // Track LLM question trackLLMQuestionAsked(); + // Process attachments if any + let processedAttachments: Attachment[] = []; + if (attachedFiles.length > 0) { + try { + setLoadingStatus("๐Ÿ“Ž Processing attachments..."); + processedAttachments = await processAttachments(attachedFiles); + console.log(`[LLM Chat] Processed ${processedAttachments.length} attachments`); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to process attachments"; + setError(errorMessage); + setIsLoading(false); + return; + } + } + try { let relevantResults: SavedResult[] = []; @@ -383,15 +566,21 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug console.log('System Prompt:', systemPrompt); console.log('User Query:', query); console.log('Relevant Results Count:', relevantResults.length); + console.log('Attachments Count:', processedAttachments.length); console.log('======================'); + // Format the user query with attachments for LLM + const userQueryWithAttachments = processedAttachments.length > 0 + ? formatAttachmentsForMessage(processedAttachments, query) + : query; + // Build the message history to send to LLM FIRST (before updating state) // For first message: [system, user] // For follow-ups: [conversational system, user1, assistant1, ..., userN] const messagesToSend = shouldIncludeContext ? [ { role: "system" as const, content: systemPrompt }, - { role: "user" as const, content: query } + { role: "user" as const, content: userQueryWithAttachments } ] : [ // Use conversational system prompt for follow-ups (replace the detailed one from history) @@ -402,7 +591,7 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug content: m.content })), // Add the new user question - { role: "user" as const, content: query } + { role: "user" as const, content: userQueryWithAttachments } ]; // Now add messages to state for UI display @@ -421,7 +610,8 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug const userMessage: Message = { role: 'user', content: query, - timestamp: new Date() + timestamp: new Date(), + attachments: processedAttachments.length > 0 ? processedAttachments : undefined }; setMessages(prev => [...prev, userMessage]); @@ -469,6 +659,9 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug throw new Error("No response generated from LLM"); } + // Clear attachments after successful send + setAttachedFiles([]); + } catch (err) { console.error('[LLM Chat] Error:', err); @@ -837,6 +1030,33 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug )}
)} + {message.role === 'user' && message.attachments && message.attachments.length > 0 && ( +
+ + {expandedAttachmentIndex === idx && ( +
+ {message.attachments.map((attachment, attIdx) => ( +
+
+ ๐Ÿ“„ {attachment.name} + + {attachment.type.toUpperCase()} โ€ข {(attachment.size / 1024).toFixed(1)}KB + +
+
+
{attachment.content.substring(0, 500)}{attachment.content.length > 500 ? '...' : ''}
+
+
+ ))} +
+ )} +
+ )}
{message.timestamp.toLocaleTimeString()}
@@ -867,6 +1087,43 @@ Remember: You have plenty of space. Use ALL of it to provide a complete, thoroug
+ {/* Hidden file input */} + + + {/* Attachment preview chips */} + {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((file, idx) => ( +
+ ๐Ÿ“Ž + {file.name} + ({(file.size / 1024).toFixed(1)}KB) + +
+ ))} +
+ )} + + {/* Attachment error display */} + {attachmentError && ( +
+ โš ๏ธ {attachmentError} +
+ )} +