From b9df05f8a509aa30c95edf6845e9b2f074317b72 Mon Sep 17 00:00:00 2001
From: Anurag Verma <78868769+anurag629@users.noreply.github.com>
Date: Sat, 28 Feb 2026 12:19:07 +0530
Subject: [PATCH 1/3] =?UTF-8?q?release:=20v1.1.0=20=E2=80=94=20Mobile=20UI?=
=?UTF-8?q?/UX=20improvements=20(#14)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* chore: rename main branch references to production (#10)
Update CI workflows, README, CONTRIBUTING, and SECURITY to reflect
the branch rename from main to production.
* Add Feedback link to footer (#11)
* fix: comprehensive mobile UI/UX improvements (#13)
* fix: improve mobile UI/UX across editor, navbar, API docs, and preview pages
- Redesign API docs page with mobile-first layout: div-based params
instead of tables, horizontal sticky tab nav, contained code blocks
- Fix hamburger menu broken after View Transitions by removing
transition:persist from Header and moving mobile menu inside
- Improve editor mobile layout: single scroll column, proper touch
targets, auto-switch to customize tab on template select
- Add safe-area and viewport-fit handling for notched devices
- Improve preview page mobile: stacked URL input, larger buttons
- Adjust responsive nav-height and global spacing for small screens
Made-with: Cursor
* fix: improve create page customize & export tabs for mobile devices
- Fix editor-right panel not stretching full width on mobile by adding
flex-direction: column (mobile-show forced display:flex without it)
- Enlarge touch targets for toggles, sliders, color swatches, and buttons
- Make customize header sticky with backdrop blur on mobile
- Add icons to export copy buttons for better visual clarity
- Increase export button sizes with proper tap feedback (scale on active)
- Add safe-area-inset-bottom padding for notched phones
- Full-width upload buttons and select dropdowns on small screens
- Responsive breakpoints for 768px, 480px, and 380px (very narrow)
Made-with: Cursor
* docs: update CLAUDE.md with enhanced guidance and template system details
- Added introductory guidance for using Claude Code.
- Updated project description to include deployment URL and GitHub link.
- Expanded commands section with additional commands for testing and linting.
- Revised architecture section to reflect the current state of templates and API.
- Introduced a new section for pages and API endpoints with detailed descriptions.
- Enhanced template contribution guidelines and conventions for clarity.
- Added CI/CD process overview and environment variable details.
---------
Co-authored-by: Prathviraj Singh
---
.github/workflows/ci.yml | 4 +-
.github/workflows/pr-target-check.yml | 8 +-
CLAUDE.md | 80 +++-
CONTRIBUTING.md | 2 +-
README.md | 12 +-
SECURITY.md | 2 +-
src/components/Footer.astro | 1 +
src/components/Header.astro | 263 ++++++++++---
src/components/editor/EditorApp.tsx | 27 +-
src/components/editor/ExportBar.tsx | 4 +-
src/layouts/Layout.astro | 4 +-
src/layouts/ToolLayout.astro | 9 +-
src/pages/api-docs.astro | 268 +++++++------
src/styles/api-docs.css | 480 ++++++++++++++---------
src/styles/editor.css | 528 ++++++++++++++++++++++++--
src/styles/global.css | 3 +-
src/styles/preview.css | 92 ++++-
17 files changed, 1338 insertions(+), 449 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 17c905d..0d13bc1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,9 +2,9 @@ name: CI
on:
push:
- branches: [main, dev]
+ branches: [production, dev]
pull_request:
- branches: [main, dev]
+ branches: [production, dev]
jobs:
build:
diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml
index 3ef2504..7dde21a 100644
--- a/.github/workflows/pr-target-check.yml
+++ b/.github/workflows/pr-target-check.yml
@@ -2,16 +2,16 @@ name: PR Target Check
on:
pull_request:
- branches: [main]
+ branches: [production]
jobs:
check-source-branch:
runs-on: ubuntu-latest
steps:
- - name: Only allow PRs from dev to main
+ - name: Only allow PRs from dev to production
if: github.head_ref != 'dev'
run: |
- echo "::error::PRs targeting 'main' are only allowed from the 'dev' branch."
+ echo "::error::PRs targeting 'production' are only allowed from the 'dev' branch."
echo "Please target 'dev' instead, or merge your branch into 'dev' first."
echo ""
echo " Source: ${{ github.head_ref }}"
@@ -19,4 +19,4 @@ jobs:
exit 1
- name: PR source branch is valid
if: github.head_ref == 'dev'
- run: echo "PR from 'dev' to 'main' — allowed."
+ run: echo "PR from 'dev' to 'production' — allowed."
diff --git a/CLAUDE.md b/CLAUDE.md
index b14de89..03c7366 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,16 +1,21 @@
# CLAUDE.md — OGCOPS
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
## What is this?
-OGCOPS is a free, open-source OG image generator and social media preview checker. Built with Astro + React Islands, deployed to Vercel.
+OGCOPS is a free, open-source OG image generator and social media preview checker. Built with Astro SSR + React Islands, deployed to Vercel at og.codercops.com. GitHub: github.com/codercops/ogcops. MIT licensed.
## Commands
```bash
-npm run dev # Start dev server
-npm run build # Production build
-npm run preview # Preview production build
-npm run test # Run vitest
-npm run test:watch # Watch mode
-npm run check # Type-check (astro check + tsc)
+npm run dev # Start dev server (port 4321)
+npm run build # Production build
+npm run preview # Preview production build
+npm run test # Run vitest (single run)
+npm run test:watch # Watch mode
+npm run test:ui # Vitest UI
+npm run check # Type-check (astro check + tsc --noEmit)
+npm run lint # Astro linting
+npm run generate:favicons # Generate favicon assets
```
## Architecture
@@ -19,20 +24,32 @@ npm run check # Type-check (astro check + tsc)
- **Satori** runs client-side for instant SVG preview (zero server calls during editing)
- **Satori + resvg-wasm** runs server-side for PNG generation (`/api/og`)
- **No database** — state lives in URL query params + client-side useReducer
-- **CORS-open API** for developer use
+- **CORS-open API** — no API key, no rate limits
## Key Directories
-- `src/templates/` — 109 templates across 12 categories. Each template is a `.ts` file exporting a `TemplateDefinition`.
-- `src/lib/` — Core engine (og-engine.ts, font-loader.ts, meta-fetcher.ts, meta-analyzer.ts)
+- `src/templates/` — 120 templates across 12 categories. Each is a `.ts` file exporting a `TemplateDefinition`.
+- `src/lib/` — Core engine (og-engine.ts, font-loader.ts, meta-fetcher.ts, meta-analyzer.ts, api-validation.ts)
- `src/components/editor/` — React components for the OG image editor
- `src/components/preview/` — React components for the social media preview checker
-- `src/pages/api/` — API endpoints (og, preview, templates)
+- `src/pages/api/` — API endpoints
+- `src/styles/` — CSS files (global.css, editor.css, preview.css, api-docs.css)
+- `public/fonts/` — Bundled .woff fonts (Inter, Playfair Display, JetBrains Mono)
+- `tests/` — Vitest tests (api/, lib/, templates/)
-## Conventions
-- CSS custom properties only (no Tailwind). Accent: `#E07A5F`.
-- TypeScript strict mode. Path alias `@/*` → `src/*`.
-- Fonts bundled as `.woff` in `public/fonts/`.
-- Templates follow `TemplateDefinition` interface in `src/templates/types.ts`.
+## Pages & API Endpoints
+
+**Pages:**
+- `/` — Homepage
+- `/create/` — OG image editor
+- `/templates` — Template gallery
+- `/preview` — Social media preview checker
+- `/api-docs` — API documentation
+
+**API (all CORS-open, no auth):**
+- `GET /api/og?template={id}&...` — Generate PNG (1200x630, 24h cache)
+- `GET /api/preview?url={url}` — Fetch and analyze a URL's meta tags
+- `GET /api/templates` — List all templates as JSON
+- `GET /api/templates/[id]/thumbnail.png` — Template thumbnail (1-week cache)
## Reference Files
- Template interface: `src/templates/types.ts`
@@ -42,12 +59,34 @@ npm run check # Type-check (astro check + tsc)
- API validation schemas: `src/lib/api-validation.ts`
- Template registry: `src/templates/registry.ts`
-## Template Contribution
-1. Create `src/templates/{category}/{id}.ts`
-2. Export a `TemplateDefinition`
-3. Register in `src/templates/{category}/index.ts`
+## Template System
+
+120 templates across 12 categories: blog, product, saas, github, event, podcast, developer, newsletter, quote, ecommerce, job, tutorial.
+
+**Adding a template:**
+1. Create `src/templates/{category}/{id}.ts` exporting a `TemplateDefinition`
+2. Register in `src/templates/{category}/index.ts`
+3. Add import + registration in `src/templates/registry.ts`
4. Run `npm run test` to verify
+**Template field types:** text, textarea, color, select, number, toggle, image.
+**Field groups:** Content, Style, Brand.
+
+## Conventions
+- CSS custom properties only (no Tailwind). Accent: `#E07A5F`.
+- TypeScript strict mode. Path alias `@/*` → `src/*`.
+- Fonts bundled as `.woff` in `public/fonts/` (Inter Regular/Medium/SemiBold/Bold, Playfair Display Regular/Bold, JetBrains Mono Regular/Bold).
+- Node 22 (`.nvmrc`).
+- Testing: Vitest with node environment. Tests in `tests/**/*.test.ts`. Globals enabled.
+
+## CI/CD
+- Runs on push to `production`/`dev` and PRs to those branches
+- Steps: `npm run check` → `npm run test` → `npm run build`
+- **Branch strategy:** `dev` is the default branch. PRs target `dev`. Releases go `dev` → `production`. Direct PRs to `production` are blocked unless from `dev`.
+
+## Environment Variables (`.env.local`, all optional)
+- `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` — Optional visitor counter
+
## Gotchas / Constraints
- Satori does **not** support CSS grid — only flexbox
- Every `div` must have `display: 'flex'` explicitly in its style
@@ -55,6 +94,7 @@ npm run check # Type-check (astro check + tsc)
- Font files must be listed in `astro.config.mjs` `includeFiles` array for Vercel deployment
- WASM imports need `optimizeDeps.exclude` in the Vite config
- `renderToPng` returns `ArrayBuffer` (not `Buffer`) for `BodyInit` compatibility
+- Canvas is always 1200x630px
## Do NOT
- Add Tailwind CSS — the project uses CSS custom properties only
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8b228e9..e912fb4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -113,7 +113,7 @@ npm run build # Full production build
1. Fork the repo and create a branch: `git checkout -b feat/my-feature`
2. Make your changes
3. Ensure `npm run check` and `npm run test` pass
-4. Push and open a PR against `main`
+4. Push and open a PR against `dev`
5. Fill out the [PR template](.github/PULL_REQUEST_TEMPLATE.md) — screenshots are required for visual changes
6. Wait for review
diff --git a/README.md b/README.md
index 4ca02ab..19b6b1d 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ OGCOPS is different:
## Quick Start
```bash
-git clone https://github.com/codercops/ogcops.git
+git clone -b dev https://github.com/codercops/ogcops.git
cd ogcops
npm install
npm run dev
@@ -114,6 +114,16 @@ The build output in `dist/` can be deployed to any Node.js hosting platform.
Contributions are welcome — templates, bug fixes, features, docs, and more. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup and guidelines.
+> **Important:** Always fork and branch from `dev` (the default branch). The `production` branch is for releases only. PRs targeting `production` directly will be closed.
+
+```bash
+# Fork the repo on GitHub, then:
+git clone https://github.com//ogcops.git
+cd ogcops
+git checkout dev
+git checkout -b your-feature-branch
+```
+
- [Open an issue](https://github.com/codercops/ogcops/issues) — bug reports and feature requests
- [Start a discussion](https://github.com/codercops/ogcops/discussions) — questions, ideas, show & tell
diff --git a/SECURITY.md b/SECURITY.md
index 64fdfe5..a12dbb6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -35,7 +35,7 @@ The following are **out of scope:**
| Version | Supported |
|---------|-----------|
-| Latest (main branch) | Yes |
+| Latest (production branch) | Yes |
| Older releases | No |
## Recognition
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
index b11e817..805026b 100644
--- a/src/components/Footer.astro
+++ b/src/components/Footer.astro
@@ -20,6 +20,7 @@ const year = new Date().getFullYear();
Templates
API Docs
GitHub
+ Feedback
diff --git a/src/components/Header.astro b/src/components/Header.astro
index 086b61a..7ae7f71 100644
--- a/src/components/Header.astro
+++ b/src/components/Header.astro
@@ -36,41 +36,82 @@ const navItems = [
-
-
-
- {/* Right: Customize */}
+ {/* Right: Customize / Export */}
{mobileTab === 'export' ? (
) : (
copyToClipboard(apiUrl, 'url')}
>
- {copied === 'url' ? 'Copied!' : 'Copy URL'}
+
+ {copied === 'url' ? 'Copied!' : 'Copy Image URL'}
copyToClipboard(metaTags, 'meta')}
>
+
{copied === 'meta' ? 'Copied!' : 'Copy Meta Tags'}
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index 0cb1229..1d0ce60 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -31,7 +31,7 @@ const fullTitle = title === 'OGCOPS' ? title : `${title} | ${siteName}`;
-
+
{noindex && }
@@ -106,7 +106,7 @@ const fullTitle = title === 'OGCOPS' ? title : `${title} | ${siteName}`;
-
+
diff --git a/src/layouts/ToolLayout.astro b/src/layouts/ToolLayout.astro
index 1c21182..37acc6d 100644
--- a/src/layouts/ToolLayout.astro
+++ b/src/layouts/ToolLayout.astro
@@ -19,7 +19,7 @@ const fullTitle = `${title} | ${siteName}`;
-
+
@@ -61,7 +61,14 @@ const fullTitle = `${title} | ${siteName}`;
body {
display: flex;
flex-direction: column;
+ min-height: 100vh;
+ min-height: 100dvh;
height: 100vh;
+ height: 100dvh;
overflow: hidden;
+ padding-top: env(safe-area-inset-top);
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
+ padding-bottom: env(safe-area-inset-bottom);
}
diff --git a/src/pages/api-docs.astro b/src/pages/api-docs.astro
index 9c56591..591db5b 100644
--- a/src/pages/api-docs.astro
+++ b/src/pages/api-docs.astro
@@ -5,24 +5,29 @@ import '@/styles/api-docs.css';
-
+
+
+
API Documentation
+
+ Generate OG images, check URL meta tags, and list templates with our free REST API.
+ No API key required. CORS-enabled for browser use.
+
+
+
+
+
+ {/* AI Settings Modal */}
+
);
}
diff --git a/src/components/editor/TemplatePanel.tsx b/src/components/editor/TemplatePanel.tsx
index d483ef8..c027cb0 100644
--- a/src/components/editor/TemplatePanel.tsx
+++ b/src/components/editor/TemplatePanel.tsx
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import type { TemplateDefinition, TemplateCategory } from '@/templates/types';
import { CATEGORY_META, ALL_CATEGORIES } from '@/templates/types';
import { TemplateThumbnail } from './TemplateThumbnail';
+import { AITemplateSearch } from './AITemplateSearch';
interface TemplatePanelProps {
templates: TemplateDefinition[];
@@ -49,6 +50,7 @@ export function TemplatePanel({
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
+
-
- {label}
- {required && * }
-
+
+
+ {label}
+ {required && * }
+
+ {action}
+
{multiline ? (
+
+
+
+
+
+ Proxy text generation through your own AI provider API key (BYOK). Supports OpenAI, Anthropic, Google, Groq, and OpenRouter.
+ Your API key is passed through to the provider and never stored.
+
+
+
Request Body (JSON)
+
+
+
+ provider
+ string
+ required
+
+
AI provider: openai, anthropic, google, groq, or openrouter
+
+
+
+ apiKey
+ string
+ required
+
+
Your API key for the selected provider
+
+
+
+ model
+ string
+ required
+
+
Model ID (e.g. gpt-4o-mini, claude-sonnet-4-20250514, gemini-2.0-flash)
+
+
+
+ prompt
+ string
+ required
+
+
User prompt (max 10,000 characters)
+
+
+
+ systemPrompt
+ string
+
+
Optional system prompt (max 5,000 characters)
+
+
+
+ maxTokens
+ number
+
+
Max response tokens, 1–4096 (default: provider default)
+
+
+
+ temperature
+ number
+
+
Sampling temperature, 0–2 (default: provider default)
+
+
+
+
Response
+
+
+
{`{ "content": "Generated text from the AI model" }`}
+
+
+
Example
+
+
+
{`const res = await fetch('https://og.codercops.com/api/ai/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ provider: 'openai',
+ apiKey: 'sk-your-key',
+ model: 'gpt-4o-mini',
+ prompt: 'Generate 3 catchy titles for a blog post about React',
+ maxTokens: 200,
+ temperature: 0.7,
+ }),
+});
+
+const { content } = await res.json();`}
+
+
+
Error Codes
+
+
+
400
+
Invalid request body (missing fields or out-of-range values)
+
+
+
401
+
Invalid API key or unauthorized
+
+
+
429
+
Rate limited by the provider
+
+
+
502
+
Provider returned an error
+
+
+
+
+
+
+
+
Validate an AI provider API key by making a minimal test request. Returns whether the key is valid.
+
+
Request Body (JSON)
+
+
+
+ provider
+ string
+ required
+
+
AI provider: openai, anthropic, google, groq, or openrouter
+
+
+
+ apiKey
+ string
+ required
+
+
API key to validate
+
+
+
+
Response
+
+
+
{`{ "valid": true }
+// or
+{ "valid": false, "error": "Invalid API key" }`}
+
+
+
+
+
+
+
+ Analyze a URL's content and auto-generate template field values using AI.
+ Fetches the page, extracts content, then uses your AI key to suggest a template, fill fields, and pick colors.
+
+
+
Request Body (JSON)
+
+
+
+ url
+ string
+ required
+
+
URL to analyze
+
+
+
+ provider
+ string
+ required
+
+
AI provider
+
+
+
+ apiKey
+ string
+ required
+
+
Your API key
+
+
+
+ model
+ string
+ required
+
+
Model ID
+
+
+
+
Response
+
+
+
{`{
+ "templateId": "blog-minimal-dark",
+ "fields": {
+ "title": "Generated Title",
+ "description": "Generated description",
+ "author": "Author Name"
+ },
+ "colors": {
+ "bgColor": "#1a1a2e",
+ "accentColor": "#e07a5f"
+ }
+}`}
+
+
diff --git a/src/pages/api/ai/autofill.ts b/src/pages/api/ai/autofill.ts
new file mode 100644
index 0000000..b0d95c8
--- /dev/null
+++ b/src/pages/api/ai/autofill.ts
@@ -0,0 +1,144 @@
+import type { APIRoute } from 'astro';
+import { z } from 'zod';
+import { getProvider } from '@/lib/ai/providers';
+import { fetchMetaTags } from '@/lib/meta-fetcher';
+import { buildAutofillPrompt, parseAutofillResponse } from '@/lib/ai/autofill';
+import { getTemplate } from '@/templates/registry';
+
+const CORS_HEADERS = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ 'Content-Type': 'application/json',
+};
+
+const autofillSchema = z.object({
+ provider: z.enum(['openai', 'anthropic', 'google', 'groq', 'openrouter']),
+ apiKey: z.string().min(1),
+ model: z.string().min(1),
+ url: z.string().url('A valid URL is required'),
+});
+
+export const POST: APIRoute = async ({ request }) => {
+ if (request.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return new Response(
+ JSON.stringify({ error: 'Invalid JSON body' }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ const parsed = autofillSchema.safeParse(body);
+ if (!parsed.success) {
+ const errors = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
+ return new Response(
+ JSON.stringify({ error: errors }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ const { provider: providerId, apiKey, model, url } = parsed.data;
+ const provider = getProvider(providerId);
+ if (!provider) {
+ return new Response(
+ JSON.stringify({ error: `Unknown provider: ${providerId}` }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ try {
+ // Step 1: Fetch the URL and extract content
+ let pageContent: {
+ title?: string;
+ description?: string;
+ headings?: string[];
+ bodyText?: string;
+ ogTags?: Record;
+ themeColor?: string;
+ };
+
+ try {
+ const meta = await fetchMetaTags(url);
+ pageContent = {
+ title: meta.ogTitle || meta.title,
+ description: meta.ogDescription || meta.description,
+ ogTags: meta as Record,
+ themeColor: meta.themeColor,
+ };
+ } catch {
+ return new Response(
+ JSON.stringify({ error: 'Could not fetch the URL' }),
+ { status: 502, headers: CORS_HEADERS }
+ );
+ }
+
+ // Step 2: Build prompt and call AI
+ const { prompt, systemPrompt } = buildAutofillPrompt(pageContent);
+ const { url: aiUrl, body: aiBody } = provider.bodyFormatter({
+ model,
+ prompt,
+ systemPrompt,
+ maxTokens: 500,
+ temperature: 0.5,
+ });
+
+ const fetchUrl = providerId === 'google' ? `${aiUrl}?key=${apiKey}` : aiUrl;
+ const headers = provider.headerBuilder(apiKey);
+
+ const aiResponse = await fetch(fetchUrl, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(aiBody),
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!aiResponse.ok) {
+ if (aiResponse.status === 401 || aiResponse.status === 403) {
+ return new Response(
+ JSON.stringify({ error: 'Invalid API key' }),
+ { status: 401, headers: CORS_HEADERS }
+ );
+ }
+ return new Response(
+ JSON.stringify({ error: `AI provider error (${aiResponse.status})` }),
+ { status: 502, headers: CORS_HEADERS }
+ );
+ }
+
+ const aiData = await aiResponse.json();
+ const aiResult = provider.responseParser(aiData);
+
+ // Step 3: Parse the autofill result
+ const autofill = parseAutofillResponse(aiResult.content);
+ if (!autofill) {
+ return new Response(
+ JSON.stringify({ error: 'Could not parse AI response' }),
+ { status: 502, headers: CORS_HEADERS }
+ );
+ }
+
+ // Validate template exists
+ const template = getTemplate(autofill.templateId);
+ if (!template) {
+ // Fallback to first template in the suggested category
+ autofill.templateId = 'blog-minimal-dark';
+ }
+
+ return new Response(JSON.stringify(autofill), {
+ status: 200,
+ headers: CORS_HEADERS,
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Autofill failed';
+ return new Response(
+ JSON.stringify({ error: message }),
+ { status: 502, headers: CORS_HEADERS }
+ );
+ }
+};
diff --git a/src/pages/api/ai/generate.ts b/src/pages/api/ai/generate.ts
new file mode 100644
index 0000000..5664d59
--- /dev/null
+++ b/src/pages/api/ai/generate.ts
@@ -0,0 +1,106 @@
+import type { APIRoute } from 'astro';
+import { aiGenerateSchema } from '@/lib/ai/validation';
+import { getProvider } from '@/lib/ai/providers';
+
+const CORS_HEADERS = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ 'Content-Type': 'application/json',
+};
+
+export const POST: APIRoute = async ({ request }) => {
+ if (request.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return new Response(
+ JSON.stringify({ error: 'Invalid JSON body' }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ const parsed = aiGenerateSchema.safeParse(body);
+ if (!parsed.success) {
+ const errors = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
+ return new Response(
+ JSON.stringify({ error: errors }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ const { provider: providerId, apiKey, model, prompt, systemPrompt, maxTokens, temperature } = parsed.data;
+ const provider = getProvider(providerId);
+ if (!provider) {
+ return new Response(
+ JSON.stringify({ error: `Unknown provider: ${providerId}` }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ try {
+ const { url, body: requestBody } = provider.bodyFormatter({
+ model,
+ prompt,
+ systemPrompt,
+ maxTokens,
+ temperature,
+ });
+
+ // Google passes API key as query param
+ const fetchUrl = providerId === 'google' ? `${url}?key=${apiKey}` : url;
+ const headers = provider.headerBuilder(apiKey);
+
+ const response = await fetch(fetchUrl, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(requestBody),
+ signal: AbortSignal.timeout(30000),
+ });
+
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) {
+ return new Response(
+ JSON.stringify({ error: 'Invalid API key' }),
+ { status: 401, headers: CORS_HEADERS }
+ );
+ }
+ if (response.status === 429) {
+ return new Response(
+ JSON.stringify({ error: 'Rate limited by provider' }),
+ { status: 429, headers: CORS_HEADERS }
+ );
+ }
+
+ let errorMessage = `Provider error (${response.status})`;
+ try {
+ const errBody = await response.json();
+ errorMessage = errBody.error?.message ?? errBody.error ?? errorMessage;
+ } catch {
+ // use default error message
+ }
+ return new Response(
+ JSON.stringify({ error: errorMessage }),
+ { status: 502, headers: CORS_HEADERS }
+ );
+ }
+
+ const data = await response.json();
+ const result = provider.responseParser(data);
+
+ return new Response(JSON.stringify(result), {
+ status: 200,
+ headers: CORS_HEADERS,
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Generation failed';
+ return new Response(
+ JSON.stringify({ error: message }),
+ { status: 502, headers: CORS_HEADERS }
+ );
+ }
+};
diff --git a/src/pages/api/ai/validate.ts b/src/pages/api/ai/validate.ts
new file mode 100644
index 0000000..a30ba04
--- /dev/null
+++ b/src/pages/api/ai/validate.ts
@@ -0,0 +1,92 @@
+import type { APIRoute } from 'astro';
+import { aiValidateSchema } from '@/lib/ai/validation';
+import { getProvider } from '@/lib/ai/providers';
+
+const CORS_HEADERS = {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ 'Content-Type': 'application/json',
+};
+
+export const POST: APIRoute = async ({ request }) => {
+ if (request.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: CORS_HEADERS });
+ }
+
+ let body: unknown;
+ try {
+ body = await request.json();
+ } catch {
+ return new Response(
+ JSON.stringify({ valid: false, error: 'Invalid JSON body' }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ const parsed = aiValidateSchema.safeParse(body);
+ if (!parsed.success) {
+ const errors = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
+ return new Response(
+ JSON.stringify({ valid: false, error: errors }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ const { provider: providerId, apiKey } = parsed.data;
+ const provider = getProvider(providerId);
+ if (!provider) {
+ return new Response(
+ JSON.stringify({ valid: false, error: `Unknown provider: ${providerId}` }),
+ { status: 400, headers: CORS_HEADERS }
+ );
+ }
+
+ try {
+ const validateReq = provider.validateRequest(apiKey);
+
+ // For Anthropic, validation needs a minimal message body
+ const fetchOptions: RequestInit = {
+ method: validateReq.method,
+ headers: validateReq.headers,
+ signal: AbortSignal.timeout(10000),
+ };
+
+ if (providerId === 'anthropic') {
+ fetchOptions.method = 'POST';
+ fetchOptions.body = JSON.stringify({
+ model: provider.models[0].id,
+ max_tokens: 1,
+ messages: [{ role: 'user', content: 'hi' }],
+ });
+ }
+
+ const response = await fetch(validateReq.url, fetchOptions);
+
+ if (response.ok) {
+ return new Response(
+ JSON.stringify({ valid: true }),
+ { status: 200, headers: CORS_HEADERS }
+ );
+ }
+
+ // 401/403 = invalid key, other errors = provider issue
+ if (response.status === 401 || response.status === 403) {
+ return new Response(
+ JSON.stringify({ valid: false, error: 'Invalid API key' }),
+ { status: 200, headers: CORS_HEADERS }
+ );
+ }
+
+ return new Response(
+ JSON.stringify({ valid: false, error: `Provider returned ${response.status}` }),
+ { status: 200, headers: CORS_HEADERS }
+ );
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Validation failed';
+ return new Response(
+ JSON.stringify({ valid: false, error: message }),
+ { status: 502, headers: CORS_HEADERS }
+ );
+ }
+};
diff --git a/src/pages/create/[category].astro b/src/pages/create/[category].astro
index 323605d..8836803 100644
--- a/src/pages/create/[category].astro
+++ b/src/pages/create/[category].astro
@@ -4,6 +4,7 @@ import { EditorApp } from '@/components/editor/EditorApp';
import { ALL_CATEGORIES, CATEGORY_META } from '@/templates/types';
import type { TemplateCategory } from '@/templates/types';
import '@/styles/editor.css';
+import '@/styles/ai.css';
const { category } = Astro.params;
diff --git a/src/pages/create/index.astro b/src/pages/create/index.astro
index d92ee0a..431a336 100644
--- a/src/pages/create/index.astro
+++ b/src/pages/create/index.astro
@@ -2,6 +2,7 @@
import ToolLayout from '@/layouts/ToolLayout.astro';
import { EditorApp } from '@/components/editor/EditorApp';
import '@/styles/editor.css';
+import '@/styles/ai.css';
---
diff --git a/src/pages/index.astro b/src/pages/index.astro
index f309484..051919a 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -37,39 +37,52 @@ const categoryCounts = getCategoryCounts();
-
+
-
-
- BLOG
- How to Build a REST API
-
+
- dev.to · 5 min read
+ blog-minimal-dark
+ Blog
-
+
-
- LAUNCH
- Introducing OGCOPS 2.0
- Free · Open Source · No Login
-
+
- og.codercops.com
+ product-gradient-wave
+ Product
-
+
-
- PORTFOLIO
- Sarah Chen Software Engineer
-
+
- github.com/sarahchen
+ event-hackathon
+ Event
@@ -398,12 +411,14 @@ const categoryCounts = getCategoryCounts();
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-lg);
- transition: transform var(--transition-slow), box-shadow var(--transition-slow);
- border: 1px solid var(--border);
+ transition: transform var(--transition-slow), box-shadow var(--transition-slow), border-color var(--transition-slow);
+ border: 1.5px solid var(--border);
+ background: var(--bg-elevated);
}
.og-card:hover {
box-shadow: var(--shadow-xl);
+ border-color: var(--accent-primary);
}
.og-card-1 {
@@ -430,85 +445,14 @@ const categoryCounts = getCategoryCounts();
}
.og-card-3:hover { transform: rotate(-1deg) translateY(-4px); }
- /* OG card image area (1200:630 ratio ~ 1.9:1) */
- .og-card-img {
- position: relative;
+ /* OG card — real template thumbnail */
+ .og-card-img-real {
+ display: block;
width: 100%;
+ height: auto;
aspect-ratio: 1200 / 630;
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- padding: 20px;
- overflow: hidden;
- }
-
- .og-card-img-dark {
- background: linear-gradient(145deg, #1a1a1a, #2a2a2a);
- }
-
- .og-card-img-coral {
- background: var(--gradient-coral);
- }
-
- .og-card-img-light {
- background: linear-gradient(145deg, #f8f8f8, #ffffff);
- }
-
- .og-card-accent-line {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 3px;
- background: var(--gradient-coral);
- }
-
- .og-card-tag,
- .og-card-tag-light,
- .og-card-tag-dark {
- display: inline-block;
- width: fit-content;
- font-size: 9px;
- font-weight: 700;
- letter-spacing: 0.1em;
- padding: 3px 8px;
- border-radius: 4px;
- margin-bottom: 8px;
- }
-
- .og-card-tag {
- background: var(--accent-primary);
- color: #fff;
- }
- .og-card-tag-light {
- background: rgba(255, 255, 255, 0.2);
- color: rgba(255, 255, 255, 0.9);
- }
- .og-card-tag-dark {
- background: rgba(0, 0, 0, 0.08);
- color: var(--text-muted);
- }
-
- .og-card-title-lg {
- font-family: var(--font-sans);
- font-size: 16px;
- font-weight: 700;
- line-height: 1.2;
- color: #fff;
- }
-
- .og-card-title-white {
- color: #fff;
- }
-
- .og-card-title-dark {
- color: var(--text);
- }
-
- .og-card-subtitle-light {
- font-size: 10px;
- color: rgba(255, 255, 255, 0.7);
- margin-top: 6px;
+ object-fit: cover;
+ border-bottom: 1px solid var(--border-muted);
}
.og-card-meta {
@@ -519,7 +463,28 @@ const categoryCounts = getCategoryCounts();
font-size: 11px;
color: var(--text-muted);
background: var(--bg-elevated);
- border-top: 1px solid var(--border-muted);
+ }
+
+ .og-card-meta-text {
+ flex: 1;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ color: var(--text-subtle);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .og-card-meta-badge {
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 2px 7px;
+ border-radius: var(--radius-sm);
+ background: var(--accent-primary-subtle);
+ color: var(--accent-primary);
+ flex-shrink: 0;
}
.og-card-favicon {
diff --git a/src/pages/preview.astro b/src/pages/preview.astro
index 676fffd..6be14dd 100644
--- a/src/pages/preview.astro
+++ b/src/pages/preview.astro
@@ -2,6 +2,7 @@
import Layout from '@/layouts/Layout.astro';
import { PreviewApp } from '@/components/preview/PreviewApp';
import '@/styles/preview.css';
+import '@/styles/ai.css';
---
diff --git a/src/styles/ai.css b/src/styles/ai.css
new file mode 100644
index 0000000..b4a0b4d
--- /dev/null
+++ b/src/styles/ai.css
@@ -0,0 +1,959 @@
+/* ===== AI Settings Modal ===== */
+.ai-modal-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ background: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-md);
+ animation: fadeIn 0.15s ease-out;
+}
+
+.ai-modal {
+ background: var(--bg-elevated);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-xl);
+ width: 100%;
+ max-width: 480px;
+ max-height: 90vh;
+ overflow-y: auto;
+ animation: slideUp 0.2s var(--ease-out);
+}
+
+@keyframes slideUp {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.ai-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-md) var(--space-lg);
+ border-bottom: 1px solid var(--border);
+}
+
+.ai-modal-title {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.ai-modal-close {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+ transition: background var(--transition), color var(--transition);
+}
+
+.ai-modal-close:hover {
+ background: var(--bg-surface);
+ color: var(--text);
+}
+
+.ai-modal-body {
+ padding: var(--space-lg);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-lg);
+}
+
+.ai-modal-footer {
+ padding: var(--space-md) var(--space-lg);
+ border-top: 1px solid var(--border);
+ font-size: var(--text-xs);
+ color: var(--text-subtle);
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ line-height: 1.4;
+}
+
+/* ===== AI Fields ===== */
+.ai-field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.ai-field-label {
+ font-size: var(--text-xs);
+ font-weight: 500;
+ color: var(--text-muted);
+}
+
+/* ===== Provider Grid ===== */
+.ai-provider-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+}
+
+.ai-provider-btn {
+ flex: 1;
+ min-width: 80px;
+ padding: 8px 12px;
+ font-size: var(--text-xs);
+ font-weight: 500;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all var(--transition);
+ white-space: nowrap;
+}
+
+.ai-provider-btn:hover {
+ border-color: var(--border-strong);
+ color: var(--text);
+}
+
+.ai-provider-btn.active {
+ background: var(--accent-primary-subtle);
+ border-color: var(--accent-primary);
+ color: var(--accent-primary);
+ font-weight: 600;
+}
+
+/* ===== API Key Input ===== */
+.ai-key-row {
+ display: flex;
+ gap: var(--space-sm);
+}
+
+.ai-key-input {
+ flex: 1;
+ padding: 8px 12px;
+ font-size: var(--text-sm);
+ font-family: var(--font-mono);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ outline: none;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+
+.ai-key-input:focus {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px var(--accent-primary-subtle);
+}
+
+.ai-key-toggle {
+ width: 38px;
+ height: 38px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: all var(--transition);
+}
+
+.ai-key-toggle:hover {
+ border-color: var(--border-strong);
+ color: var(--text);
+}
+
+.ai-key-link {
+ font-size: var(--text-xs);
+ color: var(--accent-primary);
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ transition: opacity var(--transition);
+}
+
+.ai-key-link:hover {
+ opacity: 0.8;
+}
+
+/* ===== Model Select ===== */
+.ai-model-select {
+ width: 100%;
+ padding: 8px 32px 8px 12px;
+ font-size: var(--text-sm);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ outline: none;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23999' stroke-width='2'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
+ background-repeat: no-repeat;
+ background-position: right 12px center;
+ cursor: pointer;
+ transition: border-color var(--transition), box-shadow var(--transition);
+}
+
+.ai-model-select:focus {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px var(--accent-primary-subtle);
+}
+
+/* ===== Action Buttons ===== */
+.ai-actions {
+ display: flex;
+ gap: var(--space-sm);
+}
+
+.ai-btn {
+ flex: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 8px 16px;
+ font-size: var(--text-sm);
+ font-weight: 500;
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: all var(--transition);
+ border: 1px solid transparent;
+ min-height: 38px;
+}
+
+.ai-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.ai-btn:active:not(:disabled) {
+ transform: scale(0.97);
+}
+
+.ai-btn-validate {
+ background: var(--gradient-coral);
+ color: white;
+ border-color: var(--accent-primary);
+}
+
+.ai-btn-validate:hover:not(:disabled) {
+ box-shadow: var(--shadow-md), var(--shadow-glow);
+}
+
+.ai-btn-clear {
+ background: var(--bg-surface);
+ color: var(--text-muted);
+ border-color: var(--border);
+}
+
+.ai-btn-clear:hover:not(:disabled) {
+ color: var(--text);
+ border-color: var(--border-strong);
+}
+
+/* ===== Spinner ===== */
+.ai-spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ===== Status ===== */
+.ai-status {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: 8px 12px;
+ font-size: var(--text-xs);
+ border-radius: var(--radius-sm);
+ font-weight: 500;
+}
+
+.ai-status-valid {
+ background: rgba(34, 197, 94, 0.1);
+ color: #16a34a;
+}
+
+.ai-status-invalid,
+.ai-status-error {
+ background: rgba(239, 68, 68, 0.1);
+ color: #dc2626;
+}
+
+/* ===== AI Generate Button (inline next to text fields) ===== */
+.ai-generate-wrapper {
+ position: relative;
+}
+
+.ai-generate-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ font-size: 11px;
+ font-weight: 500;
+ background: var(--accent-primary-subtle);
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ color: var(--accent-primary);
+ cursor: pointer;
+ transition: all var(--transition);
+ white-space: nowrap;
+ line-height: 1.4;
+}
+
+.ai-generate-btn:hover {
+ background: var(--accent-primary);
+ color: white;
+ box-shadow: var(--shadow-glow);
+}
+
+.ai-generate-btn:active {
+ transform: scale(0.95);
+}
+
+.ai-generate-btn.error {
+ border-color: #dc2626;
+ color: #dc2626;
+ background: rgba(239, 68, 68, 0.08);
+}
+
+.ai-generate-error {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ margin-top: 4px;
+ padding: 4px 8px;
+ font-size: 10px;
+ color: #dc2626;
+ background: rgba(239, 68, 68, 0.08);
+ border-radius: var(--radius-sm);
+ white-space: nowrap;
+ z-index: 10;
+ animation: fadeIn 0.15s ease-out;
+}
+
+/* ===== Spinner (dark variant for inline use) ===== */
+.ai-spinner-dark {
+ width: 12px;
+ height: 12px;
+ border: 2px solid var(--border);
+ border-top-color: var(--accent-primary);
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+
+/* ===== Suggestion Picker ===== */
+.ai-picker {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ margin-top: 6px;
+ width: 320px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-xl);
+ z-index: 100;
+ animation: slideUp 0.15s var(--ease-out);
+ overflow: hidden;
+}
+
+.ai-picker-header {
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border-muted);
+}
+
+.ai-picker-title {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-muted);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.ai-picker-list {
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.ai-picker-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-sm);
+ padding: var(--space-lg);
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+}
+
+.ai-picker-option {
+ display: block;
+ width: 100%;
+ padding: 10px 12px;
+ font-size: var(--text-sm);
+ color: var(--text);
+ background: none;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ transition: background var(--transition-fast);
+ line-height: 1.4;
+}
+
+.ai-picker-option:hover,
+.ai-picker-option.active {
+ background: var(--accent-primary-subtle);
+}
+
+.ai-picker-option + .ai-picker-option {
+ border-top: 1px solid var(--border-muted);
+}
+
+.ai-picker-footer {
+ display: flex;
+ gap: var(--space-sm);
+ padding: 8px 12px;
+ border-top: 1px solid var(--border-muted);
+}
+
+.ai-picker-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 10px;
+ font-size: 11px;
+ font-weight: 500;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.ai-picker-btn:hover {
+ border-color: var(--border-strong);
+ color: var(--text);
+}
+
+.ai-picker-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ===== Topbar AI Button ===== */
+.ai-topbar-btn {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ font-size: var(--text-xs);
+ font-weight: 500;
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-subtle);
+ cursor: pointer;
+ transition: all var(--transition);
+ white-space: nowrap;
+}
+
+.ai-topbar-btn:hover {
+ border-color: var(--border-strong);
+ color: var(--text);
+}
+
+.ai-topbar-btn.configured {
+ border-color: var(--accent-primary-muted);
+ color: var(--accent-primary);
+ background: var(--accent-primary-subtle);
+}
+
+.ai-topbar-btn.configured:hover {
+ box-shadow: var(--shadow-glow);
+}
+
+/* ===== Smart Autofill ===== */
+.ai-autofill-trigger {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ width: 100%;
+ padding: 10px var(--space-lg);
+ font-size: var(--text-xs);
+ font-weight: 500;
+ background: var(--accent-primary-subtle);
+ border: none;
+ border-bottom: 1px solid var(--border);
+ color: var(--accent-primary);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.ai-autofill-trigger:hover {
+ background: rgba(224, 122, 95, 0.15);
+}
+
+.ai-autofill {
+ padding: var(--space-md) var(--space-lg);
+ border-bottom: 1px solid var(--border);
+ background: var(--bg-surface);
+}
+
+.ai-autofill-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 6px;
+}
+
+.ai-autofill-title {
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--accent-primary);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.ai-autofill-close {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ color: var(--text-subtle);
+ cursor: pointer;
+ border-radius: var(--radius-sm);
+}
+
+.ai-autofill-close:hover { color: var(--text); }
+
+.ai-autofill-desc {
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ margin-bottom: var(--space-sm);
+}
+
+.ai-autofill-input-row {
+ display: flex;
+ gap: var(--space-sm);
+}
+
+.ai-autofill-input {
+ flex: 1;
+ padding: 8px 12px;
+ font-size: var(--text-sm);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text);
+ outline: none;
+ transition: border-color var(--transition);
+}
+
+.ai-autofill-input:focus {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px var(--accent-primary-subtle);
+}
+
+.ai-autofill-steps {
+ margin-top: var(--space-sm);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.ai-autofill-step {
+ font-size: 11px;
+ color: var(--text-subtle);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.ai-autofill-step.active { color: var(--text-muted); }
+.ai-autofill-step.done { color: #16a34a; }
+
+/* ===== AI Analyzer (Preview Checker) ===== */
+.ai-analyzer {
+ margin-top: var(--space-md);
+}
+
+.ai-analyzer-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 10px 20px;
+ font-size: var(--text-sm);
+ font-weight: 500;
+ background: var(--gradient-coral);
+ color: white;
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: all var(--transition);
+}
+
+.ai-analyzer-btn:hover:not(:disabled) {
+ box-shadow: var(--shadow-md), var(--shadow-glow);
+}
+
+.ai-analyzer-btn:disabled { opacity: 0.6; cursor: not-allowed; }
+
+.ai-analyzer-error {
+ margin-top: var(--space-sm);
+ padding: 8px 12px;
+ font-size: var(--text-xs);
+ color: #dc2626;
+ background: rgba(239, 68, 68, 0.08);
+ border-radius: var(--radius-sm);
+}
+
+.ai-analyzer-results {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.ai-analyzer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-md) var(--space-lg);
+ border-bottom: 1px solid var(--border-muted);
+}
+
+.ai-analyzer-title {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ color: var(--text);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+}
+
+.ai-analyzer-score {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+}
+
+.ai-analyzer-score-num {
+ font-size: var(--text-sm);
+ font-weight: 600;
+ font-family: var(--font-mono);
+ color: var(--text);
+}
+
+.ai-analyzer-score-bar {
+ width: 80px;
+ height: 6px;
+ background: var(--border);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.ai-analyzer-score-fill {
+ height: 100%;
+ background: var(--gradient-coral);
+ border-radius: 3px;
+ transition: width 0.5s var(--ease-out);
+}
+
+.ai-analyzer-section {
+ padding: var(--space-md) var(--space-lg);
+ border-bottom: 1px solid var(--border-muted);
+}
+
+.ai-analyzer-section:last-of-type { border-bottom: none; }
+
+.ai-analyzer-section-title {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin: 0 0 var(--space-sm) 0;
+}
+
+.ai-section-good { color: #16a34a; }
+.ai-section-warn { color: #ca8a04; }
+.ai-section-error { color: #dc2626; }
+
+.ai-analyzer-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.ai-analyzer-list li {
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ line-height: 1.5;
+ padding-left: 16px;
+ position: relative;
+}
+
+.ai-analyzer-list li::before {
+ content: '•';
+ position: absolute;
+ left: 0;
+ color: var(--text-subtle);
+}
+
+.ai-analyzer-suggestion {
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ margin-bottom: 4px;
+}
+
+.ai-analyzer-suggestion-label {
+ font-weight: 600;
+ color: var(--text);
+ margin-right: 4px;
+}
+
+.ai-analyzer-cta {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: var(--space-md) var(--space-lg);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ color: var(--accent-primary);
+ background: var(--accent-primary-subtle);
+ border-top: 1px solid var(--border-muted);
+ transition: all var(--transition);
+}
+
+.ai-analyzer-cta:hover {
+ background: rgba(224, 122, 95, 0.15);
+}
+
+/* ===== AI Template Search ===== */
+.ai-template-search {
+ margin-top: var(--space-sm);
+}
+
+.ai-template-search-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: 6px 10px;
+ background: var(--accent-primary-subtle);
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ transition: all var(--transition);
+}
+
+.ai-template-search-row:focus-within {
+ border-color: var(--accent-primary);
+ box-shadow: 0 0 0 3px var(--accent-primary-subtle);
+}
+
+.ai-template-search-icon {
+ flex-shrink: 0;
+ color: var(--accent-primary);
+}
+
+.ai-template-search-input {
+ flex: 1;
+ background: none;
+ border: none;
+ outline: none;
+ font-size: var(--text-xs);
+ color: var(--text);
+}
+
+.ai-template-search-input::placeholder {
+ color: var(--accent-primary-muted);
+}
+
+.ai-template-search-error {
+ padding: 4px 8px;
+ margin-top: 4px;
+ font-size: 10px;
+ color: #dc2626;
+}
+
+.ai-template-search-results {
+ margin-top: var(--space-sm);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.ai-template-search-result {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: 6px 8px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ text-align: left;
+ transition: all var(--transition);
+}
+
+.ai-template-search-result:hover {
+ border-color: var(--accent-primary);
+ box-shadow: var(--shadow-sm);
+}
+
+.ai-template-search-thumb {
+ width: 48px;
+ height: 25px;
+ object-fit: cover;
+ border-radius: 3px;
+ flex-shrink: 0;
+}
+
+.ai-template-search-info {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+ flex: 1;
+}
+
+.ai-template-search-name {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ai-template-search-reason {
+ font-size: 10px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ai-template-search-score {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--accent-primary);
+ font-family: var(--font-mono);
+}
+
+/* ===== Responsive ===== */
+@media (max-width: 768px) {
+ .ai-picker {
+ width: 280px;
+ }
+
+ .ai-picker-option {
+ padding: 12px;
+ min-height: 44px;
+ }
+
+ .ai-picker-btn {
+ min-height: 36px;
+ padding: 6px 12px;
+ }
+
+ .ai-generate-btn {
+ min-height: 28px;
+ padding: 4px 10px;
+ }
+
+ .ai-autofill-input {
+ font-size: 16px;
+ min-height: 44px;
+ }
+
+ .ai-autofill-trigger {
+ padding: 12px var(--space-md);
+ min-height: 44px;
+ }
+}
+
+@media (max-width: 768px) {
+ .ai-modal-overlay {
+ padding: 0;
+ align-items: flex-end;
+ }
+
+ .ai-modal {
+ max-width: 100%;
+ border-radius: var(--radius-xl) var(--radius-xl) 0 0;
+ max-height: 90vh;
+ max-height: 90dvh;
+ }
+
+ .ai-provider-grid {
+ gap: var(--space-xs);
+ }
+
+ .ai-provider-btn {
+ min-width: 70px;
+ padding: 10px 8px;
+ font-size: 11px;
+ min-height: 44px;
+ }
+
+ .ai-key-input {
+ font-size: 16px;
+ min-height: 44px;
+ }
+
+ .ai-model-select {
+ font-size: 16px;
+ min-height: 44px;
+ }
+
+ .ai-btn {
+ min-height: 44px;
+ }
+
+ .ai-modal-close {
+ min-width: 44px;
+ min-height: 44px;
+ }
+}
+
+@media (max-width: 480px) {
+ .ai-modal-body {
+ padding: var(--space-md);
+ }
+
+ .ai-modal-header {
+ padding: var(--space-md);
+ }
+
+ .ai-provider-btn {
+ min-width: calc(33% - 6px);
+ }
+}
diff --git a/src/styles/api-docs.css b/src/styles/api-docs.css
index 8a736de..adf1ac8 100644
--- a/src/styles/api-docs.css
+++ b/src/styles/api-docs.css
@@ -137,6 +137,11 @@
line-height: 1.6;
}
+.endpoint-method-post {
+ background: rgba(59, 130, 246, 0.12);
+ color: #2563eb;
+}
+
.endpoint-path {
font-family: var(--font-mono);
font-size: var(--text-sm);
diff --git a/tests/ai/autofill.test.ts b/tests/ai/autofill.test.ts
new file mode 100644
index 0000000..e8996b7
--- /dev/null
+++ b/tests/ai/autofill.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { parseAutofillResponse } from '@/lib/ai/autofill';
+
+describe('parseAutofillResponse', () => {
+ it('should parse clean JSON response', () => {
+ const result = parseAutofillResponse(JSON.stringify({
+ templateId: 'blog-minimal-dark',
+ fields: { title: 'Hello World', author: 'John' },
+ colors: { bgColor: '#1a1a2e' },
+ }));
+ expect(result).not.toBeNull();
+ expect(result!.templateId).toBe('blog-minimal-dark');
+ expect(result!.fields.title).toBe('Hello World');
+ expect(result!.colors?.bgColor).toBe('#1a1a2e');
+ });
+
+ it('should parse JSON from markdown code block', () => {
+ const result = parseAutofillResponse('```json\n{"templateId":"blog-code-dark","fields":{"title":"Test"}}\n```');
+ expect(result).not.toBeNull();
+ expect(result!.templateId).toBe('blog-code-dark');
+ });
+
+ it('should extract JSON from surrounding text', () => {
+ const result = parseAutofillResponse('Here is the result:\n{"templateId":"product-centered-hero","fields":{"title":"Launch"}}\nDone!');
+ expect(result).not.toBeNull();
+ expect(result!.templateId).toBe('product-centered-hero');
+ });
+
+ it('should return null for invalid JSON', () => {
+ expect(parseAutofillResponse('not json at all')).toBeNull();
+ });
+
+ it('should return null for empty string', () => {
+ expect(parseAutofillResponse('')).toBeNull();
+ });
+
+ it('should return null for JSON without templateId', () => {
+ expect(parseAutofillResponse('{"fields":{"title":"test"}}')).toBeNull();
+ });
+
+ it('should return null for JSON without fields', () => {
+ expect(parseAutofillResponse('{"templateId":"test"}')).toBeNull();
+ });
+
+ it('should handle response without colors', () => {
+ const result = parseAutofillResponse('{"templateId":"blog-minimal-dark","fields":{"title":"Test"}}');
+ expect(result).not.toBeNull();
+ expect(result!.colors).toBeUndefined();
+ });
+});
diff --git a/tests/ai/generate.test.ts b/tests/ai/generate.test.ts
new file mode 100644
index 0000000..0e5859c
--- /dev/null
+++ b/tests/ai/generate.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from 'vitest';
+import { parseAIResponse } from '@/lib/ai/generate';
+
+describe('parseAIResponse', () => {
+ it('should parse clean JSON array', () => {
+ const result = parseAIResponse('["Title One", "Title Two", "Title Three"]');
+ expect(result).toEqual(['Title One', 'Title Two', 'Title Three']);
+ });
+
+ it('should parse JSON with whitespace', () => {
+ const result = parseAIResponse(' \n["Title One", "Title Two"] \n');
+ expect(result).toEqual(['Title One', 'Title Two']);
+ });
+
+ it('should parse markdown-wrapped JSON', () => {
+ const result = parseAIResponse('```json\n["Title One", "Title Two"]\n```');
+ expect(result).toEqual(['Title One', 'Title Two']);
+ });
+
+ it('should parse markdown without json tag', () => {
+ const result = parseAIResponse('```\n["Title One", "Title Two"]\n```');
+ expect(result).toEqual(['Title One', 'Title Two']);
+ });
+
+ it('should extract JSON array from surrounding text', () => {
+ const result = parseAIResponse('Here are some titles:\n["Title One", "Title Two"]\nHope these help!');
+ expect(result).toEqual(['Title One', 'Title Two']);
+ });
+
+ it('should filter out non-string values', () => {
+ const result = parseAIResponse('[1, "Title One", null, "Title Two", true]');
+ expect(result).toEqual(['Title One', 'Title Two']);
+ });
+
+ it('should fallback to newline splitting for plain text', () => {
+ const result = parseAIResponse('Title One\nTitle Two\nTitle Three');
+ expect(result).toEqual(['Title One', 'Title Two', 'Title Three']);
+ });
+
+ it('should handle numbered list fallback', () => {
+ const result = parseAIResponse('1. Title One\n2. Title Two\n3. Title Three');
+ expect(result).toEqual(['Title One', 'Title Two', 'Title Three']);
+ });
+
+ it('should handle bullet list fallback', () => {
+ const result = parseAIResponse('- Title One\n- Title Two\n- Title Three');
+ expect(result).toEqual(['Title One', 'Title Two', 'Title Three']);
+ });
+
+ it('should strip surrounding quotes in fallback', () => {
+ const result = parseAIResponse('"Title One"\n"Title Two"');
+ expect(result).toEqual(['Title One', 'Title Two']);
+ });
+
+ it('should filter empty lines in fallback', () => {
+ const result = parseAIResponse('Title One\n\n\nTitle Two');
+ expect(result).toEqual(['Title One', 'Title Two']);
+ });
+
+ it('should handle completely empty response', () => {
+ const result = parseAIResponse('');
+ expect(result).toEqual([]);
+ });
+});
diff --git a/tests/ai/prompts.test.ts b/tests/ai/prompts.test.ts
new file mode 100644
index 0000000..51262eb
--- /dev/null
+++ b/tests/ai/prompts.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect } from 'vitest';
+import { buildTitlePrompt, buildSubtitlePrompt, buildGenericFieldPrompt, getPromptForField } from '@/lib/ai/prompts';
+
+describe('buildTitlePrompt', () => {
+ it('should include category in prompt', () => {
+ const { prompt } = buildTitlePrompt({ fieldName: 'title', category: 'blog' });
+ expect(prompt).toContain('blog');
+ });
+
+ it('should include character limit', () => {
+ const { prompt } = buildTitlePrompt({ fieldName: 'title' });
+ expect(prompt).toContain('60 characters');
+ });
+
+ it('should request JSON array format', () => {
+ const { prompt } = buildTitlePrompt({ fieldName: 'title' });
+ expect(prompt).toContain('JSON array');
+ });
+
+ it('should include existing fields as context', () => {
+ const { prompt } = buildTitlePrompt({
+ fieldName: 'title',
+ currentValues: { author: 'John Doe', description: 'A blog post' },
+ });
+ expect(prompt).toContain('author: "John Doe"');
+ expect(prompt).toContain('description: "A blog post"');
+ });
+
+ it('should use count parameter', () => {
+ const { prompt } = buildTitlePrompt({ fieldName: 'title', count: 5 });
+ expect(prompt).toContain('5');
+ });
+});
+
+describe('buildSubtitlePrompt', () => {
+ it('should reference the title when available', () => {
+ const { prompt } = buildSubtitlePrompt({
+ fieldName: 'subtitle',
+ currentValues: { title: 'My Great Title' },
+ });
+ expect(prompt).toContain('My Great Title');
+ });
+
+ it('should handle missing title', () => {
+ const { prompt } = buildSubtitlePrompt({ fieldName: 'subtitle' });
+ expect(prompt).toContain('standalone subtitle');
+ });
+
+ it('should include 80 character limit', () => {
+ const { prompt } = buildSubtitlePrompt({ fieldName: 'subtitle' });
+ expect(prompt).toContain('80 characters');
+ });
+});
+
+describe('buildGenericFieldPrompt', () => {
+ it('should include field name', () => {
+ const { prompt, systemPrompt } = buildGenericFieldPrompt({ fieldName: 'Author Name' });
+ expect(prompt).toContain('Author Name');
+ expect(systemPrompt).toContain('Author Name');
+ });
+});
+
+describe('getPromptForField', () => {
+ it('should use title prompt for title field', () => {
+ const { prompt } = getPromptForField({ fieldName: 'Title' });
+ expect(prompt).toContain('60 characters');
+ });
+
+ it('should use subtitle prompt for description field', () => {
+ const { prompt } = getPromptForField({ fieldName: 'Description' });
+ expect(prompt).toContain('80 characters');
+ });
+
+ it('should use subtitle prompt for subtitle field', () => {
+ const { prompt } = getPromptForField({ fieldName: 'Subtitle' });
+ expect(prompt).toContain('80 characters');
+ });
+
+ it('should use generic prompt for other fields', () => {
+ const { prompt, systemPrompt } = getPromptForField({ fieldName: 'Author' });
+ expect(prompt).toContain('"Author"');
+ expect(systemPrompt).toContain('"Author"');
+ // Should not contain title-specific or subtitle-specific wording
+ expect(prompt).not.toContain('click-worthy');
+ expect(prompt).not.toContain('Complement the title');
+ });
+});
diff --git a/tests/ai/providers.test.ts b/tests/ai/providers.test.ts
new file mode 100644
index 0000000..20a4a1b
--- /dev/null
+++ b/tests/ai/providers.test.ts
@@ -0,0 +1,294 @@
+import { describe, it, expect } from 'vitest';
+import { getProvider, getAllProviders, getModelsForProvider, getDefaultModel, isValidProvider } from '@/lib/ai/providers';
+import { AI_PROVIDERS } from '@/lib/ai/types';
+import type { AIProvider } from '@/lib/ai/types';
+
+describe('AI Provider Registry', () => {
+ it('should have all 5 providers registered', () => {
+ const providers = getAllProviders();
+ expect(providers).toHaveLength(5);
+ expect(providers.map((p) => p.id).sort()).toEqual(
+ ['anthropic', 'google', 'groq', 'openai', 'openrouter']
+ );
+ });
+
+ it('should return correct config for each provider', () => {
+ for (const id of AI_PROVIDERS) {
+ const provider = getProvider(id);
+ expect(provider).toBeDefined();
+ expect(provider!.id).toBe(id);
+ expect(provider!.name).toBeTruthy();
+ expect(provider!.baseUrl).toMatch(/^https:\/\//);
+ expect(provider!.apiKeyUrl).toMatch(/^https:\/\//);
+ }
+ });
+
+ it('should return undefined for unknown provider', () => {
+ expect(getProvider('nonexistent' as AIProvider)).toBeUndefined();
+ });
+
+ it('should have at least 2 models per provider', () => {
+ for (const id of AI_PROVIDERS) {
+ const models = getModelsForProvider(id);
+ expect(models.length).toBeGreaterThanOrEqual(2);
+ for (const model of models) {
+ expect(model.id).toBeTruthy();
+ expect(model.name).toBeTruthy();
+ expect(model.provider).toBe(id);
+ expect(model.maxTokens).toBeGreaterThan(0);
+ }
+ }
+ });
+
+ it('should return empty array for unknown provider models', () => {
+ expect(getModelsForProvider('nonexistent' as AIProvider)).toEqual([]);
+ });
+
+ it('should return a default model for each provider', () => {
+ for (const id of AI_PROVIDERS) {
+ const defaultModel = getDefaultModel(id);
+ expect(defaultModel).toBeTruthy();
+ // Default model should be in the provider's model list
+ const models = getModelsForProvider(id);
+ expect(models.some((m) => m.id === defaultModel)).toBe(true);
+ }
+ });
+
+ it('should return undefined for unknown provider default model', () => {
+ expect(getDefaultModel('nonexistent' as AIProvider)).toBeUndefined();
+ });
+
+ it('should validate provider IDs correctly', () => {
+ expect(isValidProvider('openai')).toBe(true);
+ expect(isValidProvider('anthropic')).toBe(true);
+ expect(isValidProvider('google')).toBe(true);
+ expect(isValidProvider('groq')).toBe(true);
+ expect(isValidProvider('openrouter')).toBe(true);
+ expect(isValidProvider('nonexistent')).toBe(false);
+ expect(isValidProvider('')).toBe(false);
+ });
+});
+
+describe('AI Provider Header Builders', () => {
+ it('should build OpenAI headers with Bearer token', () => {
+ const provider = getProvider('openai')!;
+ const headers = provider.headerBuilder('sk-test-key');
+ expect(headers['Authorization']).toBe('Bearer sk-test-key');
+ expect(headers['Content-Type']).toBe('application/json');
+ });
+
+ it('should build Anthropic headers with x-api-key', () => {
+ const provider = getProvider('anthropic')!;
+ const headers = provider.headerBuilder('sk-ant-test-key');
+ expect(headers['x-api-key']).toBe('sk-ant-test-key');
+ expect(headers['anthropic-version']).toBe('2023-06-01');
+ expect(headers['Content-Type']).toBe('application/json');
+ });
+
+ it('should build Google headers (key via query param, not header)', () => {
+ const provider = getProvider('google')!;
+ const headers = provider.headerBuilder('google-key');
+ expect(headers['Content-Type']).toBe('application/json');
+ expect(headers['Authorization']).toBeUndefined();
+ });
+
+ it('should build Groq headers with Bearer token', () => {
+ const provider = getProvider('groq')!;
+ const headers = provider.headerBuilder('gsk-test-key');
+ expect(headers['Authorization']).toBe('Bearer gsk-test-key');
+ });
+
+ it('should build OpenRouter headers with referer', () => {
+ const provider = getProvider('openrouter')!;
+ const headers = provider.headerBuilder('sk-or-test-key');
+ expect(headers['Authorization']).toBe('Bearer sk-or-test-key');
+ expect(headers['HTTP-Referer']).toBeTruthy();
+ expect(headers['X-Title']).toBe('OGCOPS');
+ });
+});
+
+describe('AI Provider Body Formatters', () => {
+ const baseParams = {
+ model: 'test-model',
+ prompt: 'Generate a title',
+ systemPrompt: 'You are a helper',
+ maxTokens: 200,
+ temperature: 0.7,
+ };
+
+ it('should format OpenAI-style body (OpenAI)', () => {
+ const provider = getProvider('openai')!;
+ const { url, body } = provider.bodyFormatter(baseParams);
+ expect(url).toContain('/chat/completions');
+ const b = body as any;
+ expect(b.model).toBe('test-model');
+ expect(b.messages).toHaveLength(2);
+ expect(b.messages[0]).toEqual({ role: 'system', content: 'You are a helper' });
+ expect(b.messages[1]).toEqual({ role: 'user', content: 'Generate a title' });
+ expect(b.max_tokens).toBe(200);
+ expect(b.temperature).toBe(0.7);
+ });
+
+ it('should format OpenAI-style body without system prompt', () => {
+ const provider = getProvider('openai')!;
+ const { body } = provider.bodyFormatter({ ...baseParams, systemPrompt: undefined });
+ const b = body as any;
+ expect(b.messages).toHaveLength(1);
+ expect(b.messages[0].role).toBe('user');
+ });
+
+ it('should format Anthropic-style body', () => {
+ const provider = getProvider('anthropic')!;
+ const { url, body } = provider.bodyFormatter(baseParams);
+ expect(url).toContain('/v1/messages');
+ const b = body as any;
+ expect(b.model).toBe('test-model');
+ expect(b.system).toBe('You are a helper');
+ expect(b.messages).toHaveLength(1);
+ expect(b.messages[0]).toEqual({ role: 'user', content: 'Generate a title' });
+ expect(b.max_tokens).toBe(200);
+ expect(b.temperature).toBe(0.7);
+ });
+
+ it('should format Anthropic body without system prompt', () => {
+ const provider = getProvider('anthropic')!;
+ const { body } = provider.bodyFormatter({ ...baseParams, systemPrompt: undefined });
+ const b = body as any;
+ expect(b.system).toBeUndefined();
+ });
+
+ it('should format Google Gemini body', () => {
+ const provider = getProvider('google')!;
+ const { url, body } = provider.bodyFormatter(baseParams);
+ expect(url).toContain('generateContent');
+ expect(url).toContain('test-model');
+ const b = body as any;
+ expect(b.contents[0].parts[0].text).toContain('Generate a title');
+ expect(b.contents[0].parts[0].text).toContain('You are a helper');
+ expect(b.generationConfig.maxOutputTokens).toBe(200);
+ expect(b.generationConfig.temperature).toBe(0.7);
+ });
+
+ it('should format Groq body (OpenAI-compatible)', () => {
+ const provider = getProvider('groq')!;
+ const { url, body } = provider.bodyFormatter(baseParams);
+ expect(url).toContain('groq.com');
+ expect(url).toContain('/chat/completions');
+ const b = body as any;
+ expect(b.messages).toHaveLength(2);
+ });
+
+ it('should format OpenRouter body (OpenAI-compatible)', () => {
+ const provider = getProvider('openrouter')!;
+ const { url, body } = provider.bodyFormatter(baseParams);
+ expect(url).toContain('openrouter.ai');
+ expect(url).toContain('/chat/completions');
+ const b = body as any;
+ expect(b.messages).toHaveLength(2);
+ });
+
+ it('should use defaults when maxTokens and temperature are not provided', () => {
+ const provider = getProvider('openai')!;
+ const { body } = provider.bodyFormatter({ model: 'gpt-4o', prompt: 'hi' });
+ const b = body as any;
+ expect(b.max_tokens).toBe(300);
+ expect(b.temperature).toBe(0.8);
+ });
+});
+
+describe('AI Provider Response Parsers', () => {
+ it('should parse OpenAI response', () => {
+ const provider = getProvider('openai')!;
+ const result = provider.responseParser({
+ choices: [{ message: { content: 'Hello world' } }],
+ model: 'gpt-4o-mini',
+ usage: { prompt_tokens: 10, completion_tokens: 5 },
+ });
+ expect(result.content).toBe('Hello world');
+ expect(result.model).toBe('gpt-4o-mini');
+ expect(result.usage).toEqual({ inputTokens: 10, outputTokens: 5 });
+ });
+
+ it('should parse Anthropic response', () => {
+ const provider = getProvider('anthropic')!;
+ const result = provider.responseParser({
+ content: [{ type: 'text', text: 'Hello from Claude' }],
+ model: 'claude-sonnet-4-6',
+ usage: { input_tokens: 15, output_tokens: 8 },
+ });
+ expect(result.content).toBe('Hello from Claude');
+ expect(result.model).toBe('claude-sonnet-4-6');
+ expect(result.usage).toEqual({ inputTokens: 15, outputTokens: 8 });
+ });
+
+ it('should parse Google Gemini response', () => {
+ const provider = getProvider('google')!;
+ const result = provider.responseParser({
+ candidates: [{ content: { parts: [{ text: 'Hello from Gemini' }] } }],
+ modelVersion: 'gemini-2.0-flash',
+ usageMetadata: { promptTokenCount: 20, candidatesTokenCount: 10 },
+ });
+ expect(result.content).toBe('Hello from Gemini');
+ expect(result.model).toBe('gemini-2.0-flash');
+ expect(result.usage).toEqual({ inputTokens: 20, outputTokens: 10 });
+ });
+
+ it('should handle empty/malformed responses gracefully', () => {
+ const provider = getProvider('openai')!;
+ const result = provider.responseParser({});
+ expect(result.content).toBe('');
+ expect(result.model).toBe('');
+ expect(result.usage).toBeUndefined();
+ });
+
+ it('should handle missing usage data', () => {
+ const provider = getProvider('anthropic')!;
+ const result = provider.responseParser({
+ content: [{ text: 'test' }],
+ model: 'claude-haiku-4-5',
+ });
+ expect(result.content).toBe('test');
+ expect(result.usage).toBeUndefined();
+ });
+});
+
+describe('AI Provider Validate Requests', () => {
+ it('should build OpenAI validation request', () => {
+ const provider = getProvider('openai')!;
+ const req = provider.validateRequest('sk-test');
+ expect(req.method).toBe('GET');
+ expect(req.url).toContain('/v1/models');
+ expect(req.headers['Authorization']).toBe('Bearer sk-test');
+ });
+
+ it('should build Anthropic validation request', () => {
+ const provider = getProvider('anthropic')!;
+ const req = provider.validateRequest('sk-ant-test');
+ expect(req.method).toBe('POST');
+ expect(req.url).toContain('/v1/messages');
+ expect(req.headers['x-api-key']).toBe('sk-ant-test');
+ });
+
+ it('should build Google validation request with key in URL', () => {
+ const provider = getProvider('google')!;
+ const req = provider.validateRequest('google-key');
+ expect(req.method).toBe('GET');
+ expect(req.url).toContain('key=google-key');
+ });
+
+ it('should build Groq validation request', () => {
+ const provider = getProvider('groq')!;
+ const req = provider.validateRequest('gsk-test');
+ expect(req.method).toBe('GET');
+ expect(req.url).toContain('/models');
+ expect(req.headers['Authorization']).toBe('Bearer gsk-test');
+ });
+
+ it('should build OpenRouter validation request', () => {
+ const provider = getProvider('openrouter')!;
+ const req = provider.validateRequest('sk-or-test');
+ expect(req.method).toBe('GET');
+ expect(req.url).toContain('/models');
+ expect(req.headers['Authorization']).toBe('Bearer sk-or-test');
+ });
+});
diff --git a/tests/ai/recommender.test.ts b/tests/ai/recommender.test.ts
new file mode 100644
index 0000000..4ed8cf3
--- /dev/null
+++ b/tests/ai/recommender.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from 'vitest';
+import { parseRecommenderResponse } from '@/lib/ai/recommender';
+
+describe('parseRecommenderResponse', () => {
+ it('should parse clean JSON array', () => {
+ const result = parseRecommenderResponse(JSON.stringify([
+ { id: 'blog-minimal-dark', score: 95, reason: 'Perfect match' },
+ { id: 'blog-code-dark', score: 80, reason: 'Good for code' },
+ ]));
+ expect(result).toHaveLength(2);
+ expect(result[0].id).toBe('blog-minimal-dark');
+ expect(result[0].score).toBe(95);
+ expect(result[0].reason).toBe('Perfect match');
+ });
+
+ it('should parse markdown-wrapped JSON', () => {
+ const result = parseRecommenderResponse('```json\n[{"id":"test","score":90,"reason":"match"}]\n```');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('test');
+ });
+
+ it('should extract array from surrounding text', () => {
+ const result = parseRecommenderResponse('Here are the results:\n[{"id":"test","score":85,"reason":"good"}]\nHope this helps!');
+ expect(result).toHaveLength(1);
+ });
+
+ it('should limit to 5 results', () => {
+ const items = Array.from({ length: 10 }, (_, i) => ({ id: `t${i}`, score: 90 - i, reason: 'match' }));
+ const result = parseRecommenderResponse(JSON.stringify(items));
+ expect(result).toHaveLength(5);
+ });
+
+ it('should handle missing score', () => {
+ const result = parseRecommenderResponse('[{"id":"test","reason":"good"}]');
+ expect(result[0].score).toBe(50); // default
+ });
+
+ it('should handle missing reason', () => {
+ const result = parseRecommenderResponse('[{"id":"test","score":90}]');
+ expect(result[0].reason).toBe('');
+ });
+
+ it('should filter out items without id', () => {
+ const result = parseRecommenderResponse('[{"score":90,"reason":"no id"},{"id":"valid","score":80}]');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('valid');
+ });
+
+ it('should return empty array for invalid input', () => {
+ expect(parseRecommenderResponse('not json')).toEqual([]);
+ });
+
+ it('should return empty array for empty string', () => {
+ expect(parseRecommenderResponse('')).toEqual([]);
+ });
+});
diff --git a/tests/ai/storage.test.ts b/tests/ai/storage.test.ts
new file mode 100644
index 0000000..5ea4733
--- /dev/null
+++ b/tests/ai/storage.test.ts
@@ -0,0 +1,177 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import {
+ getApiKey,
+ setApiKey,
+ removeApiKey,
+ clearAllApiKeys,
+ getSelectedProvider,
+ setSelectedProvider,
+ getSelectedModel,
+ setSelectedModel,
+ hasAnyKeyConfigured,
+} from '@/lib/ai/storage';
+
+// Mock localStorage for Node environment
+const store: Record = {};
+const localStorageMock = {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
+ removeItem: vi.fn((key: string) => { delete store[key]; }),
+ key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
+ get length() { return Object.keys(store).length; },
+ clear: vi.fn(() => { for (const key of Object.keys(store)) delete store[key]; }),
+};
+
+Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
+
+beforeEach(() => {
+ localStorageMock.clear();
+ vi.clearAllMocks();
+});
+
+describe('API Key Storage', () => {
+ it('should set and get an API key', () => {
+ setApiKey('openai', 'sk-test-123');
+ expect(getApiKey('openai')).toBe('sk-test-123');
+ });
+
+ it('should return null for unset key', () => {
+ expect(getApiKey('openai')).toBeNull();
+ });
+
+ it('should store keys independently per provider', () => {
+ setApiKey('openai', 'sk-openai');
+ setApiKey('anthropic', 'sk-ant-anthropic');
+ setApiKey('google', 'google-key');
+ expect(getApiKey('openai')).toBe('sk-openai');
+ expect(getApiKey('anthropic')).toBe('sk-ant-anthropic');
+ expect(getApiKey('google')).toBe('google-key');
+ });
+
+ it('should remove only the specified provider key', () => {
+ setApiKey('openai', 'sk-openai');
+ setApiKey('anthropic', 'sk-ant-key');
+ removeApiKey('openai');
+ expect(getApiKey('openai')).toBeNull();
+ expect(getApiKey('anthropic')).toBe('sk-ant-key');
+ });
+
+ it('should overwrite existing key', () => {
+ setApiKey('openai', 'sk-old');
+ setApiKey('openai', 'sk-new');
+ expect(getApiKey('openai')).toBe('sk-new');
+ });
+});
+
+describe('clearAllApiKeys', () => {
+ it('should remove all AI-related entries', () => {
+ setApiKey('openai', 'sk-1');
+ setApiKey('anthropic', 'sk-2');
+ setSelectedProvider('openai');
+ setSelectedModel('openai', 'gpt-4o');
+ clearAllApiKeys();
+ expect(getApiKey('openai')).toBeNull();
+ expect(getApiKey('anthropic')).toBeNull();
+ expect(getSelectedProvider()).toBeNull();
+ expect(getSelectedModel('openai')).toBeNull();
+ });
+
+ it('should not remove non-AI localStorage entries', () => {
+ store['other-key'] = 'should-stay';
+ setApiKey('openai', 'sk-1');
+ clearAllApiKeys();
+ expect(store['other-key']).toBe('should-stay');
+ });
+});
+
+describe('Provider Selection', () => {
+ it('should set and get selected provider', () => {
+ setSelectedProvider('anthropic');
+ expect(getSelectedProvider()).toBe('anthropic');
+ });
+
+ it('should return null when no provider selected', () => {
+ expect(getSelectedProvider()).toBeNull();
+ });
+
+ it('should overwrite selected provider', () => {
+ setSelectedProvider('openai');
+ setSelectedProvider('google');
+ expect(getSelectedProvider()).toBe('google');
+ });
+});
+
+describe('Model Selection', () => {
+ it('should set and get model per provider', () => {
+ setSelectedModel('openai', 'gpt-4o');
+ setSelectedModel('anthropic', 'claude-sonnet-4-6');
+ expect(getSelectedModel('openai')).toBe('gpt-4o');
+ expect(getSelectedModel('anthropic')).toBe('claude-sonnet-4-6');
+ });
+
+ it('should return null for unset model', () => {
+ expect(getSelectedModel('openai')).toBeNull();
+ });
+
+ it('should remember model when switching providers', () => {
+ setSelectedModel('openai', 'gpt-4o');
+ setSelectedModel('anthropic', 'claude-haiku-4-5');
+ setSelectedProvider('anthropic');
+ // OpenAI model should still be remembered
+ expect(getSelectedModel('openai')).toBe('gpt-4o');
+ expect(getSelectedModel('anthropic')).toBe('claude-haiku-4-5');
+ });
+});
+
+describe('hasAnyKeyConfigured', () => {
+ it('should return false when no keys are set', () => {
+ expect(hasAnyKeyConfigured()).toBe(false);
+ });
+
+ it('should return true when at least one key is set', () => {
+ setApiKey('groq', 'gsk-test');
+ expect(hasAnyKeyConfigured()).toBe(true);
+ });
+
+ it('should return false after clearing all keys', () => {
+ setApiKey('openai', 'sk-test');
+ clearAllApiKeys();
+ expect(hasAnyKeyConfigured()).toBe(false);
+ });
+
+ it('should return true with multiple keys set', () => {
+ setApiKey('openai', 'sk-1');
+ setApiKey('anthropic', 'sk-2');
+ expect(hasAnyKeyConfigured()).toBe(true);
+ });
+});
+
+describe('Error Handling', () => {
+ it('should handle localStorage errors gracefully for getApiKey', () => {
+ const originalGetItem = localStorageMock.getItem;
+ localStorageMock.getItem = vi.fn(() => { throw new Error('quota exceeded'); });
+ expect(getApiKey('openai')).toBeNull();
+ localStorageMock.getItem = originalGetItem;
+ });
+
+ it('should handle localStorage errors gracefully for setApiKey', () => {
+ const originalSetItem = localStorageMock.setItem;
+ localStorageMock.setItem = vi.fn(() => { throw new Error('quota exceeded'); });
+ // Should not throw
+ expect(() => setApiKey('openai', 'sk-test')).not.toThrow();
+ localStorageMock.setItem = originalSetItem;
+ });
+
+ it('should handle localStorage errors gracefully for hasAnyKeyConfigured', () => {
+ Object.defineProperty(localStorageMock, 'length', {
+ get: () => { throw new Error('access denied'); },
+ configurable: true,
+ });
+ expect(hasAnyKeyConfigured()).toBe(false);
+ // Restore
+ Object.defineProperty(localStorageMock, 'length', {
+ get: () => Object.keys(store).length,
+ configurable: true,
+ });
+ });
+});
diff --git a/tests/ai/validation.test.ts b/tests/ai/validation.test.ts
new file mode 100644
index 0000000..2b03107
--- /dev/null
+++ b/tests/ai/validation.test.ts
@@ -0,0 +1,128 @@
+import { describe, it, expect } from 'vitest';
+import { aiValidateSchema, aiGenerateSchema } from '@/lib/ai/validation';
+
+describe('AI Validate Schema', () => {
+ it('should accept valid input', () => {
+ const result = aiValidateSchema.safeParse({
+ provider: 'openai',
+ apiKey: 'sk-test-123',
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('should reject missing provider', () => {
+ const result = aiValidateSchema.safeParse({
+ apiKey: 'sk-test-123',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should reject missing apiKey', () => {
+ const result = aiValidateSchema.safeParse({
+ provider: 'openai',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should reject empty apiKey', () => {
+ const result = aiValidateSchema.safeParse({
+ provider: 'openai',
+ apiKey: '',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should reject unknown provider', () => {
+ const result = aiValidateSchema.safeParse({
+ provider: 'unknown',
+ apiKey: 'sk-test',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should accept all valid providers', () => {
+ for (const provider of ['openai', 'anthropic', 'google', 'groq', 'openrouter']) {
+ const result = aiValidateSchema.safeParse({ provider, apiKey: 'test-key' });
+ expect(result.success).toBe(true);
+ }
+ });
+});
+
+describe('AI Generate Schema', () => {
+ const validInput = {
+ provider: 'openai',
+ apiKey: 'sk-test-123',
+ model: 'gpt-4o-mini',
+ prompt: 'Generate a title for a blog post about REST APIs',
+ };
+
+ it('should accept valid input', () => {
+ const result = aiGenerateSchema.safeParse(validInput);
+ expect(result.success).toBe(true);
+ });
+
+ it('should accept input with all optional fields', () => {
+ const result = aiGenerateSchema.safeParse({
+ ...validInput,
+ systemPrompt: 'You are a copywriter',
+ maxTokens: 200,
+ temperature: 0.7,
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('should reject missing provider', () => {
+ const { provider, ...rest } = validInput;
+ expect(aiGenerateSchema.safeParse(rest).success).toBe(false);
+ });
+
+ it('should reject missing apiKey', () => {
+ const { apiKey, ...rest } = validInput;
+ expect(aiGenerateSchema.safeParse(rest).success).toBe(false);
+ });
+
+ it('should reject missing model', () => {
+ const { model, ...rest } = validInput;
+ expect(aiGenerateSchema.safeParse(rest).success).toBe(false);
+ });
+
+ it('should reject missing prompt', () => {
+ const { prompt, ...rest } = validInput;
+ expect(aiGenerateSchema.safeParse(rest).success).toBe(false);
+ });
+
+ it('should reject empty prompt', () => {
+ expect(aiGenerateSchema.safeParse({ ...validInput, prompt: '' }).success).toBe(false);
+ });
+
+ it('should reject prompt exceeding 10000 chars', () => {
+ const longPrompt = 'a'.repeat(10001);
+ expect(aiGenerateSchema.safeParse({ ...validInput, prompt: longPrompt }).success).toBe(false);
+ });
+
+ it('should reject maxTokens exceeding 4096', () => {
+ expect(aiGenerateSchema.safeParse({ ...validInput, maxTokens: 5000 }).success).toBe(false);
+ });
+
+ it('should reject negative maxTokens', () => {
+ expect(aiGenerateSchema.safeParse({ ...validInput, maxTokens: -1 }).success).toBe(false);
+ });
+
+ it('should reject temperature above 2', () => {
+ expect(aiGenerateSchema.safeParse({ ...validInput, temperature: 2.5 }).success).toBe(false);
+ });
+
+ it('should reject negative temperature', () => {
+ expect(aiGenerateSchema.safeParse({ ...validInput, temperature: -0.1 }).success).toBe(false);
+ });
+
+ it('should accept temperature at boundaries', () => {
+ expect(aiGenerateSchema.safeParse({ ...validInput, temperature: 0 }).success).toBe(true);
+ expect(aiGenerateSchema.safeParse({ ...validInput, temperature: 2 }).success).toBe(true);
+ });
+
+ it('should reject systemPrompt exceeding 5000 chars', () => {
+ const longSystem = 'a'.repeat(5001);
+ expect(aiGenerateSchema.safeParse({ ...validInput, systemPrompt: longSystem }).success).toBe(false);
+ });
+});
diff --git a/tests/api/ai-generate.test.ts b/tests/api/ai-generate.test.ts
new file mode 100644
index 0000000..2cff9de
--- /dev/null
+++ b/tests/api/ai-generate.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from 'vitest';
+import { aiGenerateSchema } from '@/lib/ai/validation';
+
+describe('AI Generate API Schema', () => {
+ const valid = {
+ provider: 'openai',
+ apiKey: 'sk-test',
+ model: 'gpt-4o-mini',
+ prompt: 'Generate titles',
+ };
+
+ it('should accept valid generate request', () => {
+ expect(aiGenerateSchema.safeParse(valid).success).toBe(true);
+ });
+
+ it('should accept with all optional fields', () => {
+ expect(aiGenerateSchema.safeParse({
+ ...valid,
+ systemPrompt: 'You are a helper',
+ maxTokens: 200,
+ temperature: 0.7,
+ }).success).toBe(true);
+ });
+
+ it('should reject missing required fields', () => {
+ const { provider, ...noProvider } = valid;
+ const { apiKey, ...noKey } = valid;
+ const { model, ...noModel } = valid;
+ const { prompt, ...noPrompt } = valid;
+ expect(aiGenerateSchema.safeParse(noProvider).success).toBe(false);
+ expect(aiGenerateSchema.safeParse(noKey).success).toBe(false);
+ expect(aiGenerateSchema.safeParse(noModel).success).toBe(false);
+ expect(aiGenerateSchema.safeParse(noPrompt).success).toBe(false);
+ });
+
+ it('should reject out-of-range values', () => {
+ expect(aiGenerateSchema.safeParse({ ...valid, maxTokens: 5000 }).success).toBe(false);
+ expect(aiGenerateSchema.safeParse({ ...valid, temperature: 3 }).success).toBe(false);
+ expect(aiGenerateSchema.safeParse({ ...valid, prompt: 'a'.repeat(10001) }).success).toBe(false);
+ });
+});
diff --git a/tests/api/ai-validate.test.ts b/tests/api/ai-validate.test.ts
new file mode 100644
index 0000000..497ca85
--- /dev/null
+++ b/tests/api/ai-validate.test.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect } from 'vitest';
+import { aiValidateSchema } from '@/lib/ai/validation';
+
+describe('AI Validate API Schema', () => {
+ it('should accept valid validate request', () => {
+ expect(aiValidateSchema.safeParse({ provider: 'openai', apiKey: 'sk-test' }).success).toBe(true);
+ expect(aiValidateSchema.safeParse({ provider: 'anthropic', apiKey: 'sk-ant-test' }).success).toBe(true);
+ expect(aiValidateSchema.safeParse({ provider: 'google', apiKey: 'key' }).success).toBe(true);
+ expect(aiValidateSchema.safeParse({ provider: 'groq', apiKey: 'gsk-test' }).success).toBe(true);
+ expect(aiValidateSchema.safeParse({ provider: 'openrouter', apiKey: 'sk-or-test' }).success).toBe(true);
+ });
+
+ it('should reject missing fields', () => {
+ expect(aiValidateSchema.safeParse({}).success).toBe(false);
+ expect(aiValidateSchema.safeParse({ provider: 'openai' }).success).toBe(false);
+ expect(aiValidateSchema.safeParse({ apiKey: 'test' }).success).toBe(false);
+ });
+
+ it('should reject invalid provider', () => {
+ expect(aiValidateSchema.safeParse({ provider: 'invalid', apiKey: 'test' }).success).toBe(false);
+ });
+});