diff --git a/backend/app.py b/backend/app.py index 6524bdb..963aa10 100644 --- a/backend/app.py +++ b/backend/app.py @@ -4,4 +4,4 @@ from src import create_app app = create_app() if __name__ == "__main__": - app.run(debug=True, port=5000) \ No newline at end of file + app.run(debug=True, port=5000, host="0.0.0.0") \ No newline at end of file diff --git a/backend/src/__init__.py b/backend/src/__init__.py index 63adc7f..86a451a 100644 --- a/backend/src/__init__.py +++ b/backend/src/__init__.py @@ -11,5 +11,7 @@ def create_app(): app.register_blueprint(main_bp) app.register_blueprint(rag_bp, url_prefix='/api/rag') app.register_blueprint(gemini_bp, url_prefix='/api/gemini') + from .routes.reports import reports_bp + app.register_blueprint(reports_bp, url_prefix='/api/reports') return app diff --git a/backend/src/chroma/vector_store.py b/backend/src/chroma/vector_store.py index bcb259a..032914f 100644 --- a/backend/src/chroma/vector_store.py +++ b/backend/src/chroma/vector_store.py @@ -53,9 +53,11 @@ def search_documents(query_embedding, collection_name=COLLECTION_NAME, num_resul if results and results["documents"]: for i, doc in enumerate(results["documents"][0]): score = results["distances"][0][i] if "distances" in results else None + meta = results["metadatas"][0][i] if "metadatas" in results else {} output.append({ "text": doc, - "score": score + "score": score, + "metadata": meta }) return output @@ -67,3 +69,12 @@ def delete_documents_by_source(source_file, collection_name=COLLECTION_NAME): collection.delete(ids=results["ids"]) return len(results["ids"]) return 0 + +def get_all_metadatas(collection_name=COLLECTION_NAME, limit=None): + collection = get_collection(collection_name) + # Only fetch metadatas to be lightweight + if limit: + results = collection.get(include=["metadatas"], limit=limit) + else: + results = collection.get(include=["metadatas"]) + return results["metadatas"] if results and "metadatas" in results else [] diff --git a/backend/src/gemini/client.py b/backend/src/gemini/client.py index 29193a4..fef0733 100644 --- a/backend/src/gemini/client.py +++ b/backend/src/gemini/client.py @@ -3,9 +3,15 @@ import os def generate_content(prompt, model_name="gemini-2.0-flash-exp"): api_key = os.environ.get("GOOGLE_API_KEY") - client = genai.Client(api_key=api_key) - response = client.models.generate_content( - model=model_name, - contents=prompt, - ) - return response.text + if not api_key: + return "Error: GOOGLE_API_KEY not found." + + try: + client = genai.Client(api_key=api_key) + response = client.models.generate_content( + model=model_name, + contents=prompt, + ) + return response.text + except Exception as e: + return f"Error interacting with Gemini API: {str(e)}" diff --git a/backend/src/rag/gemeni.py b/backend/src/rag/gemeni.py index 6c36a85..6566b69 100644 --- a/backend/src/rag/gemeni.py +++ b/backend/src/rag/gemeni.py @@ -24,7 +24,10 @@ class GeminiClient: response = self.client.models.generate_content( model=self.model_name, - contents=full_message + contents=full_message, + config={ + 'system_instruction': 'You are a concise sustainability assistant. Your responses must be a single short paragraph, maximum 6 sentences long. Do not use bullet points or multiple sections.' + } ) return response.text diff --git a/backend/src/routes/reports.py b/backend/src/routes/reports.py new file mode 100644 index 0000000..fbd7ff6 --- /dev/null +++ b/backend/src/routes/reports.py @@ -0,0 +1,228 @@ +from flask import Blueprint, jsonify, request +from src.chroma.vector_store import get_all_metadatas, search_documents +from src.rag.embeddings import get_embedding + +reports_bp = Blueprint('reports', __name__) + +@reports_bp.route('/', methods=['GET']) +def get_reports(): + try: + # Fetch all metadatas to ensure we get diversity. + # 60k items is manageable for metadata-only fetch. + metadatas = get_all_metadatas() + + unique_reports = {} + + for meta in metadatas: + filename = meta.get('source') or meta.get('filename') + if not filename: + continue + + if filename not in unique_reports: + # Attempt to extract info from filename + # Common patterns: + # 2020-tesla-impact-report.pdf + # google-2023-environmental-report.pdf + # ghgp_data_2021.xlsx + + company_name = "Unknown" + year = "N/A" + sector = "Other" + + lower_name = filename.lower() + + # Extract Year + import re + year_match = re.search(r'20\d{2}', lower_name) + if year_match: + year = year_match.group(0) + + # Extract Company (heuristics) + if 'tesla' in lower_name: + company_name = "Tesla" + sector = "Automotive" + elif 'google' in lower_name: + company_name = "Google" + sector = "Tech" + elif 'apple' in lower_name: + company_name = "Apple" + sector = "Tech" + elif 'microsoft' in lower_name: + company_name = "Microsoft" + sector = "Tech" + elif 'amazon' in lower_name: + company_name = "Amazon" + sector = "Tech" + elif 'boeing' in lower_name: + company_name = "Boeing" + sector = "Aerospace" + elif 'ghgp' in lower_name: + company_name = "GHGP Data" + sector = "Data" + elif 'salesforce' in lower_name: + company_name = "Salesforce" + sector = "Tech" + elif 'hp ' in lower_name or 'hp-' in lower_name: + company_name = "HP" + sector = "Tech" + else: + # Fallback: capitalize first word of filename + parts = re.split(r'[-_.]', filename) + if parts: + company_name = parts[0].capitalize() + if company_name.isdigit(): # If starts with year + company_name = parts[1].capitalize() if len(parts) > 1 else "Unknown" + + unique_reports[filename] = { + 'company_name': company_name, + 'year': year, + 'sector': sector, + 'greenwashing_score': meta.get('greenwashing_score', 0), # Likely 0 + 'filename': filename, + 'title': f"{company_name} {year} Report" + } + + reports_list = list(unique_reports.values()) + return jsonify(reports_list) + + except Exception as e: + print(f"Error fetching reports: {e}") + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +@reports_bp.route('/search', methods=['POST']) +def search_reports(): + data = request.json + query = data.get('query', '') + + if not query: + return jsonify([]) + + try: + import re + + # Get embedding for the query + query_embedding = get_embedding(query) + + # Search in Chroma - get more results to filter + results = search_documents(query_embedding, num_results=50) + + query_lower = query.lower() + + # Helper function to extract company info + def extract_company_info(filename): + company_name = "Unknown" + year = "N/A" + sector = "Other" + + lower_name = filename.lower() + + # Extract Year + year_match = re.search(r'20\d{2}', lower_name) + if year_match: + year = year_match.group(0) + + # Extract Company (heuristics) + if 'tesla' in lower_name: + company_name = "Tesla" + sector = "Automotive" + elif 'google' in lower_name: + company_name = "Google" + sector = "Tech" + elif 'apple' in lower_name: + company_name = "Apple" + sector = "Tech" + elif 'microsoft' in lower_name: + company_name = "Microsoft" + sector = "Tech" + elif 'amazon' in lower_name: + company_name = "Amazon" + sector = "Tech" + elif 'boeing' in lower_name: + company_name = "Boeing" + sector = "Aerospace" + elif 'ghgp' in lower_name: + company_name = "GHGP Data" + sector = "Data" + elif 'salesforce' in lower_name: + company_name = "Salesforce" + sector = "Tech" + elif 'hp ' in lower_name or 'hp-' in lower_name or lower_name.startswith('hp'): + company_name = "HP" + sector = "Tech" + else: + parts = re.split(r'[-_.]', filename) + if parts: + company_name = parts[0].capitalize() + if company_name.isdigit(): + company_name = parts[1].capitalize() if len(parts) > 1 else "Unknown" + + return company_name, year, sector + + output = [] + seen_filenames = set() + + for item in results: + meta = item.get('metadata', {}) + text = item.get('text', '') + + filename = meta.get('source') or meta.get('filename', 'Unknown') + + # Skip duplicates + if filename in seen_filenames: + continue + seen_filenames.add(filename) + + company_name, year, sector = extract_company_info(filename) + + # Calculate match score - boost if query matches company/filename + match_boost = 0 + if query_lower in filename.lower(): + match_boost = 1000 # Strong boost for filename match + if query_lower in company_name.lower(): + match_boost = 1000 # Strong boost for company match + + # Semantic score (inverted distance, higher = better) + semantic_score = 1 / (item.get('score', 1) + 0.001) if item.get('score') else 0 + + combined_score = match_boost + semantic_score + + # Format snippet + snippet = text[:300] + "..." if len(text) > 300 else text + + output.append({ + 'company_name': company_name, + 'year': year, + 'filename': filename, + 'sector': sector, + 'greenwashing_score': meta.get('greenwashing_score', 0), + 'snippet': snippet, + 'relevance_score': item.get('score'), + '_combined_score': combined_score + }) + + # Sort by combined score (descending - higher is better) + output.sort(key=lambda x: x.get('_combined_score', 0), reverse=True) + + # Remove internal score field and limit results + for item in output: + item.pop('_combined_score', None) + + return jsonify(output[:20]) + except Exception as e: + print(f"Error searching reports: {e}") + return jsonify({'error': str(e)}), 500 + +@reports_bp.route('/view/', methods=['GET']) +def view_report_file(filename): + import os + from flask import send_from_directory + + # Dataset path relative to this file + # src/routes/reports.py -> src/routes -> src -> backend -> dataset + # So ../../../dataset + current_dir = os.path.dirname(os.path.abspath(__file__)) + dataset_dir = os.path.join(current_dir, '..', '..', 'dataset') + + return send_from_directory(dataset_dir, filename) diff --git a/frontend/package.json b/frontend/package.json index 785f258..3ff0c7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@types/three": "^0.182.0", "compression": "^1.8.1", "express": "^5.2.1", + "marked": "^17.0.1", "tailwindcss": "^4.1.18", "three": "^0.182.0" }, diff --git a/frontend/src/lib/components/ParallaxLandscape.svelte b/frontend/src/lib/components/ParallaxLandscape.svelte index 03abad6..614c5a0 100644 --- a/frontend/src/lib/components/ParallaxLandscape.svelte +++ b/frontend/src/lib/components/ParallaxLandscape.svelte @@ -201,19 +201,8 @@ const config = activeConfig(); if (!config.staticScene || config.scenes) { - scrollContainer = document.querySelector( - ".app-container", - ) as HTMLElement; - if (!scrollContainer) { - scrollContainer = document.querySelector( - ".content-wrapper", - ) as HTMLElement; - } - if (!scrollContainer) { - scrollContainer = document.querySelector( - "main", - ) as HTMLElement; - } + // Always use window scroll now + scrollContainer = null; updateMeasurements(); } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 88f562d..7a22d10 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -148,7 +148,8 @@ sans-serif; background-color: #0c0c0c; color: white; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; min-height: 100vh; } @@ -159,8 +160,7 @@ } .app-container { - height: 100vh; - overflow-y: auto; + min-height: 100vh; padding: 10px; padding-bottom: 70px; box-sizing: border-box; @@ -181,9 +181,7 @@ } .desktop-nav { - position: absolute; - top: 0; - left: 0; + position: relative; width: 100%; height: auto; overflow: visible; @@ -193,7 +191,6 @@ justify-content: flex-start; box-shadow: none; z-index: 100; - pointer-events: none; } .nav-container { diff --git a/frontend/src/routes/catalogue/+page.svelte b/frontend/src/routes/catalogue/+page.svelte index eae39a3..612c4e2 100644 --- a/frontend/src/routes/catalogue/+page.svelte +++ b/frontend/src/routes/catalogue/+page.svelte @@ -1,104 +1,321 @@ - Ethix - Product Catalogue + Ethix - Sustainability Reports + +
+ {#if isModalOpen && selectedReport} + + {/if} +
+ {#snippet paginationControls()} + {#if totalPages > 1} + + {/if} + {/snippet} +
+
-

Product Database

+

Sustainability Database

- Search our verified sustainability ratings + Search within verified company reports and impact + assessments

@@ -109,8 +326,9 @@
@@ -119,39 +337,108 @@ {/each}
+ + + {@render paginationControls()}
-
- {#each filteredProducts as product} -
-
- -
- -
-

{product.name}

-

{product.brand}

-
- -
+ +

Syncing with database...

+
+ {:else if filteredReports.length === 0} +
+

No reports found matching your criteria.

+
+ {:else} +
+ {#each paginatedReports as report} + {@const fileDetails = getFileDetails(report.filename)} +
-
- {/each} -
+
+ +
+ +
+
+

+ {report.company_name} +

+ {report.year} +
+ + {#if report.snippet} +

+ {@html report.snippet.replace( + new RegExp(searchQuery, "gi"), + (match) => + `${match}`, + )} +

+ {:else} +

+ {report.sector} Sector • Impact Report +

+ {/if} + +
+ + + {fileDetails.type} + + + + + Analyzed + +
+
+ + + {#if report.greenwashing_score} +
+
+ {Math.round( + Number(report.greenwashing_score), + )} +
+ Trust Score +
+ {/if} + + {/each} + + {/if} @@ -175,7 +462,7 @@ position: relative; z-index: 10; padding: 100px 24px 120px; - max-width: 1200px; + max-width: 1000px; margin: 0 auto; } @@ -281,81 +568,154 @@ box-shadow: 0 4px 16px rgba(34, 197, 94, 0.3); } - .product-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 20px; - padding: 0 12px; + /* Report List Styles */ + /* Report List Styles */ + .report-list { + display: flex; + flex-direction: column; + gap: 12px; /* Reduced gap */ } - .product-card { + .report-card { background: rgba(0, 0, 0, 0.35); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 20px; - padding: 20px; + border-radius: 16px; /* Slightly reduced radius */ + padding: 16px; /* Compact padding */ transition: all 0.3s ease; display: flex; - flex-direction: column; + align-items: center; /* Center vertically */ + gap: 16px; /* Reduced gap */ + text-decoration: none; + color: inherit; cursor: pointer; - position: relative; + width: 100%; + text-align: left; } - .product-card:hover { + .report-card:hover { background: rgba(0, 0, 0, 0.5); border-color: rgba(255, 255, 255, 0.15); - transform: translateY(-4px); + transform: translateY(-2px); } - .card-image-placeholder { - width: 100%; - aspect-ratio: 1; + /* ... skipped some styles ... */ + + .card-icon { + width: 42px; /* Smaller icon */ + height: 42px; background: rgba(255, 255, 255, 0.05); border-radius: 12px; display: flex; align-items: center; justify-content: center; - margin-bottom: 16px; + flex-shrink: 0; } - .product-info { - flex-grow: 1; + .report-info { + flex: 1; } - .product-name { + .report-header { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 4px; /* Tighter margin */ + } + + .company-name { color: white; - font-size: 16px; + font-size: 18px; /* Smaller title */ font-weight: 700; - margin: 0 0 4px 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .product-brand { - color: rgba(255, 255, 255, 0.5); - font-size: 14px; margin: 0; } + .report-year { + background: rgba(255, 255, 255, 0.1); + color: #94a3b8; + padding: 2px 6px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + } + + .report-filename { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + margin: 0 0 8px 0; /* Tighter margin */ + } + + .report-snippet { + color: rgba(255, 255, 255, 0.8); + font-size: 13px; + line-height: 1.5; + margin: 0 0 8px 0; + padding-left: 10px; + border-left: 2px solid #34d399; + } + + .report-snippet :global(.highlight) { + background: rgba(52, 211, 153, 0.3); + color: white; + font-weight: 700; + border-radius: 2px; + padding: 0 2px; + } + + .report-tags { + display: flex; + gap: 10px; + } + + .tag { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + color: #94a3b8; + } + + .score-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + } + .score-badge { - position: absolute; - top: 28px; - right: 28px; - width: 36px; + width: 36px; /* Smaller badge */ height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); } .score-text { color: white; font-weight: 800; - font-size: 12px; + font-size: 13px; + } + + .score-label { + color: rgba(255, 255, 255, 0.4); + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .loading-state, + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 0; + color: rgba(255, 255, 255, 0.5); } @media (min-width: 768px) { @@ -379,9 +739,182 @@ font-size: 32px; } - .product-grid { - grid-template-columns: repeat(2, 1fr); - gap: 12px; + .report-card { + flex-direction: column; + } + + .score-container { + flex-direction: row; + width: 100%; + border-top: 1px solid rgba(255, 255, 255, 0.1); + padding-top: 16px; + margin-top: 8px; + justify-content: flex-start; } } + + /* Pagination Styles */ + .pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + margin-top: 24px; /* Reduced from 40px for header placement */ + padding-bottom: 8px; + } + + .page-btn { + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: white; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + } + + .page-btn.nav { + width: 32px; /* Smaller nav buttons */ + height: 32px; + } + + .page-btn.number { + width: 32px; /* Smaller number buttons */ + height: 32px; + font-weight: 600; + font-size: 13px; + } + + .page-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); + } + + .page-btn.active { + background: #34d399; + color: #051f18; + border-color: #34d399; + font-weight: 800; + } + + .page-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + .page-numbers { + display: flex; + gap: 8px; + align-items: center; + } + + .page-info { + color: rgba(255, 255, 255, 0.6); + font-size: 13px; + font-weight: 500; + } + + /* Modal Styles */ + .modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(5px); + z-index: 1000; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + } + + .modal-content { + background: #0f172a; + border: 1px solid #334155; + border-radius: 24px; + width: 100%; + max-width: 1000px; + height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); + overflow: hidden; + } + + .modal-header { + padding: 20px 24px; + background: #1e293b; + border-bottom: 1px solid #334155; + display: flex; + justify-content: space-between; + align-items: center; + } + + .modal-title-group h2 { + margin: 0; + color: white; + font-size: 20px; + } + + .modal-subtitle { + color: #94a3b8; + font-size: 14px; + } + + .close-button { + background: none; + border: none; + color: #cbd5e1; + cursor: pointer; + padding: 8px; + border-radius: 50%; + transition: all 0.2s; + } + + .close-button:hover { + background: rgba(255, 255, 255, 0.1); + color: white; + } + + .modal-body { + flex: 1; + overflow: hidden; + background: #000; + display: flex; + flex-direction: column; + } + + .file-viewer { + width: 100%; + height: 100%; + border: none; + } + + .no-preview { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #94a3b8; + gap: 20px; + } + + .download-btn { + background: #34d399; + color: #0f172a; + padding: 12px 24px; + border-radius: 50px; + text-decoration: none; + font-weight: bold; + transition: transform 0.2s; + } + + .download-btn:hover { + transform: scale(1.05); + } diff --git a/frontend/src/routes/chat/+page.svelte b/frontend/src/routes/chat/+page.svelte index 86d84a2..4240dd8 100644 --- a/frontend/src/routes/chat/+page.svelte +++ b/frontend/src/routes/chat/+page.svelte @@ -2,6 +2,7 @@ import { onMount } from "svelte"; import ParallaxLandscape from "$lib/components/ParallaxLandscape.svelte"; import Icon from "@iconify/svelte"; + import { marked } from "marked"; let messages = $state([ { @@ -12,38 +13,74 @@ ]); let inputText = $state(""); let canvasElement = $state(); + let isLoading = $state(false); + let chatWindowFn: HTMLDivElement | undefined = $state(); - function sendMessage() { - if (!inputText.trim()) return; - - const userMsg = { - id: Date.now(), - text: inputText, - sender: "user", - }; - const aiResponse = generateResponse(inputText); - messages = [...messages, userMsg]; - inputText = ""; - - setTimeout(() => { - const aiMsg = { - id: Date.now() + 1, - text: aiResponse, - sender: "ai", - }; - messages = [...messages, aiMsg]; - }, 1000); + function scrollToBottom() { + if (chatWindowFn) { + setTimeout(() => { + chatWindowFn!.scrollTop = chatWindowFn!.scrollHeight; + }, 0); + } } - function generateResponse(text: string): string { - const lower = text.toLowerCase(); - if (lower.includes("plastic")) - return "Plastic takes 450 years to decompose. Check the resin code (triangle number) to see if you can recycle it."; - if (lower.includes("glass")) - return "Glass is 100% recyclable. You can recycle it forever without losing quality."; - if (lower.includes("aluminum")) - return "Aluminum is sustainable gold. Infinite recycling, low energy cost to reuse."; - return "Great question! Sustainable living starts with buying less, then reusing, then recycling."; + $effect(() => { + // Dependencies to trigger scroll + messages; + isLoading; + scrollToBottom(); + }); + + async function sendMessage() { + if (!inputText.trim() || isLoading) return; + + const userText = inputText; + const userMsg = { + id: Date.now(), + text: userText, + sender: "user", + }; + messages = [...messages, userMsg]; + inputText = ""; + isLoading = true; + + try { + const response = await fetch( + "http://localhost:5000/api/gemini/ask", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + prompt: userText, + }), + }, + ); + + const data = await response.json(); + + if (data.status === "success") { + const aiMsg = { + id: Date.now() + 1, + text: data.reply, + sender: "ai", + }; + messages = [...messages, aiMsg]; + } else { + throw new Error(data.message || "Failed to get response"); + } + } catch (error) { + const errorMsg = { + id: Date.now() + 1, + text: "Sorry, I'm having trouble connecting to my brain right now. Please try again later.", + sender: "ai", + }; + messages = [...messages, errorMsg]; + console.error("Chat Error:", error); + } finally { + isLoading = false; + } } function handleKeyDown(event: KeyboardEvent) { @@ -62,35 +99,40 @@ function animate() { frame++; if (!ctx || !canvasElement) return; - ctx.clearRect(0, 0, 100, 100); + ctx.clearRect(0, 0, 40, 40); - const yOffset = Math.sin(frame * 0.05) * 5; + const yOffset = Math.sin(frame * 0.05) * 3; // Reduced amplitude + // Head ctx.fillStyle = "#e0e0e0"; ctx.beginPath(); - ctx.arc(50, 50, 45, 0, Math.PI * 2); + ctx.arc(20, 20, 18, 0, Math.PI * 2); // Center at 20,20, Radius 18 ctx.fill(); + // Face/Visor ctx.fillStyle = "#22c55e"; ctx.beginPath(); - ctx.arc(50, 50 + yOffset, 35, 0, Math.PI * 2); + ctx.arc(20, 20 + yOffset, 14, 0, Math.PI * 2); // Center 20,20 ctx.fill(); + // Reflection/Detail ctx.fillStyle = "#16a34a"; ctx.beginPath(); - ctx.moveTo(50, 15 + yOffset); - ctx.quadraticCurveTo(70, 5 + yOffset, 60, 35 + yOffset); + ctx.moveTo(20, 8 + yOffset); + ctx.quadraticCurveTo(28, 4 + yOffset, 24, 16 + yOffset); ctx.closePath(); ctx.fill(); + // Eyes ctx.fillStyle = "white"; - ctx.fillRect(35, 40 + yOffset, 8, 12); - ctx.fillRect(57, 40 + yOffset, 8, 12); + ctx.fillRect(14, 16 + yOffset, 3, 5); + ctx.fillRect(23, 16 + yOffset, 3, 5); + // Smile ctx.strokeStyle = "white"; - ctx.lineWidth = 3; + ctx.lineWidth = 2; ctx.beginPath(); - ctx.arc(50, 65 + yOffset, 10, 0.2, Math.PI - 0.2); + ctx.arc(20, 26 + yOffset, 4, 0.2, Math.PI - 0.2); ctx.stroke(); requestAnimationFrame(animate); @@ -118,30 +160,44 @@
-

Ethix Assistant

-
- - Powered by Gemini +
+

Ethix Assistant

+
+ + Powered by Gemini +
-
+
{#each messages as msg (msg.id)}
-

{msg.text}

+
+ {@html marked.parse(msg.text)} +
{/each} + + {#if isLoading} +
+
+ + + +
+
+ {/if}
@@ -168,8 +224,9 @@