scaffold: Next.js 15 + Drizzle + Better Auth + OpenAI + Recharts base
This commit is contained in:
4
src/lib/auth-client.ts
Normal file
4
src/lib/auth-client.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
export const { signIn, signUp, signOut, useSession } = authClient;
|
||||
8
src/lib/auth.ts
Normal file
8
src/lib/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "./db";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: "pg" }),
|
||||
emailAndPassword: { enabled: true },
|
||||
});
|
||||
8
src/lib/db/index.ts
Normal file
8
src/lib/db/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
const client = postgres(connectionString);
|
||||
export const db = drizzle(client, { schema });
|
||||
66
src/lib/db/schema.ts
Normal file
66
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
integer,
|
||||
boolean,
|
||||
varchar,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
export const comparisons = pgTable(
|
||||
"comparisons",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id"),
|
||||
title: text("title").notNull(),
|
||||
query: text("query"),
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
status: text("status", {
|
||||
enum: ["researching", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("researching"),
|
||||
summary: text("summary"),
|
||||
overallData: jsonb("overall_data"),
|
||||
tags: text("tags").array(),
|
||||
isPublic: boolean("is_public").default(false),
|
||||
viewCount: integer("view_count").default(0),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
},
|
||||
(table) => [index("comparisons_user_id_idx").on(table.userId)]
|
||||
);
|
||||
|
||||
export const comparisonItems = pgTable("comparison_items", {
|
||||
id: text("id").primaryKey(),
|
||||
comparisonId: text("comparison_id")
|
||||
.notNull()
|
||||
.references(() => comparisons.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
imageUrl: text("image_url"),
|
||||
researchData: jsonb("research_data"),
|
||||
scores: jsonb("scores"),
|
||||
pros: text("pros").array(),
|
||||
cons: text("cons").array(),
|
||||
order: integer("order").notNull(),
|
||||
});
|
||||
|
||||
export const comparisonDimensions = pgTable("comparison_dimensions", {
|
||||
id: text("id").primaryKey(),
|
||||
comparisonId: text("comparison_id")
|
||||
.notNull()
|
||||
.references(() => comparisons.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
weight: integer("weight").default(1),
|
||||
order: integer("order").notNull(),
|
||||
});
|
||||
|
||||
export type Comparison = typeof comparisons.$inferSelect;
|
||||
export type NewComparison = typeof comparisons.$inferInsert;
|
||||
export type ComparisonItem = typeof comparisonItems.$inferSelect;
|
||||
export type NewComparisonItem = typeof comparisonItems.$inferInsert;
|
||||
export type ComparisonDimension = typeof comparisonDimensions.$inferSelect;
|
||||
65
src/lib/llm/index.ts
Normal file
65
src/lib/llm/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
ComparisonRequest,
|
||||
ComparisonResult,
|
||||
ResearchProgress,
|
||||
} from "./types";
|
||||
import { generateComparison } from "./providers/openai";
|
||||
|
||||
export type {
|
||||
ComparisonRequest,
|
||||
ComparisonResult,
|
||||
ResearchProgress,
|
||||
} from "./types";
|
||||
|
||||
export async function* runResearch(
|
||||
request: ComparisonRequest
|
||||
): AsyncGenerator<ResearchProgress> {
|
||||
yield { stage: "parsing", message: "Analyzing comparison request..." };
|
||||
|
||||
if (!request.items || request.items.length < 2) {
|
||||
yield {
|
||||
stage: "error",
|
||||
error: "At least 2 items are required for comparison",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < request.items.length; i++) {
|
||||
yield {
|
||||
stage: "researching",
|
||||
item: request.items[i],
|
||||
progress: Math.round(((i + 0.5) / request.items.length) * 80),
|
||||
};
|
||||
}
|
||||
|
||||
yield {
|
||||
stage: "synthesizing",
|
||||
message: "Synthesizing research into structured comparison...",
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await generateComparison(request);
|
||||
yield { stage: "complete", result };
|
||||
} catch (error) {
|
||||
yield {
|
||||
stage: "error",
|
||||
error:
|
||||
error instanceof Error ? error.message : "Research failed unexpectedly",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeResearch(
|
||||
request: ComparisonRequest
|
||||
): Promise<ComparisonResult> {
|
||||
for await (const progress of runResearch(request)) {
|
||||
if (progress.stage === "complete") {
|
||||
return progress.result;
|
||||
}
|
||||
if (progress.stage === "error") {
|
||||
throw new Error(progress.error);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Research completed without producing a result");
|
||||
}
|
||||
142
src/lib/llm/providers/openai.ts
Normal file
142
src/lib/llm/providers/openai.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import OpenAI from "openai";
|
||||
import type {
|
||||
ComparisonRequest,
|
||||
ComparisonResult,
|
||||
DimensionResult,
|
||||
ItemResearch,
|
||||
} from "../types";
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
const SYSTEM_PROMPT = `You are an expert research analyst. Your job is to compare items across multiple dimensions and produce structured, insightful comparison data.
|
||||
|
||||
When given a list of items to compare:
|
||||
1. Identify 5-8 relevant comparison dimensions (e.g., price, performance, ease of use, ecosystem, community support, scalability, documentation, etc.)
|
||||
2. Research each item across each dimension
|
||||
3. Score each item 1-10 per dimension (10 = best)
|
||||
4. Generate pros and cons lists for each item
|
||||
5. Write a concise summary comparison
|
||||
6. Provide a clear recommendation
|
||||
|
||||
You MUST respond with valid JSON matching this exact structure:
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"name": "item name",
|
||||
"description": "brief overview of this item in context of the comparison",
|
||||
"overallScore": 7.5,
|
||||
"dimensions": {
|
||||
"Dimension Name": {
|
||||
"score": 8,
|
||||
"summary": "brief assessment",
|
||||
"details": "detailed analysis with specific data points",
|
||||
"pros": ["pro 1", "pro 2"],
|
||||
"cons": ["con 1", "con 2"]
|
||||
}
|
||||
},
|
||||
"pros": ["overall pro 1", "overall pro 2"],
|
||||
"cons": ["overall con 1", "overall con 2"],
|
||||
"sources": [
|
||||
{ "title": "source title", "url": "https://...", "snippet": "relevant excerpt" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"dimensions": ["Dimension 1", "Dimension 2", ...],
|
||||
"summary": "overall comparison summary highlighting key differences and trade-offs",
|
||||
"recommendation": "clear recommendation with reasoning"
|
||||
}
|
||||
|
||||
Important:
|
||||
- Be factual and specific. Include real data where possible.
|
||||
- Scores should be on a 1-10 scale where 10 is best.
|
||||
- Provide at least 2-3 pros and cons per item.
|
||||
- The top-level pros/cons for each item should be the most significant overall points.
|
||||
- Dimension-level pros/cons should be specific to that dimension.
|
||||
- Sources should be realistic URLs to relevant documentation or resources.
|
||||
- The recommendation should consider different use cases when appropriate.`;
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function validateComparisonResult(data: unknown): data is ComparisonResult {
|
||||
if (!data || typeof data !== "object") return false;
|
||||
const result = data as Record<string, unknown>;
|
||||
if (!Array.isArray(result.items)) return false;
|
||||
if (!Array.isArray(result.dimensions)) return false;
|
||||
if (typeof result.summary !== "string") return false;
|
||||
if (typeof result.recommendation !== "string") return false;
|
||||
|
||||
for (const item of result.items as ItemResearch[]) {
|
||||
if (typeof item.name !== "string") return false;
|
||||
if (typeof item.description !== "string") return false;
|
||||
if (typeof item.overallScore !== "number") return false;
|
||||
if (!Array.isArray(item.pros)) return false;
|
||||
if (!Array.isArray(item.cons)) return false;
|
||||
if (typeof item.dimensions !== "object") return false;
|
||||
|
||||
for (const dim of Object.values(item.dimensions) as DimensionResult[]) {
|
||||
if (typeof dim.score !== "number") return false;
|
||||
if (typeof dim.summary !== "string") return false;
|
||||
if (typeof dim.details !== "string") return false;
|
||||
if (!Array.isArray(dim.pros)) return false;
|
||||
if (!Array.isArray(dim.cons)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function generateComparison(
|
||||
request: ComparisonRequest
|
||||
): Promise<ComparisonResult> {
|
||||
const userPrompt = `Compare the following items: ${request.items.join(", ")}
|
||||
${request.query ? `Focus: ${request.query}` : ""}
|
||||
${request.dimensions?.length ? `Specific dimensions to include: ${request.dimensions.join(", ")}` : ""}
|
||||
|
||||
Provide a comprehensive comparison with scores, pros/cons, and a recommendation.`;
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await client.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("Empty response from OpenAI");
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
|
||||
if (!validateComparisonResult(parsed)) {
|
||||
throw new Error("Invalid comparison result structure from OpenAI");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await sleep(RETRY_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to generate comparison after ${MAX_RETRIES} attempts: ${lastError?.message}`
|
||||
);
|
||||
}
|
||||
37
src/lib/llm/types.ts
Normal file
37
src/lib/llm/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface ComparisonRequest {
|
||||
query: string;
|
||||
items: string[];
|
||||
dimensions?: string[];
|
||||
}
|
||||
|
||||
export interface DimensionResult {
|
||||
score: number;
|
||||
summary: string;
|
||||
details: string;
|
||||
pros: string[];
|
||||
cons: string[];
|
||||
}
|
||||
|
||||
export interface ItemResearch {
|
||||
name: string;
|
||||
description: string;
|
||||
overallScore: number;
|
||||
dimensions: Record<string, DimensionResult>;
|
||||
pros: string[];
|
||||
cons: string[];
|
||||
sources: { title: string; url: string; snippet: string }[];
|
||||
}
|
||||
|
||||
export interface ComparisonResult {
|
||||
items: ItemResearch[];
|
||||
dimensions: string[];
|
||||
summary: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export type ResearchProgress =
|
||||
| { stage: "parsing"; message: string }
|
||||
| { stage: "researching"; item: string; progress: number }
|
||||
| { stage: "synthesizing"; message: string }
|
||||
| { stage: "complete"; result: ComparisonResult }
|
||||
| { stage: "error"; error: string };
|
||||
65
src/lib/types.ts
Normal file
65
src/lib/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface DimensionScore {
|
||||
score: number
|
||||
summary: string
|
||||
details: string
|
||||
pros?: string[]
|
||||
cons?: string[]
|
||||
benchmarkData?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export interface ResearchResult {
|
||||
name: string
|
||||
description: string
|
||||
imageUrl?: string
|
||||
overallScore: number
|
||||
dimensions: Record<string, DimensionScore>
|
||||
pros: string[]
|
||||
cons: string[]
|
||||
}
|
||||
|
||||
export interface ComparisonData {
|
||||
id: string
|
||||
userId: string
|
||||
title: string
|
||||
query: string
|
||||
slug: string
|
||||
status: "researching" | "completed" | "failed"
|
||||
summary: string
|
||||
items: ResearchResult[]
|
||||
dimensions: string[]
|
||||
tags: string[]
|
||||
isPublic: boolean
|
||||
viewCount: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
type: "status" | "item" | "dimension" | "complete" | "error"
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export interface ResearchProgress {
|
||||
status: "idle" | "researching" | "completed" | "failed"
|
||||
message: string
|
||||
itemsCompleted: number
|
||||
totalItems: number
|
||||
currentStep: string
|
||||
}
|
||||
|
||||
export const ITEM_COLORS = [
|
||||
"#3b82f6",
|
||||
"#14b8a6",
|
||||
"#8b5cf6",
|
||||
"#f97316",
|
||||
"#f43f5e",
|
||||
"#eab308",
|
||||
"#06b6d4",
|
||||
"#ec4899",
|
||||
"#84cc16",
|
||||
"#6366f1",
|
||||
]
|
||||
|
||||
export function getItemColor(index: number): string {
|
||||
return ITEM_COLORS[index % ITEM_COLORS.length]
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user