Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Server-side only. Never expose real API keys in browser code.
OPENAI_API_KEY=
# Optional local/dev token for provider="chatgpt-oauth" when no ChatGPT Action Authorization header is present.
CHATGPT_OAUTH_ACCESS_TOKEN=
# Optional override for OpenAI-compatible API gateways. Leave blank for https://api.openai.com/v1.
OPENAI_BASE_URL=
# Optional fallback provider for /api/generations when provider is set to "wavespeed".
WAVESPEED_API_KEY=
DATABASE_URL=
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:5173
Expand Down
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,12 @@ src/
server/
auth.ts Better Auth configuration
db/ Neon Drizzle client and auth schema
generation/ GitHub source lookup, launch brief orchestration, and image provider adapters
styles/
app.css Responsive product UI
api/
auth/[...all].ts Vercel Function mounted at /api/auth/*
generations.ts Vercel Function mounted at /api/generations
drizzle/
*.sql Database migrations
public/
Expand Down Expand Up @@ -114,6 +116,12 @@ Secrets belong on the server, not in browser code:

```env
OPENAI_API_KEY=
# Optional local/dev token for provider="chatgpt-oauth" when no ChatGPT Action Authorization header is present.
CHATGPT_OAUTH_ACCESS_TOKEN=
# Optional OpenAI-compatible gateway override. Leave blank for the official OpenAI API.
OPENAI_BASE_URL=
# Optional fallback provider key when /api/generations uses provider="wavespeed".
WAVESPEED_API_KEY=
DATABASE_URL=
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
Expand Down Expand Up @@ -147,14 +155,18 @@ http://localhost:5173/api/auth/callback/google
https://your-domain.example/api/auth/callback/google
```

For live generation, add a Node API layer or Vercel serverless route that:
## Live Generation API

1. fetches GitHub README / metadata / optional PDF context
2. calls the copy model
3. calls the image model
4. stores a manifest with copy, prompt, image path, model settings, and output preset
`POST /api/generations` runs the server-side launch-card workflow:

The current app keeps the core workflow deterministic and client-safe.
1. fetches GitHub repository metadata and README content, with deterministic fallback data for failed source lookups
2. calls OpenAI Responses for repository analysis and launch planning when `provider` is `chatgpt-oauth` or `openai`
3. calls OpenAI Images for the marketing card render and returns `outputs[locale].imageUrl` for browser preview
4. writes prompt, image, quality report, and manifest artifacts under the server output root

The Hero generator sends `provider: "chatgpt-oauth"` by default, and the backend uses `chatgpt-oauth` when `provider` is omitted. For ChatGPT Action traffic, QuickFork reads the OAuth bearer token from the request `Authorization` header and uses it for repo analysis, launch planning, and image generation. For local development or server-side fallback, set `CHATGPT_OAUTH_ACCESS_TOKEN`.

The API still accepts `provider: "openai"` with `OPENAI_API_KEY`, `provider: "mock"` for tests, and `provider: "wavespeed"` for the existing Wavespeed-compatible path. OAuth tokens are never written into manifests or API responses; model call metadata records only the credential source.

## Local Development

Expand Down Expand Up @@ -193,10 +205,7 @@ VERCEL_PROJECT_ID=

## Roadmap

- Fetch GitHub README and repository metadata automatically.
- Add PDF upload and extraction for paper-style repositories.
- Add server-side OpenAI generation using `OPENAI_API_KEY`.
- Save generation manifests for repeatable publishing.
- Export README banners, slide images, and social cards as separate assets.
- Add project pages for public showcases.

Expand Down
56 changes: 46 additions & 10 deletions api/generations.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { normalizeCreateGenerationInput } from "./generations.js";
import { attachRequestAuth, normalizeCreateGenerationInput } from "./generations.js";

describe("/api/generations input contract", () => {
it("normalizes a valid project launch generation request", () => {
Expand All @@ -9,22 +9,22 @@ describe("/api/generations input contract", () => {
repoUrl: " https://github.com/QwenLM/FlashQLA ",
locales: ["en", "zh"],
preset: "4:3",
provider: "mock",
provider: "chatgpt-oauth",
imageQuality: "low",
models: {
llm: "openai/gpt-5.5",
image: "openai/gpt-image-2/text-to-image",
llm: "gpt-5.5",
image: "gpt-image-2",
},
}),
).toMatchObject({
repoUrl: "https://github.com/QwenLM/FlashQLA",
locales: ["en", "zh"],
preset: "4:3",
provider: "mock",
provider: "chatgpt-oauth",
imageQuality: "low",
models: {
llm: "openai/gpt-5.5",
image: "openai/gpt-image-2/text-to-image",
llm: "gpt-5.5",
image: "gpt-image-2",
},
});
});
Expand Down Expand Up @@ -60,12 +60,48 @@ describe("/api/generations input contract", () => {
).toThrow(/locales/i);
});

it("uses the production provider requirement in validation errors", () => {
expect(() =>
it("accepts ChatGPT OAuth, direct OpenAI, and Wavespeed providers while rejecting unknown providers", () => {
expect(
normalizeCreateGenerationInput({
repoUrl: "https://github.com/QwenLM/FlashQLA",
provider: "chatgpt-oauth",
}).provider,
).toBe("chatgpt-oauth");
expect(
normalizeCreateGenerationInput({
repoUrl: "https://github.com/QwenLM/FlashQLA",
provider: "openai",
}).provider,
).toBe("openai");
expect(
normalizeCreateGenerationInput({
repoUrl: "https://github.com/QwenLM/FlashQLA",
provider: "wavespeed",
}).provider,
).toBe("wavespeed");
expect(() =>
normalizeCreateGenerationInput({
repoUrl: "https://github.com/QwenLM/FlashQLA",
provider: "manual-oauth",
}),
).toThrow("provider must be wavespeed.");
).toThrow("provider must be chatgpt-oauth, openai, wavespeed, or mock.");
});

it("attaches ChatGPT Action OAuth bearer tokens from the request header only", () => {
const normalized = normalizeCreateGenerationInput({
repoUrl: "https://github.com/QwenLM/FlashQLA",
provider: "chatgpt-oauth",
auth: {
bearerToken: "body-token-must-not-be-trusted",
},
} as unknown);
const withAuth = attachRequestAuth(normalized, "Bearer “oauth-user-token”");

expect(normalized.auth).toBeUndefined();
expect(withAuth.auth).toEqual({
bearerToken: "oauth-user-token",
source: "request_header",
});
expect(JSON.stringify(withAuth)).not.toContain("Bearer");
});
});
33 changes: 29 additions & 4 deletions api/generations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { runProjectLaunchGeneration } from "../src/server/generation/orchestrator.js";
import type { CreateGenerationInput } from "../src/server/generation/types.js";
import { GenerationError } from "../src/server/generation/types.js";
import { normalizeOpenAIApiKey } from "../src/server/generation/llm.js";

type ApiErrorCode = "VALIDATION_ERROR" | "METHOD_NOT_ALLOWED" | "GENERATION_FAILED";
const localeValues = ["en", "zh", "ja"] as const;
Expand All @@ -18,7 +19,7 @@ const presetValues = [
"16:9",
"21:9",
] as const;
const providerValues = ["mock", "wavespeed"] as const;
const providerValues = ["mock", "chatgpt-oauth", "openai", "wavespeed"] as const;
const qualityValues = ["low"] as const;

function sendJson(res: ServerResponse, statusCode: number, body: unknown) {
Expand Down Expand Up @@ -67,7 +68,7 @@ export function normalizeCreateGenerationInput(body: unknown): CreateGenerationI
if (!body || typeof body !== "object") {
throw new GenerationError("VALIDATION_ERROR", "Request body must be a JSON object.");
}
const value = body as Partial<CreateGenerationInput>;
const { auth: _untrustedAuth, ...value } = body as Partial<CreateGenerationInput> & { auth?: unknown };
if (typeof value.repoUrl !== "string" || !value.repoUrl.trim()) {
throw new GenerationError("VALIDATION_ERROR", "repoUrl is required.");
}
Expand All @@ -80,7 +81,7 @@ export function normalizeCreateGenerationInput(body: unknown): CreateGenerationI
throw new GenerationError("VALIDATION_ERROR", "preset is not supported.");
}
if (value.provider !== undefined && !isOneOf(value.provider, providerValues)) {
throw new GenerationError("VALIDATION_ERROR", "provider must be wavespeed.");
throw new GenerationError("VALIDATION_ERROR", "provider must be chatgpt-oauth, openai, wavespeed, or mock.");
}
if (value.imageQuality !== undefined && !isOneOf(value.imageQuality, qualityValues)) {
throw new GenerationError("VALIDATION_ERROR", "imageQuality must be low.");
Expand All @@ -98,6 +99,30 @@ export function normalizeCreateGenerationInput(body: unknown): CreateGenerationI
} as CreateGenerationInput;
}

function authorizationHeaderValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}

function bearerTokenFromAuthorizationHeader(value: string | string[] | undefined) {
const header = authorizationHeaderValue(value);
if (!header?.trim()) return undefined;
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match?.[1]) return undefined;
return normalizeOpenAIApiKey(match[1]);
}

export function attachRequestAuth(input: CreateGenerationInput, authorizationHeader: string | string[] | undefined): CreateGenerationInput {
const bearerToken = bearerTokenFromAuthorizationHeader(authorizationHeader);
if (!bearerToken) return input;
return {
...input,
auth: {
bearerToken,
source: "request_header",
},
};
}

export default async function handler(req: IncomingMessage, res: ServerResponse) {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
Expand All @@ -107,7 +132,7 @@ export default async function handler(req: IncomingMessage, res: ServerResponse)

try {
const body = await readJsonBody(req);
const input = normalizeCreateGenerationInput(body);
const input = attachRequestAuth(normalizeCreateGenerationInput(body), req.headers.authorization);
const result = await runProjectLaunchGeneration(input);
sendJson(res, 201, result);
} catch (error) {
Expand Down
20 changes: 10 additions & 10 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1357,18 +1357,18 @@ describe("App", () => {
en: {
promptPath: "output/project-launch/qwenlm-flashqla/en/marketing_card_prompt.txt",
imagePath: "output/project-launch/qwenlm-flashqla/en/marketing-card.png",
imageUrl: "https://wavespeed.ai/generated/qwenlm-flashqla.png",
imageUrl: "data:image/png;base64,cXdlbmxtLWZsYXNocWxh",
qualityReportPath: "output/project-launch/qwenlm-flashqla/en/quality-report.json",
},
},
stages: [
{ id: "repo", label: "Repository source", status: "completed" },
{ id: "readme", label: "GPT5.5 README analysis", status: "completed", model: "openai/gpt-5.5" },
{ id: "image", label: "gpt-image-2 render", status: "completed", model: "openai/gpt-image-2/text-to-image" },
{ id: "readme", label: "ChatGPT OAuth README analysis", status: "completed", model: "gpt-5.5" },
{ id: "image", label: "gpt-image-2 render", status: "completed", model: "gpt-image-2" },
],
modelCalls: [
{ provider: "wavespeed", model: "openai/gpt-5.5", purpose: "readme_analysis", status: "completed" },
{ provider: "wavespeed", model: "openai/gpt-image-2/text-to-image", purpose: "image_generation", status: "completed" },
{ provider: "chatgpt-oauth", model: "gpt-5.5", purpose: "readme_analysis", status: "completed" },
{ provider: "chatgpt-oauth", model: "gpt-image-2", purpose: "image_generation", status: "completed" },
],
launchBrief: {
summary: "CUDA kernels for faster attention inference.",
Expand Down Expand Up @@ -1562,19 +1562,19 @@ describe("App", () => {
repoUrl: "https://github.com/QwenLM/FlashQLA",
locales: ["en"],
preset: "4:3",
provider: "wavespeed",
provider: "chatgpt-oauth",
imageQuality: "low",
});
expect(await screen.findByText(/generated gen_qwenlm_flashqla_test/i)).toBeInTheDocument();
const previewImage = await within(form).findByRole("img", { name: /qwenlm\/flashqla launch card/i });
const controls = form.querySelector(".referenceControls");
expect(previewImage).toHaveAttribute("src", "https://wavespeed.ai/generated/qwenlm-flashqla.png");
expect(previewImage.closest(".generationPreview")).toHaveAccessibleName("Generated Wavespeed image result");
expect(previewImage).toHaveAttribute("src", "data:image/png;base64,cXdlbmxtLWZsYXNocWxh");
expect(previewImage.closest(".generationPreview")).toHaveAccessibleName("Generated ChatGPT OAuth image result");
expect(controls).not.toBeNull();
expect((controls?.compareDocumentPosition(previewImage) ?? 0) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(within(form).getByRole("link", { name: /download generated image/i })).toHaveAttribute(
"href",
"https://wavespeed.ai/generated/qwenlm-flashqla.png",
"data:image/png;base64,cXdlbmxtLWZsYXNocWxh",
);
const briefRegion = await screen.findByRole("region", { name: /free repo launch brief/i });
expect(within(briefRegion).getByText(/AI project builders, open-source maintainers/i)).toBeInTheDocument();
Expand Down Expand Up @@ -1733,7 +1733,7 @@ describe("App", () => {
const previewDialog = await screen.findByRole("dialog", { name: /generated image preview/i });
expect(within(previewDialog).getByRole("img", { name: /qwenlm\/flashqla launch card/i })).toHaveAttribute(
"src",
"https://wavespeed.ai/generated/qwenlm-flashqla.png",
"data:image/png;base64,cXdlbmxtLWZsYXNocWxh",
);
expect(within(previewDialog).getByRole("link", { name: /download generated image/i })).toHaveAttribute(
"download",
Expand Down
4 changes: 2 additions & 2 deletions src/components/landing/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ async function createGeneration(input: {
repoUrl: input.repoUrl,
locales: input.locales,
preset: input.preset,
provider: "wavespeed",
provider: "chatgpt-oauth",
imageQuality: input.imageQuality,
}),
});
Expand Down Expand Up @@ -399,7 +399,7 @@ function ProjectLaunchInputPanel() {
</label>
</div>
{generatedImageUrl ? (
<figure className="generationPreview" aria-label="Generated Wavespeed image result">
<figure className="generationPreview" aria-label="Generated ChatGPT OAuth image result">
<button
aria-label="Open generated image preview"
className="generationPreviewButton"
Expand Down
Loading
Loading