Compare commits
4 Commits
feat/llm-e
...
66a2d647bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a2d647bb | ||
|
|
2c2fd3547c | ||
|
|
3568e2f008 | ||
|
|
d8ff5f4bb1 |
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
version: "3.8"
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/comparaison
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: comparaison
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -18,10 +18,6 @@ function slugify(text: string): string {
|
|||||||
.slice(0, 200);
|
.slice(0, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement rate limiting per IP/user
|
|
||||||
// Example: Use Upstash Ratelimit or a simple in-memory counter
|
|
||||||
// const ratelimit = new Ratelimit({ redis, limiter: slidingWindow(5, "1m") })
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const body: { query?: string; items?: string[]; dimensions?: string[] } =
|
const body: { query?: string; items?: string[]; dimensions?: string[] } =
|
||||||
await request.json();
|
await request.json();
|
||||||
@@ -29,21 +25,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (!items || items.length < 2) {
|
if (!items || items.length < 2) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "At least 2 items are required for comparison" },
|
{ error: "At least 2 items are required" },
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length > 10) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Maximum 10 items allowed per comparison" },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.some((item) => item.trim().length === 0)) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "Item names cannot be empty" },
|
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,20 +70,6 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress.stage === "searching") {
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(
|
|
||||||
serializeSSE("progress", {
|
|
||||||
status: "researching",
|
|
||||||
message: `Searching the web for ${progress.item}... (${progress.results} results found)`,
|
|
||||||
itemsCompleted,
|
|
||||||
totalItems: items.length,
|
|
||||||
currentStep: `Searching ${progress.item}`,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress.stage === "researching") {
|
if (progress.stage === "researching") {
|
||||||
itemsCompleted++;
|
itemsCompleted++;
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
@@ -134,17 +102,7 @@ export async function POST(request: Request) {
|
|||||||
if (progress.stage === "complete") {
|
if (progress.stage === "complete") {
|
||||||
const result = progress.result;
|
const result = progress.result;
|
||||||
|
|
||||||
const comparisonData: Omit<
|
const comparisonData: Omit<ComparisonData, "id" | "userId" | "slug" | "tags" | "isPublic" | "viewCount" | "createdAt" | "updatedAt"> = {
|
||||||
ComparisonData,
|
|
||||||
| "id"
|
|
||||||
| "userId"
|
|
||||||
| "slug"
|
|
||||||
| "tags"
|
|
||||||
| "isPublic"
|
|
||||||
| "viewCount"
|
|
||||||
| "createdAt"
|
|
||||||
| "updatedAt"
|
|
||||||
> = {
|
|
||||||
title,
|
title,
|
||||||
query: query || "",
|
query: query || "",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -219,7 +177,7 @@ export async function POST(request: Request) {
|
|||||||
encoder.encode(
|
encoder.encode(
|
||||||
serializeSSE("progress", {
|
serializeSSE("progress", {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
message: `Comparison failed: ${progress.error}`,
|
message: progress.error,
|
||||||
itemsCompleted,
|
itemsCompleted,
|
||||||
totalItems: items.length,
|
totalItems: items.length,
|
||||||
currentStep: "Failed",
|
currentStep: "Failed",
|
||||||
@@ -234,16 +192,11 @@ export async function POST(request: Request) {
|
|||||||
.set({ status: "failed", updatedAt: new Date() })
|
.set({ status: "failed", updatedAt: new Date() })
|
||||||
.where(eq(comparisons.id, id));
|
.where(eq(comparisons.id, id));
|
||||||
|
|
||||||
const message =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "An unexpected error occurred during research";
|
|
||||||
|
|
||||||
controller.enqueue(
|
controller.enqueue(
|
||||||
encoder.encode(
|
encoder.encode(
|
||||||
serializeSSE("progress", {
|
serializeSSE("progress", {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
message: `Comparison failed: ${message}`,
|
message: error instanceof Error ? error.message : "Unknown error",
|
||||||
itemsCompleted,
|
itemsCompleted,
|
||||||
totalItems: items.length,
|
totalItems: items.length,
|
||||||
currentStep: "Failed",
|
currentStep: "Failed",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
|
import * as schema from "./db/schema";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: drizzleAdapter(db, { provider: "pg" }),
|
database: drizzleAdapter(db, { provider: "pg", schema }),
|
||||||
emailAndPassword: { enabled: true },
|
emailAndPassword: { enabled: true },
|
||||||
|
session: { expiresIn: 60 * 60 * 24 * 7 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,27 @@ import {
|
|||||||
index,
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const users = pgTable("users", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name"),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
emailVerified: boolean("email_verified").default(false),
|
||||||
|
image: text("image"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessions = pgTable("sessions", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
export const comparisons = pgTable(
|
export const comparisons = pgTable(
|
||||||
"comparisons",
|
"comparisons",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import type {
|
|||||||
ComparisonResult,
|
ComparisonResult,
|
||||||
ResearchProgress,
|
ResearchProgress,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { searchItem, type SearchResult } from "./providers/tavily";
|
import { generateComparison } from "./providers/openai";
|
||||||
import { generateComparisonWithResearch } from "./providers/openai";
|
|
||||||
import { getActiveProvider } from "./providers";
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
ComparisonRequest,
|
ComparisonRequest,
|
||||||
@@ -26,28 +24,6 @@ export async function* runResearch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = getActiveProvider();
|
|
||||||
const searchResults: Record<string, SearchResult[]> = {};
|
|
||||||
|
|
||||||
if (provider.hasSearch) {
|
|
||||||
for (let i = 0; i < request.items.length; i++) {
|
|
||||||
const item = request.items[i];
|
|
||||||
const results = await searchItem(item, request.query);
|
|
||||||
searchResults[item] = results;
|
|
||||||
|
|
||||||
yield {
|
|
||||||
stage: "searching",
|
|
||||||
item,
|
|
||||||
results: results.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
yield {
|
|
||||||
stage: "researching",
|
|
||||||
item,
|
|
||||||
progress: Math.round(((i + 1) / request.items.length) * 50),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < request.items.length; i++) {
|
for (let i = 0; i < request.items.length; i++) {
|
||||||
yield {
|
yield {
|
||||||
stage: "researching",
|
stage: "researching",
|
||||||
@@ -55,15 +31,14 @@ export async function* runResearch(
|
|||||||
progress: Math.round(((i + 0.5) / request.items.length) * 80),
|
progress: Math.round(((i + 0.5) / request.items.length) * 80),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
stage: "synthesizing",
|
stage: "synthesizing",
|
||||||
message: `Synthesizing research into structured comparison using ${provider.name}...`,
|
message: "Synthesizing research into structured comparison...",
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await provider.synthesize(request, searchResults);
|
const result = await generateComparison(request);
|
||||||
yield { stage: "complete", result };
|
yield { stage: "complete", result };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
yield {
|
yield {
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { ComparisonRequest, ComparisonResult } from "../types";
|
|
||||||
import type { SearchResult } from "./tavily";
|
|
||||||
import { generateComparisonWithResearch, generateComparison } from "./openai";
|
|
||||||
import { synthesizeResearch } from "./perplexity";
|
|
||||||
|
|
||||||
export interface Provider {
|
|
||||||
name: string;
|
|
||||||
hasSearch: boolean;
|
|
||||||
synthesize: (
|
|
||||||
request: ComparisonRequest,
|
|
||||||
searchResults: Record<string, SearchResult[]>
|
|
||||||
) => Promise<ComparisonResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getActiveProvider(): Provider {
|
|
||||||
const hasTavily = !!process.env.TAVILY_API_KEY;
|
|
||||||
const hasPerplexity = !!process.env.PERPLEXITY_API_KEY;
|
|
||||||
const hasOpenAI = !!process.env.OPENAI_API_KEY;
|
|
||||||
|
|
||||||
if (hasTavily && hasPerplexity) {
|
|
||||||
console.log("[llm] Using provider: Tavily search + Perplexity synthesis");
|
|
||||||
return {
|
|
||||||
name: "Tavily + Perplexity",
|
|
||||||
hasSearch: true,
|
|
||||||
synthesize: synthesizeResearch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasTavily && hasOpenAI) {
|
|
||||||
console.log("[llm] Using provider: Tavily search + OpenAI synthesis");
|
|
||||||
return {
|
|
||||||
name: "Tavily + OpenAI",
|
|
||||||
hasSearch: true,
|
|
||||||
synthesize: generateComparisonWithResearch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasOpenAI) {
|
|
||||||
console.log("[llm] Using provider: OpenAI only (no web search)");
|
|
||||||
return {
|
|
||||||
name: "OpenAI",
|
|
||||||
hasSearch: false,
|
|
||||||
synthesize: async (request) => generateComparison(request),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(
|
|
||||||
"[llm] No API keys configured. Research will fail at synthesis."
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
name: "None",
|
|
||||||
hasSearch: false,
|
|
||||||
synthesize: async () => {
|
|
||||||
throw new Error(
|
|
||||||
"No LLM provider configured. Set OPENAI_API_KEY, TAVILY_API_KEY, or PERPLEXITY_API_KEY."
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { searchItem, type SearchResult } from "./tavily";
|
|
||||||
export { generateComparison, generateComparisonWithResearch } from "./openai";
|
|
||||||
export { synthesizeResearch } from "./perplexity";
|
|
||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
DimensionResult,
|
DimensionResult,
|
||||||
ItemResearch,
|
ItemResearch,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { SearchResult } from "./tavily";
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
const client = new OpenAI({
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
@@ -141,75 +140,3 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation.
|
|||||||
`Failed to generate comparison after ${MAX_RETRIES} attempts: ${lastError?.message}`
|
`Failed to generate comparison after ${MAX_RETRIES} attempts: ${lastError?.message}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateComparisonWithResearch(
|
|
||||||
request: ComparisonRequest,
|
|
||||||
searchResults: Record<string, SearchResult[]>
|
|
||||||
): Promise<ComparisonResult> {
|
|
||||||
const allResults = Object.values(searchResults).flat();
|
|
||||||
if (allResults.length === 0) {
|
|
||||||
return generateComparison(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
let researchContext = "Web research data:\n\n";
|
|
||||||
for (const [itemName, results] of Object.entries(searchResults)) {
|
|
||||||
if (results.length === 0) continue;
|
|
||||||
researchContext += `=== ${itemName} ===\n`;
|
|
||||||
for (const r of results) {
|
|
||||||
researchContext += `- ${r.title}: ${r.content}\n Source: ${r.url}\n`;
|
|
||||||
}
|
|
||||||
researchContext += "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPrompt = `Compare the following items: ${request.items.join(", ")}
|
|
||||||
${request.query ? `Focus: ${request.query}` : ""}
|
|
||||||
${request.dimensions?.length ? `Specific dimensions to include: ${request.dimensions.join(", ")}` : ""}
|
|
||||||
|
|
||||||
${researchContext}
|
|
||||||
|
|
||||||
Use the web research data above to provide factual, data-driven insights. Reference specific data points in your analysis. 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: "system",
|
|
||||||
content:
|
|
||||||
"You have access to real web search results. Use them to ground your comparison in factual data. Cite specific findings from the research when scoring and analyzing items.",
|
|
||||||
},
|
|
||||||
{ 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 with research after ${MAX_RETRIES} attempts: ${lastError?.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import type { ComparisonRequest, ComparisonResult } from "../types";
|
|
||||||
import type { SearchResult } from "./tavily";
|
|
||||||
import { generateComparisonWithResearch } from "./openai";
|
|
||||||
|
|
||||||
const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are a research synthesis engine. Given web search results for multiple items, produce a structured JSON comparison.
|
|
||||||
|
|
||||||
You MUST respond with valid JSON matching this exact structure:
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"name": "item name",
|
|
||||||
"description": "brief overview",
|
|
||||||
"overallScore": 7.5,
|
|
||||||
"dimensions": {
|
|
||||||
"Dimension Name": {
|
|
||||||
"score": 8,
|
|
||||||
"summary": "brief assessment",
|
|
||||||
"details": "detailed analysis",
|
|
||||||
"pros": ["pro 1"],
|
|
||||||
"cons": ["con 1"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pros": ["overall pro 1"],
|
|
||||||
"cons": ["overall con 1"],
|
|
||||||
"sources": [{ "title": "source", "url": "https://...", "snippet": "excerpt" }]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"dimensions": ["Dimension 1", "Dimension 2"],
|
|
||||||
"summary": "comparison summary",
|
|
||||||
"recommendation": "clear recommendation"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
export async function synthesizeResearch(
|
|
||||||
request: ComparisonRequest,
|
|
||||||
searchResults: Record<string, SearchResult[]>
|
|
||||||
): Promise<ComparisonResult> {
|
|
||||||
const apiKey = process.env.PERPLEXITY_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
return generateComparisonWithResearch(request, searchResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allResults = Object.values(searchResults).flat();
|
|
||||||
if (allResults.length === 0) {
|
|
||||||
return generateComparisonWithResearch(request, searchResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
let researchContext = "Search results for each item:\n\n";
|
|
||||||
for (const [itemName, results] of Object.entries(searchResults)) {
|
|
||||||
if (results.length === 0) continue;
|
|
||||||
researchContext += `=== ${itemName} ===\n`;
|
|
||||||
for (const r of results) {
|
|
||||||
researchContext += `- ${r.title}: ${r.content}\n Source: ${r.url}\n`;
|
|
||||||
}
|
|
||||||
researchContext += "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPrompt = `Compare: ${request.items.join(", ")}
|
|
||||||
${request.query ? `Focus: ${request.query}` : ""}
|
|
||||||
${request.dimensions?.length ? `Dimensions: ${request.dimensions.join(", ")}` : ""}
|
|
||||||
|
|
||||||
${researchContext}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(PERPLEXITY_API_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: "sonar",
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: SYSTEM_PROMPT },
|
|
||||||
{ role: "user", content: userPrompt },
|
|
||||||
],
|
|
||||||
temperature: 0.3,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(
|
|
||||||
`Perplexity API error: ${response.status} ${response.statusText}`
|
|
||||||
);
|
|
||||||
return generateComparisonWithResearch(request, searchResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const content = data.choices?.[0]?.message?.content;
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
console.error("Empty response from Perplexity");
|
|
||||||
return generateComparisonWithResearch(request, searchResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed: unknown = JSON.parse(content);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!parsed ||
|
|
||||||
typeof parsed !== "object" ||
|
|
||||||
!Array.isArray((parsed as Record<string, unknown>).items) ||
|
|
||||||
!Array.isArray((parsed as Record<string, unknown>).dimensions)
|
|
||||||
) {
|
|
||||||
console.error("Invalid structure from Perplexity, falling back to OpenAI");
|
|
||||||
return generateComparisonWithResearch(request, searchResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed as ComparisonResult;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
"Perplexity synthesis failed, falling back to OpenAI:",
|
|
||||||
error instanceof Error ? error.message : error
|
|
||||||
);
|
|
||||||
return generateComparisonWithResearch(request, searchResults);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
export interface SearchResult {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
content: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAVILY_API_URL = "https://api.tavily.com/search";
|
|
||||||
|
|
||||||
export async function searchItem(
|
|
||||||
itemName: string,
|
|
||||||
context: string
|
|
||||||
): Promise<SearchResult[]> {
|
|
||||||
const apiKey = process.env.TAVILY_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const query = `${itemName} ${context}`.trim();
|
|
||||||
|
|
||||||
const response = await fetch(TAVILY_API_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
api_key: apiKey,
|
|
||||||
query,
|
|
||||||
max_results: 5,
|
|
||||||
include_answer: false,
|
|
||||||
search_depth: "advanced",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(
|
|
||||||
`Tavily API error: ${response.status} ${response.statusText}`
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.results || !Array.isArray(data.results)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.results.map(
|
|
||||||
(result: {
|
|
||||||
title?: string;
|
|
||||||
url?: string;
|
|
||||||
content?: string;
|
|
||||||
score?: number;
|
|
||||||
}) => ({
|
|
||||||
title: result.title ?? "",
|
|
||||||
url: result.url ?? "",
|
|
||||||
content: result.content ?? "",
|
|
||||||
score: result.score ?? 0,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Tavily search failed for "${itemName}":`,
|
|
||||||
error instanceof Error ? error.message : error
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ export interface ComparisonResult {
|
|||||||
|
|
||||||
export type ResearchProgress =
|
export type ResearchProgress =
|
||||||
| { stage: "parsing"; message: string }
|
| { stage: "parsing"; message: string }
|
||||||
| { stage: "searching"; item: string; results: number }
|
|
||||||
| { stage: "researching"; item: string; progress: number }
|
| { stage: "researching"; item: string; progress: number }
|
||||||
| { stage: "synthesizing"; message: string }
|
| { stage: "synthesizing"; message: string }
|
||||||
| { stage: "complete"; result: ComparisonResult }
|
| { stage: "complete"; result: ComparisonResult }
|
||||||
|
|||||||
@@ -1,20 +1,29 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
const publicPaths = ["/sign-in", "/sign-up", "/api/auth"];
|
const publicPaths = ["/", "/explore", "/sign-in", "/sign-up", "/api/auth"];
|
||||||
|
const protectedPaths = ["/compare", "/profile"];
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/_next") ||
|
||||||
|
pathname.startsWith("/favicon") ||
|
||||||
|
pathname.includes(".")
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
const isPublic = publicPaths.some(
|
const isPublic = publicPaths.some(
|
||||||
(path) => pathname === path || pathname.startsWith(path + "/"),
|
(path) => pathname === path || pathname.startsWith(path + "/"),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isPublic) {
|
const isProtected = protectedPaths.some(
|
||||||
return NextResponse.next();
|
(path) => pathname === path || pathname.startsWith(path + "/"),
|
||||||
}
|
);
|
||||||
|
|
||||||
if (pathname.startsWith("/_next") || pathname.startsWith("/favicon")) {
|
if (isPublic && !isProtected) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +31,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session && isProtected) {
|
||||||
const signInUrl = new URL("/sign-in", request.url);
|
const signInUrl = new URL("/sign-in", request.url);
|
||||||
signInUrl.searchParams.set("callbackUrl", pathname);
|
signInUrl.searchParams.set("callbackUrl", pathname);
|
||||||
return NextResponse.redirect(signInUrl);
|
return NextResponse.redirect(signInUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user