Skip to content

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Good Boy Dossier

Upload a photo of your dog. Get back a noir detective case file.

Good Boy Dossier hands your pet's photo to Google Gemini, which opens an official file on them: a codename, an invented case history, a deadpan read on their current expression, a confession to recent crimes, a bio in their own voice, and a legally meaningless Good Boy Score out of 100.

Then you download the whole thing as a shareable card.

2

Purpose of this project

Built for DEV's Weekend Challenge: "Dog Days".

The brief was dogs, and the interesting constraint in a weekend build isn't the model it's making the output feel personal. A generic AI dog caption is forgettable. A rubber-stamped government file that specifically calls out the mud on your dog's left paw is something people screenshot and send to their group chat.

So the whole app optimises for one moment: the download button producing an image worth posting.


How Gemini is used

One multimodal call to gemini-3.7-flash, made server-side in app/api/analyze/route.ts via the @google/generative-ai SDK. The prompt logic lives in lib/gemini.ts.

The image goes up as inlineData (base64) alongside a text prompt, and Gemini returns the seven dossier fields as JSON. Typical latency is 7–18 seconds, which is why the loading state is something worth reading.

Getting reliable output took more than asking nicely:

  • Structured output. The call sets responseMimeType: 'application/json' plus a responseSchema naming all seven fields, so the model is constrained at the API level rather than merely instructed.
  • Evidence discipline in the prompt. The system instruction requires the model to anchor its writing in at least two things it can actually see coat texture, ear carriage, a collar, a toy, the couch. This is the single biggest difference between "cute generic caption" and "how did it know". On a test photo of a border collie it independently picked up the red bandana, both frisbees in frame, and the sideways tongue loll, then docked 22 points "for refusing to surrender the white disc".
  • A model fallback chain. GEMINI_MODELS is a list, not a string. Google retires hosted models with little notice gemini-2.0-flash and gemini-2.5-flash both now answer "no longer available" so the first entry is pinned for reproducibility and gemini-flash-latest backs it up as an alias that can't go stale.
  • Failure-aware retries, bounded to 3 calls. The next step is chosen from the kind of failure rather than retried blindly: malformed or truncated JSON retries the same model with a blunter prompt and a cooler temperature; a retired or overloaded model falls through to the next model; a safety block stops immediately, because that's a verdict on the photo. Backoff between attempts, since an instant retry just hits the same demand spike.
  • A generous output budget. maxOutputTokens is 4096 for a ~400-token answer. Gemini 3.x reasons before responding and those thinking tokens come out of the same budget at 1024 the model ran out mid-string and returned truncated, unparseable JSON.
  • Graceful degradation on bad JSON. extractJson() tries the raw response, then strips ```json fences, then falls back to the outermost {...} pair to survive a chatty preamble, then repairs trailing commas.
  • A runtime type guard. isDossier() validates the shape before the object is allowed anywhere near React, so a malformed field can't blank the UI.
  • Output sanitising. Strings are trimmed and length-capped on word boundaries and the score is clamped to 1–100, so an over-enthusiastic response can't break the card layout or the exported PNG.
  • Relaxed safety thresholds. A family dog on a sofa can trip a conservative filter and cost the entire response, so the four standard harm categories are set to BLOCK_ONLY_HIGH. Prompt-level content limits keep it PG.
  • Non-dogs play along. Cats, rabbits, and lizards get investigated identically. A houseplant or a sandwich still gets a file the prompt frames the absurdity as the Bureau's bureaucratic problem rather than breaking character to point out you didn't upload a dog. (Fed a synthetic grey circle, it opened a file on "The Ambient Void" and gave it a 42 for "total geometric defiance".)

Tech stack

Framework Next.js 14 (App Router), TypeScript (strict)
Styling Tailwind CSS 3
AI Google Gemini gemini-3.7-flash via @google/generative-ai
Image export html-to-image
Fonts Playfair Display, Courier Prime, Special Elite (self-hosted via next/font)
Storage None. Everything is client-session state
Deploy Vercel-ready

Getting started

1. Get a Gemini API key

Go to aistudio.google.com/apikey, sign in with a Google account, and click Create API key. The free tier is more than enough to run this.

2. Add it to .env.local

cp .env.local.example .env.local

Then open .env.local and paste your key in:

GEMINI_API_KEY=AIza...your_actual_key

The variable is deliberately not prefixed with NEXT_PUBLIC_, so it stays server-side and is never bundled into the browser. .env.local is gitignored.

3. Install and run

npm install
npm run dev

Open http://localhost:3000 and upload a dog.

If you started the dev server before creating .env.local, restart it Next only reads env files at boot. The app will tell you so in plain English rather than failing silently.

Other scripts

npm run build      # production build
npm start          # serve the production build
npm run lint       # eslint
npm run typecheck  # tsc --noEmit

Deploying to Vercel

  1. Push this repo to GitHub.
  2. Import Project in Vercel the Next.js preset is detected automatically.
  3. Add an environment variable GEMINI_API_KEY with your key, for all environments.
  4. Deploy.

No other configuration is needed. The API route is already pinned to the Node runtime with maxDuration = 60 to give a slow vision call room to finish.


How it works

app/
  api/analyze/route.ts   POST base64 image -> validate -> Gemini -> typed JSON
  layout.tsx             fonts, metadata, full-page grain + vignette
  page.tsx               static shell
  error.tsx              in-character error boundary
components/
  DossierApp.tsx         all client state; intake -> running -> filed
  UploadPanel.tsx        drag-drop, file picker, paste, preview
  RunningTheFile.tsx     typewriter status terminal
  DossierCard.tsx        the case file, in screen and export variants
  ResultView.tsx         actions + the off-screen export node
lib/
  gemini.ts              prompt, schema, JSON recovery, retry
  image.ts               decode, EXIF-correct, downscale (client-side)
  download.ts            PNG render, filename, download, native share
  score.ts               score bands and section order
  types.ts               shared contract + runtime guard

Three details worth calling out

The upload is downscaled in the browser first. A modern phone photo is 4–12MB, which is past Vercel's serverless request body limit. lib/image.ts decodes with createImageBitmap(file, { imageOrientation: 'from-image' }) so EXIF rotation is respected, resizes to 1280px on the long edge, and re-encodes as JPEG. Payloads land around 30–300KB, and the same data URL is reused for the preview and the export so nothing is ever fetched cross-origin.

The downloaded PNG is rendered from a separate fixed-width node. Tailwind breakpoints read the viewport, not the element so capturing the live responsive card would hand phone users a narrow mobile-shaped PNG. Instead DossierCard has an export variant at a fixed 1000px with no responsive utilities, mounted off-screen and captured at 1.5x. A phone and a desktop produce a byte-identical 1500px-wide image. (The capture ref points at an inner statically-positioned div, because html-to-image copies the captured node's own computed style onto its clone ref'ing the left:-20000px wrapper would push the content out of the exported viewport and yield a blank PNG.)

The export texture is noise-free, and the scale is capped, both for file size. X rejects PNGs over 5MB precisely where these are meant to be posted. The screen UI uses real film grain, but per-pixel noise is close to incompressible: at 2x it accounted for 4.7MB of a 6.8MB export. Swapping in smooth large-scale gradient mottling for the export brought that down a lot, but real dossiers vary in height with how much Gemini writes, and a detailed photo plus Chrome's gradient dithering still landed finished files at 4.8–5.2MB straddling the limit. Capturing at 1.5x instead of 2x settles it: ~1.9–2.2MB for any dossier length, with the 15px body copy still rendering at an effective 22px, comfortably crisp past Instagram's 1080px display width.


Notes

  • Your photo isn't stored. There's no database and no filesystem writes. The image lives in React state and is sent to Google's Gemini API for the duration of one request. It's gone when you reload.
  • It's fiction. Every dossier is invented. The model is told not to guess breed, age, sex, or anything health-related, and to refer to any humans in frame only as "the handler".
  • Accessibility. The typewriter loader announces once via a polite live region instead of narrating every keystroke; all animation respects prefers-reduced-motion; the export node is aria-hidden.
  • Security advisories. npm audit reports advisories against Next.js 14 that are only patched in 15.5.21+. None reach this app: there are no Server Actions, no custom server, no rewrites, and the image optimizer is disabled with no remotePatterns. If you'd rather clear them, npm i next@latest eslint-config-next@latest the code needs no changes for Next 15.

Built with Google Gemini for DEV's Weekend Challenge — Dog Days. No animals were interrogated.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages