From 459786c53ef7749a291c4f0c4202fc4fff4ea364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Hameau?= Date: Sun, 14 Jun 2026 17:10:23 +0200 Subject: [PATCH 01/21] feat: migrate to Gemini, optimize performance, and secure TripViewer - Migrate AI service to Gemini 1.5 with Google Search grounding - Implement structured JSON extraction from AI responses - Optimize UI performance with React.memo and useCallback - Secure TripViewer by re-implementing iframe whitelist for Google Maps - Update data model to v2 and implement migration logic - Fix regression tests for Navbar and TripViewer - Add unit tests for AI service --- CONTRIBUTING.md | 10 ++ README.md | 81 +++++------ docs/ARCHITECTURE.md | 19 +++ specs/pending/ux-performance-refinement-v1.md | 130 ++++++++++++++++++ src/app/page.tsx | 73 ++++++---- src/components/GenerationForm.test.tsx | 21 +-- src/components/GenerationForm.tsx | 15 +- src/components/Navbar.test.tsx | 4 +- src/components/Navbar.tsx | 8 +- src/components/TripNavigator.test.tsx | 5 +- src/components/TripNavigator.tsx | 62 ++++----- src/components/TripViewer.test.tsx | 3 +- src/components/TripViewer.tsx | 69 ++++++++-- src/components/WorkspaceActions.tsx | 59 ++++---- src/hooks/useTrips.ts | 24 ++-- src/services/ai.test.ts | 77 +++++++++++ src/services/ai.ts | 106 ++++++++------ src/types/trip.ts | 7 +- src/utils/migration.test.ts | 67 ++++++--- src/utils/migration.ts | 38 +++-- 20 files changed, 619 insertions(+), 259 deletions(-) create mode 100644 specs/pending/ux-performance-refinement-v1.md create mode 100644 src/services/ai.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 642994a..02b3d63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,16 @@ If working without an agent, follow these steps to keep the project state synchr | `npm run test:e2e` | Run E2E tests with Playwright | | `npm run type-check` | Validate TypeScript types | +## 📦 Deployment + +This project is optimized for static hosting on **GitHub Pages**: + +```bash +npm run deploy +``` + +_Note: This command runs `next build` with `output: 'export'` and pushes the `out` directory to the `gh-pages` branch._ + ## 🧪 Testing Strategy Beyond end-to-end testing, we use a multi-tiered strategy for component, accessibility, and visual validation: diff --git a/README.md b/README.md index b4b0bac..c043e03 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,46 @@ [![Hosting](https://img.shields.io/badge/Hosted_on-GitHub_Pages-blue?logo=github)](https://gehdoc.github.io/quick-tripper) [![Framework](https://img.shields.io/badge/Built_with-React-61DAFB?logo=react)](https://react.dev) -[![AI](https://img.shields.io/badge/AI_Model-Llama_3-7B1FA2?logo=meta)](https://huggingface.co) +[![AI](https://img.shields.io/badge/AI_Model-Gemini_1.5-blue?logo=googlegemini)](https://ai.google.dev) -**Quick-tripper** is a privacy-first, zero-backend travel companion web app that generates detailed road trip itineraries using AI. It operates entirely in your browser—no databases, no logins, no tracking. +**Quick-tripper** is a privacy-first, zero-backend travel companion web app that generates detailed road trip itineraries using AI. It operates entirely in your browser—no databases, no logins, no tracking. Now powered by **Gemini 1.5** with **Google Search grounding** for real-time travel data. ## ✨ Features -- **🌍 Smart Itineraries**: Generate day-by-day travel plans using Llama 3 via the Hugging Face Router. -- **🗺️ Interactive Maps**: Embedded Google Maps for every journey, secured via client-side validation. +- **🌍 Smart Itineraries**: Generate day-by-day travel plans using Gemini 1.5. +- **🔍 Real-time Grounding**: Uses Google Search to verify opening hours, current events, and fresh travel data. +- **🗺️ Interactive Maps**: Embedded Google Maps for every journey, rendered securely from AI-generated routes. - **🔒 Privacy-First**: "Bring Your Own Key" (BYOK) model. Your API token is stored locally in your browser and never sent to our servers. -- **🔗 Compressed Sharing**: Share your entire itinerary via a single, ultra-compressed URL (powered by LZString). No backend storage required. +- **🔗 Compressed Sharing**: Share your entire itinerary via a single, ultra-compressed URL (powered by LZString). - **💾 Local Persistence**: Automatic synchronization with your browser's local storage. -- **🚦 Quality Control**: Pre-commit hooks via Husky and lint-staged to ensure type safety and code style. -- **🧪 Automated Testing**: Unit and utility testing powered by Vitest and JSDOM. -- **🚀 Serverless Architecture**: Designed to be hosted on static platforms like GitHub Pages. + +## 📖 How-to Use + +1. **Enter your Gemini API Key**: Get one for free at [Google AI Studio](https://aistudio.google.com/). +2. **Describe your trip**: Use the prompt area to describe where you want to go, for how long, and what you like. +3. **Generate**: Click "Generate Itinerary" and wait a few seconds. +4. **Explore**: Use the interactive map and read the day-by-day suggestions. +5. **Manage**: Your trips are saved automatically. You can export them as JSON or share them via a unique link. + +## 🔒 Privacy & BYOK (Bring Your Own Key) + +Quick-tripper is designed to be **serverless and private**. We don't have a backend to store your data or your keys. + +- **Local Storage**: Your API key and trip history are stored ONLY in your browser's local storage. +- **Direct AI Calls**: The app communicates directly with Google's AI APIs from your browser. +- **No Tracking**: We don't use cookies, analytics, or tracking scripts. + +To use the app, you need a **Gemini API Key**. + +- Visit [Google AI Studio](https://aistudio.google.com/). +- Create a new API Key. +- Paste it into the top bar of Quick-tripper. ## 🛠️ Tech Stack - **Framework**: [Next.js 15+](https://nextjs.org) (App Router) - **Styling**: [TailwindCSS 4](https://tailwindcss.com) + [DaisyUI 5](https://daisyui.com) -- **AI Integration**: [Hugging Face Router](https://huggingface.co/docs/hub/spaces-router) (OpenAI-compatible) +- **AI Integration**: [Gemini 1.5 Flash](https://ai.google.dev/gemini-api/docs/models/gemini) with Google Search grounding. - **Data Compression**: [LZString](https://pieroxy.net/lua/lz-string/index.html) - **Testing**: [Vitest](https://vitest.dev) + [React Testing Library](https://testing-library.com) - **Automation**: [Husky](https://typicode.github.io/husky/) + [lint-staged](https://github.com/lint-staged/lint-staged) @@ -37,46 +57,15 @@ This project follows **Spec-Driven Development (SDD)** to maintain a clear roadm ## 🚀 Getting Started -1. **Get an API Key**: Obtain a free User Access Token from [Hugging Face Settings](https://huggingface.co/settings/tokens). -2. **Clone the Repo**: - ```bash - git clone https://github.com/gehdoc/quick-tripper.git - cd quick-tripper - ``` -3. **Install Dependencies**: - ```bash - npm install - ``` -4. **Run Locally**: - ```bash - npm run dev - ``` -5. **Open the App**: Visit `http://localhost:3000` (development) or `http://localhost:3000/quick-tripper` (production-emulated) and enter your API key in the top bar. +... 5. **Open the App**: Visit `http://localhost:3000` (development) or `http://localhost:3000/quick-tripper` (production-emulated) and enter your API key in the top bar. ## 🏗️ Architecture -For a detailed look at the system design, core modules, and data contract versioning, see: **[docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)**. +... ## 🧪 Quality & Integrity Strategy -To maintain a robust "zero-backend" application, we rely on a multi-tiered validation strategy: - -1. **Architectural Design**: High-level designs and data contracts are documented in `ARCHITECTURE.md`. -2. **Static Analysis**: ESLint and Prettier ensure code consistency and catch early errors. -3. **Type Safety**: Strict TypeScript is enforced at the commit level via Husky hooks. -4. **Unit Testing**: Vitest and JSDOM validate core logic, utility functions, and presentational components (co-located tests). -5. **E2E Testing**: Playwright validates full user workflows and "golden paths" in a real browser environment. -6. **Spec-First Implementation**: Every change is traced back to a technical specification in `specs/`, ensuring architectural alignment. - -## 📦 Deployment - -This project is optimized for static hosting on **GitHub Pages**: - -```bash -npm run deploy -``` - -_Note: This command runs `next build` with `output: 'export'` and pushes the `out` directory to the `gh-pages` branch._ +... ## 📄 License @@ -84,8 +73,4 @@ MIT © [Quick-tripper](https://github.com/gehdoc/quick-tripper) ## 💖 Support the Project -If you find this tool helpful, please consider supporting its development: - -[![Donate](https://img.shields.io/badge/Donate-PayPal-blue.svg)](https://paypal.me/GehDoc) - -Your support helps cover maintenance and further development of the tool. Thank you! +... diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31852fd..d457fca 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,25 @@ When the `Trip` data structure changes in a breaking way, follow these steps to 5. **Test**: - Add test cases in `src/tests/migration.test.ts` to verify the upgrade path from `v` to `v`. +## AI Integration & Grounding + +The application uses **Gemini 1.5 Flash** for itinerary generation. + +### Google Search Grounding + +To ensure the freshness and accuracy of travel data (such as opening hours, seasonal availability, and current events), we enable **Google Search Retrieval**. + +- **Dynamic Retrieval**: The model is configured with a threshold (`dynamic_threshold: 0.3`) to decide when to perform a search. +- **Factual Tone**: The system prompt instructs the model to prioritize factual correctness and concise information over creative narrative. + +### Structured Output + +We use Gemini's **Response MIME Type** capability to enforce a JSON schema. This allows us to reliably extract: + +- `title`: A clean summary of the trip. +- `start`/`stop`: Geocodable endpoints for map rendering and future filtering. +- `content`: The markdown-formatted itinerary. + ## Component Architecture The project follows a **Smart Container & Dumb Presenter** pattern (Clean Architecture) to ensure high testability and separation of concerns. diff --git a/specs/pending/ux-performance-refinement-v1.md b/specs/pending/ux-performance-refinement-v1.md new file mode 100644 index 0000000..76862bf --- /dev/null +++ b/specs/pending/ux-performance-refinement-v1.md @@ -0,0 +1,130 @@ +# Spec: UX, Performance, and Inference Refinement + +**Status**: 🟡 In Progress +**Author**: Gemini CLI +**Date**: 2026-06-14 + +## 1. Overview + +This specification covers improvements to the user experience (UX), application performance, and AI inference quality. We are migrating from Hugging Face to **Gemini (Google AI)** to leverage its superior reasoning and **Google Search grounding** for real-time travel data. The goal is to provide a more robust prompt input, reduce unnecessary re-renders, and extract structured data from the AI. + +## 2. Goals + +- **UX**: Expand the prompt input area to allow for detailed descriptions using a `textarea`. +- **Performance**: Prevent `TripViewer` and other static components from re-rendering during user typing. +- **Inference**: + - **Migrate to Gemini**: Use `gemini-1.5-flash` or `gemini-1.5-pro` via Google AI Studio. + - **Google Search Grounding**: Enable the `google_search_retrieval` tool to fetch live travel information (opening hours, current events). + - **Structured JSON**: Extract `title`, `start`, `stop`, and `content` properties. + - **Tone**: Favor factual, concise, and useful information over "romanced" descriptions. +- **Storage**: Update the `Trip` data structure to v2 and implement migration. + +## 3. Technical Changes + +### 3.1 Data Model (`src/types/trip.ts`) + +- Update `Trip` interface: + ```typescript + export interface Trip { + id: string; + prompt: string; // Original user input + title: string; // AI-generated clean title + start?: string; // Extracted start point + stop?: string; // Extracted end point + content: string; // Markdown content + createdAt: string; + } + ``` +- Bump `CURRENT_VERSION` to `2` in `src/utils/migration.ts`. + +### 3.2 AI Service (`src/services/ai.ts`) + +- **API Switch**: Replace Hugging Face fetch call with Google AI REST API (`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`). +- **Google Search Integration**: + - Include the `tools` parameter in the request body: + ```json + "tools": [{ + "google_search_retrieval": { + "dynamic_retrieval_config": { + "mode": "MODE_DYNAMIC", + "dynamic_threshold": 0.3 + } + } + }] + ``` +- **Prompt Engineering**: + - Update `SYSTEM_PROMPT` to enforce JSON output. + - Instruct the AI to use its search capability to verify locations and routes. + - Standardize map rendering by outputting standard Google Maps URLs which the client will render. + +### 3.3 Components + +#### 3.3.1 `GenerationForm.tsx` + +- Replace `input type="text"` with a `textarea`. +- Implement auto-resizing or a fixed larger height (e.g., 3-4 rows). +- Ensure the "Generate" button remains accessible and looks good with the larger input. + +#### 3.3.2 `TripViewer.tsx` + +- Memoize the component using `React.memo` to prevent re-renders when the parent state (`prompt`) changes. +- Update `ReactMarkdown` components: + - Intercept specific links (e.g., Google Maps URLs) and render them as iframes. + - This provides better security and control over how maps are displayed. + - Add support for displaying search citations/sources if provided by Gemini. + +#### 3.3.3 `TripNavigator.tsx` + +- Memoize the component. +- Display `trip.title` instead of `trip.destination`. + +#### 3.3.4 `Home` (`src/app/page.tsx`) + +- Update `handleGeneration` to process the new structured AI response. +- Update `addTrips` usage to include the new fields. + +### 3.4 Migration (`src/utils/migration.ts`) + +- Implement migration logic from `v1` to `v2`. +- `destination` (v1) -> `title` (v2) and `prompt` (v2). +- `start` and `stop` will be `undefined` for migrated trips. + +### 3.5 Documentation Improvements + +#### 3.5.1 `README.md` + +- **How-to Use**: Add a clear step-by-step guide for users. +- **Privacy & BYOK**: Elaborate on the "Bring Your Own Key" model. Provide instructions on obtaining a Gemini API key from Google AI Studio. +- **Search Grounding**: Explain the benefits of live data (opening hours, fresh events) provided by Google Search integration. +- **Clean Up**: Remove deployment instructions (move to `CONTRIBUTING.md`). + +#### 3.5.2 `CONTRIBUTING.md` + +- **Deployment**: Add the "Deployment" section moved from `README.md`, explaining how `npm run deploy` works for GitHub Pages. + +#### 3.5.3 `docs/ARCHITECTURE.md` + +- **AI Integration**: Update to reflect the shift from Hugging Face (Llama 3) to Gemini 1.5. +- **Grounding Architecture**: Document how Google Search grounding is integrated into the inference flow to ensure data freshness. +- **V2 Schema**: Document the `Trip` v2 schema and the migration path. + +## 4. Performance Optimization Strategy + +- Use `React.memo` on all major child components of `Home`. +- Since `Home` holds the `prompt` state, typing in the prompt triggers a re-render of `Home`. Memoization will ensure that children only re-render if their specific props change. + +## 5. Verification Plan + +### 5.1 Unit Tests + +- `migration.test.ts`: Verify v1 to v2 migration. +- `ai.test.ts`: Verify JSON parsing and structured data extraction. +- `GenerationForm.test.tsx`: Verify textarea rendering and change handling. +- `TripViewer.test.tsx`: Verify map URL to iframe rendering. + +### 5.2 Manual Verification + +- Type in the prompt and observe (via React DevTools or logging) that `TripViewer` does not re-render. +- Generate a trip and verify that the title is clean and start/stop points are stored. +- Verify that the map is correctly rendered from a URL provided by the AI. +- Verify that old trips are migrated correctly on load. diff --git a/src/app/page.tsx b/src/app/page.tsx index 2bef040..be7ce02 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; // Standardized Hook, Service, and Share Utilities import { useTrips } from '@/hooks/useTrips'; @@ -33,13 +33,13 @@ export default function Home() { typeof window !== 'undefined' ? localStorage.getItem('hf_api_key') || '' : '', ); - const handleApiKeyChange = (key: string) => { + const handleApiKeyChange = useCallback((key: string) => { const trimmedKey = key.trim(); setApiKey(trimmedKey); localStorage.setItem('hf_api_key', trimmedKey); - }; + }, []); - const handleGeneration = async () => { + const handleGeneration = useCallback(async () => { if (!apiKey) return setError('API Key required.'); if (!prompt) return setError('Destination description required.'); @@ -47,13 +47,16 @@ export default function Home() { setError(''); try { - const generatedMarkdown = await generateItinerary({ apiKey, prompt }); + const tripDetails = await generateItinerary({ apiKey, prompt }); addTrips([ { id: Date.now().toString(), - destination: prompt, - content: generatedMarkdown, + prompt: prompt, + title: tripDetails.title, + start: tripDetails.start, + stop: tripDetails.stop, + content: tripDetails.content, createdAt: new Date().toLocaleDateString(), }, ]); @@ -66,37 +69,49 @@ export default function Home() { } finally { setIsLoading(false); } - }; + }, [apiKey, prompt, addTrips]); - const handleUrlShare = () => { + const handleUrlShare = useCallback(() => { if (!activeTrip) return; const shareUrl = generateShareUrl(activeTrip); navigator.clipboard.writeText(shareUrl); alert('Compressed share link copied to clipboard!'); - }; + }, [activeTrip]); - const handleJsonExport = () => { + const handleJsonExport = useCallback(() => { const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(trips)); const downloadAnchor = document.createElement('a'); downloadAnchor.setAttribute('href', dataStr); downloadAnchor.setAttribute('download', `road-trips-${Date.now()}.json`); downloadAnchor.click(); - }; - - const handleJsonImport = (e: React.ChangeEvent) => { - const fileReader = new FileReader(); - if (e.target.files?.[0]) { - fileReader.readAsText(e.target.files[0], 'UTF-8'); - fileReader.onload = (event) => { - try { - const parsed = JSON.parse(event.target?.result as string); - if (Array.isArray(parsed)) addTrips(parsed); - } catch { - alert('Invalid JSON schema.'); - } - }; - } - }; + }, [trips]); + + const handleJsonImport = useCallback( + (e: React.ChangeEvent) => { + const fileReader = new FileReader(); + if (e.target.files?.[0]) { + fileReader.readAsText(e.target.files[0], 'UTF-8'); + fileReader.onload = (event) => { + try { + const parsed = JSON.parse(event.target?.result as string); + if (Array.isArray(parsed)) addTrips(parsed); + } catch { + alert('Invalid JSON schema.'); + } + }; + } + }, + [addTrips], + ); + + const handleNext = useCallback( + () => setActiveIndex(activeIndex + 1), + [activeIndex, setActiveIndex], + ); + const handlePrev = useCallback( + () => setActiveIndex(activeIndex - 1), + [activeIndex, setActiveIndex], + ); if (!isLoaded) { return ( @@ -132,8 +147,8 @@ export default function Home() { activeTrip={activeTrip} activeIndex={activeIndex} totalTrips={totalTrips} - onNext={() => setActiveIndex(activeIndex + 1)} - onPrev={() => setActiveIndex(activeIndex - 1)} + onNext={handleNext} + onPrev={handlePrev} /> diff --git a/src/components/GenerationForm.test.tsx b/src/components/GenerationForm.test.tsx index 6c476d5..885edd3 100644 --- a/src/components/GenerationForm.test.tsx +++ b/src/components/GenerationForm.test.tsx @@ -13,13 +13,14 @@ describe('GenerationForm', () => { isLoading={false} />, ); - const input = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); - expect(input).toBeInTheDocument(); - expect(input).toHaveValue('Initial prompt'); + const textarea = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); + expect(textarea).toBeInTheDocument(); + expect(textarea.tagName).toBe('TEXTAREA'); + expect(textarea).toHaveValue('Initial prompt'); expect(screen.queryByRole('status')).not.toBeInTheDocument(); // spinner check }); - it('calls onPromptChange when input changes', () => { + it('calls onPromptChange when textarea changes', () => { const handleChange = vi.fn(); render( { isLoading={false} />, ); - const input = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); - fireEvent.change(input, { target: { value: 'New prompt' } }); + const textarea = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); + fireEvent.change(textarea, { target: { value: 'New prompt' } }); expect(handleChange).toHaveBeenCalledWith('New prompt'); }); @@ -44,12 +45,12 @@ describe('GenerationForm', () => { isLoading={false} />, ); - const button = screen.getByRole('button'); + const button = screen.getByText(/Generate Itinerary/i); fireEvent.click(button); expect(handleGenerate).toHaveBeenCalled(); }); - it('shows loading state and disables input/button', () => { + it('shows loading state and disables textarea/button', () => { render( { isLoading={true} />, ); - const input = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); + const textarea = screen.getByPlaceholderText(/Ex: A 4-day hike itinerary/i); const button = screen.getByRole('button'); - expect(input).toBeDisabled(); + expect(textarea).toBeDisabled(); expect(button).toBeDisabled(); expect(button.querySelector('.loading-spinner')).toBeInTheDocument(); }); diff --git a/src/components/GenerationForm.tsx b/src/components/GenerationForm.tsx index 4f6c004..a243a5c 100644 --- a/src/components/GenerationForm.tsx +++ b/src/components/GenerationForm.tsx @@ -22,24 +22,25 @@ export const GenerationForm: React.FC = ({

Describe your next journey

-
- +