| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Carver Excel Analyst</title> |
| |
| <link rel="preconnect" href="https://fonts.googleapis.com"> |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"> |
| |
| <script type="module" src="https://esm.run/@google/generative-ai"></script> |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> |
| |
| <style> |
| |
| :root { |
| --font-main: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
| --color-bg: #f8f9fa; |
| --color-card: #ffffff; |
| --color-border: #e9ecef; |
| --color-text: #212529; |
| --color-text-light: #6c757d; |
| --color-accent: #0d6efd; |
| --color-user-msg: #0d6efd; |
| --color-user-text: #ffffff; |
| --color-assist-msg: #f1f3f5; |
| --color-assist-text: #212529; |
| --color-danger: #d90429; |
| --color-danger-bg: #fff0f0; |
| --color-success: #28a745; |
| --color-success-bg: #f0fff4; |
| } |
| |
| * { |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| } |
| |
| body { |
| font-family: var(--font-main); |
| background-color: var(--color-bg); |
| color: var(--color-text); |
| display: flex; |
| justify-content: center; |
| align-items: flex-start; |
| min-height: 100vh; |
| padding: 20px; |
| } |
| |
| |
| .container { |
| width: 100%; |
| max-width: 1500 px; |
| background-color: var(--color-card); |
| border-radius: 12px; |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.05); |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| height: calc(100vh - 40px); |
| border: 1px solid var(--color-border); |
| } |
| |
| |
| header { |
| padding: 20px 24px; |
| border-bottom: 1px solid var(--color-border); |
| background-color: var(--color-card); |
| } |
| |
| h1 { |
| margin: 0; |
| color: var(--color-text); |
| font-size: 1.4rem; |
| font-weight: 600; |
| } |
| |
| p.caption { |
| margin: 4px 0 0; |
| font-size: 0.9rem; |
| color: var(--color-text-light); |
| } |
| |
| |
| .config-bar { |
| padding: 16px 24px; |
| background-color: #fdfdfe; |
| border-bottom: 1px solid var(--color-border); |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| |
| .api-key-group { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .api-key-group label { |
| font-weight: 500; |
| font-size: 0.9rem; |
| } |
| |
| .api-key-group input { |
| flex-grow: 1; |
| padding: 8px 12px; |
| border: 1px solid var(--color-border); |
| border-radius: 6px; |
| font-family: var(--font-main); |
| font-size: 0.9rem; |
| } |
| |
| .api-key-warning { |
| font-size: 0.8rem; |
| color: #e74c3c; |
| font-weight: 500; |
| margin-top: 8px; |
| } |
| |
| |
| .header-actions { |
| display: flex; |
| gap: 8px; |
| } |
| |
| .action-button { |
| padding: 6px 12px; |
| border: 1px solid var(--color-border); |
| background-color: var(--color-card); |
| border-radius: 6px; |
| font-size: 0.85rem; |
| cursor: pointer; |
| font-family: var(--font-main); |
| transition: all 0.2s; |
| } |
| |
| .action-button:hover { |
| background-color: var(--color-assist-msg); |
| border-color: var(--color-accent); |
| } |
| |
| .action-button.danger { |
| color: var(--color-danger); |
| border-color: var(--color-danger); |
| } |
| |
| .action-button.danger:hover { |
| background-color: var(--color-danger-bg); |
| } |
| |
| |
| #chat-box { |
| flex-grow: 1; |
| padding: 24px; |
| overflow-y: auto; |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| |
| background-image: radial-gradient(var(--color-border) 1px, transparent 1px); |
| background-size: 10px 10px; |
| background-color: #ffffff; |
| } |
| |
| |
| #chat-box::-webkit-scrollbar { |
| width: 6px; |
| } |
| #chat-box::-webkit-scrollbar-track { |
| background: transparent; |
| } |
| #chat-box::-webkit-scrollbar-thumb { |
| background: var(--color-border); |
| border-radius: 3px; |
| } |
| #chat-box::-webkit-scrollbar-thumb:hover { |
| background: #d0d5db; |
| } |
| |
| .message { |
| padding: 14px 20px; |
| border-radius: 18px; |
| max-width: 85%; |
| line-height: 1.6; |
| word-wrap: break-word; |
| font-size: 0.95rem; |
| } |
| |
| .message.user { |
| background-color: var(--color-user-msg); |
| color: var(--color-user-text); |
| border-bottom-right-radius: 6px; |
| align-self: flex-end; |
| } |
| |
| .message.assistant { |
| background-color: var(--color-assist-msg); |
| color: var(--color-assist-text); |
| border-bottom-left-radius: 6px; |
| align-self: flex-start; |
| } |
| |
| .message.error { |
| background-color: var(--color-danger-bg); |
| color: var(--color-danger); |
| border: 1px solid var(--color-danger); |
| } |
| |
| .message.success { |
| background-color: var(--color-success-bg); |
| color: var(--color-success); |
| border: 1px solid var(--color-success); |
| } |
| |
| |
| .message.assistant strong { font-weight: 600; } |
| .message.assistant h1, .message.assistant h2, .message.assistant h3 { |
| margin-top: 1.2em; |
| margin-bottom: 0.6em; |
| border-bottom: 1px solid var(--color-border); |
| padding-bottom: 5px; |
| font-weight: 600; |
| } |
| .message.assistant code { |
| font-family: monospace; |
| background-color: #dfe7ed; |
| padding: 2px 6px; |
| border-radius: 4px; |
| font-size: 0.9em; |
| } |
| .message.assistant pre { |
| background-color: #2c3e50; |
| color: #f4f7f6; |
| padding: 16px; |
| border-radius: 8px; |
| overflow-x: auto; |
| margin: 1em 0; |
| } |
| .message.assistant pre code { |
| background-color: transparent; |
| padding: 0; |
| font-size: 0.85rem; |
| } |
| .message.assistant table { |
| border-collapse: collapse; |
| width: 100%; |
| margin: 1em 0; |
| font-size: 0.9rem; |
| } |
| .message.assistant th, .message.assistant td { |
| border: 1px solid var(--color-border); |
| padding: 10px; |
| text-align: left; |
| } |
| .message.assistant th { |
| background-color: var(--color-assist-msg); |
| font-weight: 600; |
| } |
| |
| |
| .input-area { |
| border-top: 1px solid var(--color-border); |
| padding: 16px 24px; |
| background-color: var(--color-card); |
| } |
| |
| #file-list { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| margin-bottom: 12px; |
| align-items: center; |
| } |
| |
| .file-pill { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| background-color: var(--color-assist-msg); |
| color: var(--color-text); |
| padding: 5px 12px; |
| border-radius: 15px; |
| font-size: 0.8rem; |
| font-weight: 500; |
| } |
| |
| .file-remove { |
| background: none; |
| border: none; |
| color: var(--color-danger); |
| cursor: pointer; |
| font-size: 0.7rem; |
| padding: 0; |
| width: 16px; |
| height: 16px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| |
| .file-remove:hover { |
| background-color: var(--color-danger); |
| color: white; |
| } |
| |
| .prompt-input-group { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| border: 1px solid var(--color-border); |
| border-radius: 25px; |
| padding: 4px 4px 4px 12px; |
| } |
| .prompt-input-group:focus-within { |
| border-color: var(--color-accent); |
| box-shadow: 0 0 0 3px rgba(13,110,253,0.1); |
| } |
| |
| #prompt-input { |
| flex-grow: 1; |
| border: none; |
| outline: none; |
| background: transparent; |
| font-family: var(--font-main); |
| font-size: 1rem; |
| padding: 8px; |
| } |
| |
| #file-input { |
| display: none; |
| } |
| |
| .icon-button { |
| background-color: transparent; |
| border: none; |
| cursor: pointer; |
| padding: 8px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| transition: background-color 0.2s; |
| } |
| .icon-button svg { |
| width: 20px; |
| height: 20px; |
| fill: var(--color-text-light); |
| } |
| .icon-button:hover { |
| background-color: var(--color-assist-msg); |
| } |
| |
| #send-button { |
| background-color: var(--color-accent); |
| } |
| #send-button svg { |
| fill: var(--color-user-text); |
| } |
| #send-button:hover { |
| background-color: #0b5ed7; |
| } |
| #send-button:disabled { |
| background-color: #a0c7e4; |
| cursor: not-allowed; |
| } |
| |
| |
| #status-text { |
| display: block; |
| text-align: center; |
| font-size: 0.85rem; |
| color: var(--color-text-light); |
| margin-bottom: 12px; |
| height: 1.2em; |
| } |
| |
| .spinner { |
| display: inline-block; |
| width: 1em; |
| height: 1em; |
| border: 2px solid currentColor; |
| border-right-color: transparent; |
| border-radius: 50%; |
| animation: spin 0.6s linear infinite; |
| margin-bottom: -3px; |
| margin-right: 5px; |
| } |
| @keyframes spin { |
| to { transform: rotate(360deg); } |
| } |
| |
| |
| .debug-container { |
| padding: 12px 24px 4px; |
| border-top: 1px solid var(--color-border); |
| background-color: #f8f9fa; |
| } |
| |
| .debug-container summary { |
| font-size: 0.8rem; |
| color: var(--color-text-light); |
| cursor: pointer; |
| font-weight: 500; |
| padding: 8px 0; |
| list-style: none; |
| } |
| |
| .debug-container summary::-webkit-details-marker { |
| display: none; |
| } |
| |
| .debug-container summary::before { |
| content: 'βΆ'; |
| display: inline-block; |
| margin-right: 8px; |
| transition: transform 0.2s; |
| } |
| |
| .debug-container[open] summary::before { |
| transform: rotate(90deg); |
| } |
| |
| #debug-box { |
| display: none; |
| font-size: 0.8rem; |
| background-color: var(--color-card); |
| border: 1px solid var(--color-border); |
| padding: 12px; |
| margin: 8px 0 0 0; |
| border-radius: 8px; |
| font-family: monospace; |
| line-height: 1.4; |
| max-height: 200px; |
| overflow-y: auto; |
| white-space: pre-wrap; |
| word-wrap: break-word; |
| } |
| |
| |
| .debug-container[open] #debug-box, |
| .debug-box-visible { |
| display: block; |
| } |
| |
| |
| .debug-container summary { |
| display: none; |
| } |
| |
| |
| .conversation-info { |
| text-align: center; |
| padding: 8px 16px; |
| font-size: 0.85rem; |
| color: var(--color-text-light); |
| background-color: var(--color-success-bg); |
| border-bottom: 1px solid var(--color-border); |
| } |
| |
| </style> |
| </head> |
| <body> |
|
|
| <div class="container"> |
| <header> |
| <h1>π§ Carver Excel Analyst</h1> |
| <p class="caption">This AI uses a 2-step process to refine your query and provide an expert analysis. <b>Upload files once, ask multiple questions!</b></p> |
| </header> |
|
|
| <div class="config-bar"> |
| <div class="api-key-group"> |
| <label for="api-key-input">API Key:</label> |
| <input type="password" id="api-key-input" placeholder="Enter your Google API Key"> |
| </div> |
| <div class="header-actions"> |
| <button class="action-button success" id="export-pdf-btn" title="Export conversation to PDF"> |
| π Export PDF |
| </button> |
| <button class="action-button" id="toggle-details-btn" title="Show/Hide analysis details"> |
| ποΈ Show Analysis Details |
| </button> |
| <button class="action-button" id="new-conversation-btn" title="Start a new conversation"> |
| π New Conversation |
| </button> |
| </div> |
| </div> |
|
|
| <div class="conversation-info" id="conversation-info" style="display: none;"> |
| πΎ Files remain available for this conversation. Ask follow-up questions anytime! |
| </div> |
|
|
| <div id="chat-box"> |
| <div class="message assistant"> |
| Welcome to Carver Excel Analyst! This is a <b>conversational chatbot</b> that remembers your files and conversation. |
| <br><br> |
| <b>How it works:</b> |
| <br>β’ Upload your Excel, PDF, or image files once |
| <br>β’ Ask questions about the data |
| <br>β’ Files stay available for follow-up questions |
| <br>β’ Start a new conversation anytime to clear history |
| <br><br> |
| <b>Please provide your API key and upload your files to begin!</b> |
| </div> |
| </div> |
| |
| <div class="input-area"> |
| <div id="status-text"></div> |
| |
| <div id="file-list"> |
| </div> |
| |
| <div class="prompt-input-group"> |
| <input type="file" id="file-input" multiple> |
| <button class="icon-button" id="attach-button" title="Upload files"> |
| <svg viewBox="0 0 24 24"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v11.5c0 2.76 2.24 5 5 5s5-2.24 5-5V6h-1.5z"></path></svg> |
| </button> |
| |
| <input type="text" id="prompt-input" placeholder="Ask a question about your files..."> |
| |
| <button class="icon-button" id="send-button" title="Send message"> |
| <svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2 .01 7z"></path></svg> |
| </button> |
| </div> |
| |
| <div class="debug-container" id="debug-container"> |
| <div id="debug-box">No analysis details yet.</div> |
| </div> |
| </div> |
| </div> |
|
|
| <script type="module"> |
| // --- Import Generative AI SDK --- |
| const { GoogleGenerativeAI } = await import("https://esm.run/@google/generative-ai"); |
| |
| // --- DOM Element References --- |
| const apiKeyInput = document.getElementById("api-key-input"); |
| const fileInput = document.getElementById("file-input"); |
| const attachButton = document.getElementById("attach-button"); |
| const fileListDisplay = document.getElementById("file-list"); |
| const promptInput = document.getElementById("prompt-input"); |
| const sendButton = document.getElementById("send-button"); |
| const chatBox = document.getElementById("chat-box"); |
| const statusText = document.getElementById("status-text"); |
| const debugBox = document.getElementById("debug-box"); |
| const newConversationBtn = document.getElementById("new-conversation-btn"); |
| const conversationInfo = document.getElementById("conversation-info"); |
| const exportPdfBtn = document.getElementById("export-pdf-btn"); |
| const toggleDetailsBtn = document.getElementById("toggle-details-btn"); |
| |
| // --- Model Configuration --- |
| const GENERATOR_MODEL_NAME = "gemini-2.0-flash"; |
| const ANALYST_MODEL_NAME = "gemini-2.5-flash"; |
| |
| // --- Global State --- |
| let uploadedFiles = []; // Persistent file storage |
| let conversationHistory = []; // Store conversation messages |
| let conversationStartTime = Date.now(); |
| let analysisDetailsVisible = false; |
| |
| // --- The Main Carver Analyst System Prompt --- |
| const CARVER_ANALYST_SYSTEM_PROMPT = ` |
| You are **Carver Excel Analyst**, an internal AI specialist for Carver Procurement Consultancy. |
| Your role: Help Carver team members analyze Excel sheets (e.g., BOQs, comparative sheets, quotations, price lists, tender data) and produce clear summaries and structured tables. |
| NOTE: Excel files (.xlsx, .xls) have been pre-converted into structured CSV text, with each sheet as a separate text block, prefixed with [File: filename.xlsx | Sheet: SheetName]. Use this text data for your analysis. |
| ### π― Mission |
| Understand the provided data (CSV text, PDFs, images) and provide short, factual insights β **without making any assumptions**. |
| If any required input is missing, unclear, or conflicting, you **must first ask a short, specific clarifying question** before proceeding. Never guess, invent, or assume. |
| Your goal is to make Carver's data analysis simple, reliable, and audit-ready. |
| ### π§© Default Output (when all inputs are clear) |
| 1. **Executive Summary (3β6 lines)** β concise explanation answering the user's question. |
| 2. **Supporting Table (Markdown or CSV)** β key rows/columns only. |
| 3. **Audit Block (3β6 lines)** β list of: file(s) used (or file/sheet name for CSVs), cell/range references (or row/column names), missing or ignored fields, and formulas applied. |
| Do **not** create or export a new Excel file unless the user explicitly asks for it. |
| ### π§± Working Logic |
| **A. File Understanding** |
| - Detect columns and datatypes from the CSV text or tables in PDFs/images. Map columns like *Vendor*, *Item*, *Qty*, *Rate/Unit Price*, *Total*, *Lead Time*, *Remarks*, etc. |
| - Always confirm mappings if confidence <80%. |
| **B. Required Data Rules** |
| - For cost analysis β need \`Qty\` + \`UnitPrice\` or \`Total\`. |
| - For "vs budget" β need a \`Baseline\`, \`Budget\`, or explicit reference vendor. |
| - If missing, ask user directly. |
| **C. No-Assumptions Policy** |
| - If anything is missing or unclear: Ask before proceeding. |
| - Example: "No 'Budget' column found. Should I use the lowest vendor total as baseline?" |
| **D. Validations** |
| - Detect and report missing values, currency mismatches, etc., in the Audit Block. |
| **E. Computation Rules** |
| - \`Saving = Baseline β Selected\` |
| - \`Saving% = (Baseline β Selected) / Baseline Γ 100\` |
| **F. Output Formatting** |
| - Keep summary short. Use markdown tables. End every result with an **Audit Block**. |
| **H. Tone** |
| - Professional, precise, procurement-focused. |
| ### π§ Objective |
| Act like Carver's in-house **Excel procurement analyst** β structured, audit-traceable, and transparent. Never make assumptions. Always verify. |
| |
| IMPORTANT: This is a conversational chatbot. You have access to the conversation history and can refer back to previous analyses when answering follow-up questions. |
| `; |
| |
| // --- Event Listeners --- |
| sendButton.addEventListener("click", handleSend); |
| promptInput.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| handleSend(); |
| } |
| }); |
| |
| // Trigger hidden file input |
| attachButton.addEventListener("click", () => { |
| fileInput.click(); |
| }); |
| |
| // Handle new conversation |
| newConversationBtn.addEventListener("click", startNewConversation); |
| |
| // Handle PDF export |
| exportPdfBtn.addEventListener("click", exportToPDF); |
| |
| // Handle analysis details toggle |
| toggleDetailsBtn.addEventListener("click", toggleAnalysisDetails); |
| |
| // Update file pills when files are selected |
| fileInput.addEventListener("change", (e) => { |
| const newFiles = Array.from(e.target.files); |
| uploadedFiles = [...uploadedFiles, ...newFiles]; // Append, don't replace |
| updateFileDisplay(); |
| fileInput.value = ""; // Reset input |
| |
| if (uploadedFiles.length > 0) { |
| showSuccess(`π Added ${newFiles.length} file(s). Total files: ${uploadedFiles.length}`); |
| conversationInfo.style.display = "block"; |
| } |
| }); |
| |
| // --- Main Function: handleSend --- |
| async function handleSend() { |
| const apiKey = apiKeyInput.value.trim(); |
| const userPrompt = promptInput.value.trim(); |
| |
| // --- 1. Validations --- |
| if (!apiKey) { |
| displayError("Please enter your Google API Key."); |
| return; |
| } |
| if (!userPrompt) { |
| displayError("Please enter a prompt."); |
| return; |
| } |
| if (uploadedFiles.length === 0) { |
| displayError("Please upload at least one file first."); |
| return; |
| } |
| |
| // --- 2. Setup UI for Loading --- |
| setLoadingState(true, "Processing your question..."); |
| displayMessage(userPrompt, "user"); |
| debugBox.textContent = "Generating refined prompt..."; // Clear old debug info |
| |
| const assistantMessageEl = displayMessage("", "assistant"); // Create empty bubble |
| |
| try { |
| // --- 3. Process Files into Generative Parts --- |
| const partsArrays = await Promise.all( |
| uploadedFiles.map(fileToGenerativeParts) |
| ); |
| const fileParts = partsArrays.flat(); |
| |
| // --- 4. Build conversation context --- |
| let conversationContext = ""; |
| if (conversationHistory.length > 0) { |
| conversationContext = ` |
| Previous conversation context: |
| ${conversationHistory.slice(-6).map(msg => |
| `${msg.role.toUpperCase()}: ${msg.content.substring(0, 200)}${msg.content.length > 200 ? '...' : ''}` |
| ).join('\n')} |
| |
| `; |
| } |
| |
| // --- 5. Initialize AI Client --- |
| const genAI = new GoogleGenerativeAI(apiKey); |
| |
| // --- 6. STEP 1: Generate the "Better Prompt" (Normal Model) --- |
| setLoadingState(true, `Step 1/2: Thinking about your question... (${GENERATOR_MODEL_NAME})`); |
| |
| const generatorModel = genAI.getGenerativeModel({ model: GENERATOR_MODEL_NAME }); |
| |
| const step1SystemPrompt = ` |
| You are a prompt engineering assistant. Your job is to take a user's simple question and the attached file(s) and generate a new, detailed, and specific prompt. This new prompt will be given to a second AI, which is an expert 'Carver Excel Analyst'. |
| NOTE: The attached files may include images, PDFs, or TEXT parts that are CSV conversions of Excel sheets. The analyst is aware of this. |
| |
| The user's question is: "${userPrompt}" |
| |
| ${conversationContext}Based on the user's question and the file(s), generate a single, clear, and actionable instruction for the expert analyst. |
| - Be specific. |
| - Ask for the final output (summary, table, audit block) as defined by the analyst's role. |
| - Consider previous conversation context when generating the prompt. |
| - For example, if the user says "how much did I save?", you should generate a prompt like: |
| "Please analyze the attached data (CSV text, PDFs, images). Identify the baseline or budget, compare all vendor totals against it, and calculate the saving amount and percentage for the lowest-cost compliant vendor. Present this in an executive summary, a comparison table, and an audit block." |
| `; |
| |
| const step1Contents = [ |
| ...fileParts, |
| { text: step1SystemPrompt } |
| ]; |
| |
| const resultStep1 = await generatorModel.generateContent({ |
| contents: [{ parts: step1Contents }] |
| }); |
| const generatedPrompt = resultStep1.response.text(); |
| |
| // Display the refined prompt in the debug box |
| debugBox.textContent = generatedPrompt; |
| analysisDetailsVisible = true; |
| updateDetailsVisibility(); |
| |
| // --- 7. STEP 2: Get Final Answer (Reasoning Model) --- |
| setLoadingState(true, `Step 2/2: The Carver Analyst is working... (${ANALYST_MODEL_NAME})`); |
| |
| const analystModel = genAI.getGenerativeModel({ |
| model: ANALYST_MODEL_NAME, |
| systemInstruction: CARVER_ANALYST_SYSTEM_PROMPT |
| }); |
| |
| const step2Contents = [ |
| ...fileParts, |
| { text: generatedPrompt } |
| ]; |
| |
| const result = await analystModel.generateContentStream({ |
| contents: [{ parts: step2Contents }] |
| }); |
| |
| // --- 8. Stream Response to Chat --- |
| let fullResponse = ""; |
| for await (const chunk of result.stream) { |
| if (typeof chunk.text === 'function') { |
| const chunkText = chunk.text(); |
| fullResponse += chunkText; |
| assistantMessageEl.innerHTML = marked.parse(fullResponse); |
| scrollToBottom(); |
| } |
| } |
| |
| // --- 9. Save to conversation history --- |
| conversationHistory.push( |
| { role: "user", content: userPrompt }, |
| { role: "assistant", content: fullResponse } |
| ); |
| |
| // Keep only last 20 messages to prevent context overflow |
| if (conversationHistory.length > 20) { |
| conversationHistory = conversationHistory.slice(-20); |
| } |
| |
| } catch (error) { |
| console.error("Error:", error); |
| displayError(`An error occurred: ${error.message}`); |
| assistantMessageEl.remove(); // Remove the empty bubble |
| analysisDetailsVisible = false; |
| updateDetailsVisibility(); |
| } finally { |
| // --- 10. Cleanup UI --- |
| setLoadingState(false); |
| promptInput.value = ""; |
| } |
| } |
| |
| // --- Helper Functions --- |
| |
| /** |
| * Start a new conversation - clears history and files |
| */ |
| function startNewConversation() { |
| if (confirm("Start a new conversation? This will clear all uploaded files and conversation history.")) { |
| uploadedFiles = []; |
| conversationHistory = []; |
| conversationStartTime = Date.now(); |
| |
| // Clear UI |
| chatBox.innerHTML = ` |
| <div class="message assistant"> |
| π <b>New conversation started!</b> Upload your files and ask questions about them. |
| </div> |
| `; |
| |
| updateFileDisplay(); |
| conversationInfo.style.display = "none"; |
| promptInput.value = ""; |
| |
| showSuccess("Started new conversation!"); |
| } |
| } |
| |
| /** |
| * Update file display pills |
| */ |
| function updateFileDisplay() { |
| fileListDisplay.innerHTML = ""; |
| |
| if (uploadedFiles.length > 0) { |
| uploadedFiles.forEach((file, index) => { |
| const pill = document.createElement("span"); |
| pill.className = "file-pill"; |
| pill.innerHTML = ` |
| ${file.name} |
| <button class="file-remove" onclick="removeFile(${index})" title="Remove file">Γ</button> |
| `; |
| fileListDisplay.appendChild(pill); |
| }); |
| } |
| } |
| |
| /** |
| * Remove a specific file |
| */ |
| window.removeFile = function(index) { |
| if (confirm(`Remove "${uploadedFiles[index].name}"?`)) { |
| uploadedFiles.splice(index, 1); |
| updateFileDisplay(); |
| |
| if (uploadedFiles.length === 0) { |
| conversationInfo.style.display = "none"; |
| showSuccess("All files removed"); |
| } else { |
| showSuccess(`Removed file. ${uploadedFiles.length} file(s) remaining`); |
| } |
| } |
| }; |
| |
| /** |
| * Converts a File object to an array of GoogleGenerativeAI.Part objects. |
| */ |
| async function fileToGenerativeParts(file) { |
| const excelMimeTypes = [ |
| 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx |
| 'application/vnd.ms-excel' // .xls |
| ]; |
| |
| if (excelMimeTypes.includes(file.type) || file.name.endsWith('.xlsx') || file.name.endsWith('.xls')) { |
| try { |
| const buffer = await file.arrayBuffer(); |
| const workbook = XLSX.read(buffer, { type: 'buffer' }); |
| const textParts = []; |
| |
| workbook.SheetNames.forEach(sheetName => { |
| const worksheet = workbook.Sheets[sheetName]; |
| const csvData = XLSX.utils.sheet_to_csv(worksheet); |
| const partHeader = `[File: ${file.name} | Sheet: ${sheetName}]\n\n`; |
| textParts.push({ text: partHeader + csvData }); |
| }); |
| |
| return textParts; |
| } catch (error) { |
| console.error("Error reading Excel file:", error); |
| return [{ text: `[Error processing Excel file ${file.name}: ${error.message}]` }]; |
| } |
| } |
| else { // For all other files (PDF, images, etc.) |
| return new Promise((resolve, reject) => { |
| const reader = new FileReader(); |
| reader.readAsDataURL(file); |
| reader.onload = () => { |
| const dataUrl = reader.result; |
| const base64Data = dataUrl.split(',')[1]; |
| const mimeType = dataUrl.split(';')[0].split(':')[1]; |
| |
| resolve([{ |
| inlineData: { |
| data: base64Data, |
| mimeType: mimeType |
| } |
| }]); |
| }; |
| reader.onerror = (error) => reject(error); |
| }); |
| } |
| } |
| |
| /** |
| * Sets the loading state of the UI |
| */ |
| function setLoadingState(isLoading, message = "") { |
| if (isLoading) { |
| statusText.innerHTML = `<span class="spinner"></span> ${message}`; |
| sendButton.disabled = true; |
| promptInput.disabled = true; |
| attachButton.disabled = true; |
| } else { |
| statusText.innerHTML = ""; |
| sendButton.disabled = false; |
| promptInput.disabled = false; |
| attachButton.disabled = false; |
| promptInput.focus(); |
| } |
| } |
| |
| /** |
| * Displays a message in the chat box |
| */ |
| function displayMessage(text, role) { |
| const messageEl = document.createElement("div"); |
| messageEl.classList.add("message", role); |
| |
| if (role === 'error') { |
| messageEl.textContent = text; |
| } else if (role === 'assistant') { |
| messageEl.innerHTML = marked.parse(text || "..."); // Show loading dots |
| } else { |
| messageEl.textContent = text; // Plain text for user |
| } |
| |
| chatBox.appendChild(messageEl); |
| scrollToBottom(); |
| return messageEl; |
| } |
| |
| /** |
| * Displays a success message in the chat |
| */ |
| function showSuccess(text) { |
| const messageEl = document.createElement("div"); |
| messageEl.className = "message success"; |
| messageEl.textContent = text; |
| |
| chatBox.appendChild(messageEl); |
| scrollToBottom(); |
| |
| // Auto-remove success message after 3 seconds |
| setTimeout(() => { |
| if (messageEl.parentNode) { |
| messageEl.remove(); |
| } |
| }, 3000); |
| } |
| |
| /** |
| * Displays an error message in the chat |
| */ |
| function displayError(text) { |
| displayMessage(text, "error"); |
| setLoadingState(false); |
| } |
| |
| /** |
| * Scrolls the chat box to the bottom |
| */ |
| function scrollToBottom() { |
| chatBox.scrollTop = chatBox.scrollHeight; |
| } |
| |
| /** |
| * Toggle analysis details visibility |
| */ |
| function toggleAnalysisDetails() { |
| analysisDetailsVisible = !analysisDetailsVisible; |
| updateDetailsVisibility(); |
| } |
| |
| /** |
| * Update analysis details visibility |
| */ |
| function updateDetailsVisibility() { |
| if (analysisDetailsVisible) { |
| debugBox.style.display = 'block'; |
| toggleDetailsBtn.innerHTML = 'ποΈ Hide Analysis Details'; |
| } else { |
| debugBox.style.display = 'none'; |
| toggleDetailsBtn.innerHTML = 'ποΈ Show Analysis Details'; |
| } |
| } |
| |
| /** |
| * Export conversation to PDF |
| */ |
| function exportToPDF() { |
| if (conversationHistory.length === 0) { |
| displayError("No conversation to export."); |
| return; |
| } |
| |
| try { |
| // Create HTML content for PDF |
| const currentDate = new Date().toLocaleDateString(); |
| const timestamp = new Date().toLocaleTimeString(); |
| let htmlContent = ` |
| <!DOCTYPE html> |
| <html> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Carver Excel Analyst - Export</title> |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); |
| |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; |
| line-height: 1.7; |
| color: #1a202c; |
| background-color: #f7fafc; |
| font-size: 14px; |
| } |
| |
| .page { |
| background: white; |
| max-width: 900px; |
| margin: 20px auto; |
| padding: 60px; |
| box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); |
| border-radius: 8px; |
| min-height: 800px; |
| } |
| |
| |
| .header { |
| text-align: center; |
| margin-bottom: 50px; |
| padding-bottom: 30px; |
| border-bottom: 3px solid #e2e8f0; |
| position: relative; |
| } |
| |
| .header::after { |
| content: ''; |
| position: absolute; |
| bottom: -3px; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 80px; |
| height: 3px; |
| background: linear-gradient(135deg, #3182ce, #63b3ed); |
| } |
| |
| .header h1 { |
| color: #2d3748; |
| font-size: 2.5rem; |
| font-weight: 700; |
| margin-bottom: 10px; |
| letter-spacing: -0.025em; |
| } |
| |
| .header .subtitle { |
| color: #718096; |
| font-size: 1.1rem; |
| font-weight: 400; |
| margin-bottom: 15px; |
| } |
| |
| .header .metadata { |
| display: flex; |
| justify-content: center; |
| gap: 30px; |
| margin-top: 20px; |
| font-size: 0.9rem; |
| color: #a0aec0; |
| } |
| |
| .metadata-item { |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| } |
| |
| |
| .files-section { |
| background: linear-gradient(135deg, #f7fafc, #edf2f7); |
| border: 2px solid #e2e8f0; |
| border-radius: 12px; |
| padding: 25px; |
| margin-bottom: 40px; |
| } |
| |
| .files-section h2 { |
| color: #2d3748; |
| font-size: 1.4rem; |
| font-weight: 600; |
| margin-bottom: 20px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| } |
| |
| .files-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |
| gap: 15px; |
| margin-top: 15px; |
| } |
| |
| .file-card { |
| background: white; |
| padding: 15px 20px; |
| border-radius: 8px; |
| border: 1px solid #e2e8f0; |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); |
| transition: all 0.2s ease; |
| } |
| |
| .file-card:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); |
| } |
| |
| .file-name { |
| font-weight: 600; |
| color: #2d3748; |
| margin-bottom: 5px; |
| } |
| |
| .file-size { |
| color: #718096; |
| font-size: 0.85rem; |
| } |
| |
| |
| .conversation-section { |
| margin-bottom: 40px; |
| } |
| |
| .conversation-entry { |
| background: white; |
| border-radius: 12px; |
| margin-bottom: 30px; |
| overflow: hidden; |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); |
| border: 1px solid #e2e8f0; |
| } |
| |
| .user-entry { |
| border-left: 4px solid #3182ce; |
| } |
| |
| .assistant-entry { |
| border-left: 4px solid #38a169; |
| } |
| |
| .entry-header { |
| padding: 20px 25px 15px; |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| background: linear-gradient(135deg, #f7fafc, #edf2f7); |
| } |
| |
| .user-entry .entry-header { |
| background: linear-gradient(135deg, #ebf8ff, #bee3f8); |
| } |
| |
| .assistant-entry .entry-header { |
| background: linear-gradient(135deg, #f0fff4, #c6f6d5); |
| } |
| |
| .role-icon { |
| width: 32px; |
| height: 32px; |
| border-radius: 50%; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-weight: 600; |
| font-size: 0.8rem; |
| color: white; |
| } |
| |
| .user-entry .role-icon { |
| background: #3182ce; |
| } |
| |
| .assistant-entry .role-icon { |
| background: #38a169; |
| } |
| |
| .role-text { |
| font-weight: 600; |
| font-size: 1.1rem; |
| color: #2d3748; |
| } |
| |
| .timestamp { |
| margin-left: auto; |
| color: #718096; |
| font-size: 0.85rem; |
| } |
| |
| .entry-content { |
| padding: 25px; |
| background: white; |
| } |
| |
| |
| .message-content { |
| font-size: 0.95rem; |
| line-height: 1.8; |
| } |
| |
| .user-entry .message-content { |
| color: #2d3748; |
| } |
| |
| .assistant-entry .message-content { |
| color: #2d3748; |
| } |
| |
| |
| .message-content h1, .message-content h2, .message-content h3 { |
| color: #2d3748; |
| margin-top: 1.5em; |
| margin-bottom: 0.8em; |
| font-weight: 600; |
| } |
| |
| .message-content h1 { |
| font-size: 1.4rem; |
| border-bottom: 2px solid #e2e8f0; |
| padding-bottom: 8px; |
| } |
| |
| .message-content h2 { |
| font-size: 1.25rem; |
| color: #3182ce; |
| } |
| |
| .message-content h3 { |
| font-size: 1.1rem; |
| color: #38a169; |
| } |
| |
| .message-content strong { |
| font-weight: 600; |
| color: #2d3748; |
| } |
| |
| .message-content table { |
| width: 100%; |
| border-collapse: collapse; |
| margin: 20px 0; |
| font-size: 0.9rem; |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
| border-radius: 8px; |
| overflow: hidden; |
| } |
| |
| .message-content th { |
| background: linear-gradient(135deg, #3182ce, #63b3ed); |
| color: white; |
| padding: 15px 12px; |
| text-align: left; |
| font-weight: 600; |
| } |
| |
| .message-content td { |
| padding: 12px; |
| border-bottom: 1px solid #e2e8f0; |
| background: white; |
| } |
| |
| .message-content tr:nth-child(even) td { |
| background: #f7fafc; |
| } |
| |
| .message-content tr:hover td { |
| background: #edf2f7; |
| } |
| |
| .message-content code { |
| background: #f7fafc; |
| padding: 3px 8px; |
| border-radius: 4px; |
| font-family: 'Monaco', 'Consolas', 'Courier New', monospace; |
| font-size: 0.85rem; |
| color: #d53f8c; |
| border: 1px solid #e2e8f0; |
| } |
| |
| .message-content pre { |
| background: #1a202c; |
| color: #f7fafc; |
| padding: 20px; |
| border-radius: 8px; |
| overflow-x: auto; |
| margin: 20px 0; |
| border: 1px solid #2d3748; |
| } |
| |
| .message-content pre code { |
| background: transparent; |
| padding: 0; |
| color: #f7fafc; |
| border: none; |
| font-size: 0.85rem; |
| } |
| |
| .message-content blockquote { |
| border-left: 4px solid #3182ce; |
| padding-left: 20px; |
| margin: 20px 0; |
| background: #f7fafc; |
| padding: 15px 20px; |
| border-radius: 0 8px 8px 0; |
| color: #4a5568; |
| } |
| |
| |
| .audit-block { |
| background: linear-gradient(135deg, #fed7d7, #feb2b2); |
| border: 2px solid #fc8181; |
| border-radius: 8px; |
| padding: 20px; |
| margin-top: 25px; |
| } |
| |
| .audit-block h3 { |
| color: #c53030; |
| margin-top: 0; |
| margin-bottom: 15px; |
| font-weight: 600; |
| } |
| |
| .audit-block ul { |
| margin-left: 20px; |
| } |
| |
| .audit-block li { |
| margin-bottom: 5px; |
| } |
| |
| |
| @media print { |
| body { |
| background: white; |
| } |
| |
| .page { |
| margin: 0; |
| padding: 40px; |
| box-shadow: none; |
| border-radius: 0; |
| } |
| |
| .conversation-entry { |
| break-inside: avoid; |
| page-break-inside: avoid; |
| } |
| } |
| |
| |
| .page-break { |
| page-break-before: always; |
| } |
| </style> |
| </head> |
| <body> |
| <div class="page"> |
| <div class="header"> |
| <h1>π§ Carver Excel Analyst</h1> |
| <div class="subtitle">Professional Data Analysis Report</div> |
| <div class="metadata"> |
| <div class="metadata-item"> |
| <span>π
</span> |
| <span>${currentDate}</span> |
| </div> |
| <div class="metadata-item"> |
| <span>π</span> |
| <span>${timestamp}</span> |
| </div> |
| <div class="metadata-item"> |
| <span>π¬</span> |
| <span>${conversationHistory.length} messages</span> |
| </div> |
| </div> |
| </div> |
| |
| <div class="files-section"> |
| <h2>π Analyzed Files</h2> |
| <div class="files-grid"> |
| `; |
| |
| uploadedFiles.forEach(file => { |
| htmlContent += ` |
| <div class="file-card"> |
| <div class="file-name">${file.name}</div> |
| <div class="file-size">${(file.size / 1024).toFixed(1)} KB</div> |
| </div> |
| `; |
| }); |
| |
| htmlContent += ` |
| </div> |
| </div> |
| |
| <div class="conversation-section"> |
| <h2>π¬ Conversation Analysis</h2> |
| `; |
| |
| conversationHistory.forEach((message, index) => { |
| const roleClass = message.role === 'user' ? 'user-entry' : 'assistant-entry'; |
| const roleIcon = message.role === 'user' ? 'π€' : 'π€'; |
| const roleText = message.role === 'user' ? 'User' : 'Carver Analyst'; |
| const messageTime = new Date(Date.now() - (conversationHistory.length - index) * 1000).toLocaleTimeString(); |
| |
| htmlContent += ` |
| <div class="conversation-entry ${roleClass}"> |
| <div class="entry-header"> |
| <div class="role-icon">${roleIcon}</div> |
| <div class="role-text">${roleText}</div> |
| <div class="timestamp">${messageTime}</div> |
| </div> |
| <div class="entry-content"> |
| <div class="message-content"> |
| ${marked.parse(message.content)} |
| </div> |
| </div> |
| </div> |
| `; |
| }); |
| |
| htmlContent += ` |
| </div> |
| </div> |
| </body> |
| </html> |
| `; |
| |
| // Create and trigger download |
| const blob = new Blob([htmlContent], { type: 'text/html' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `carver-analyst-report-${new Date().toISOString().split('T')[0]}.html`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| |
| showSuccess("β
Professional report exported! Open the HTML file and print as PDF."); |
| |
| // Show instructions for PDF conversion |
| setTimeout(() => { |
| showSuccess("π‘ To create PDF: Open the file β Print (Ctrl/Cmd+P) β Save as PDF"); |
| }, 2000); |
| |
| } catch (error) { |
| console.error("Export error:", error); |
| displayError(`Export failed: ${error.message}`); |
| } |
| } |
| |
| </script> |
| </body> |
| </html> |