Initial commit
This commit is contained in:
27
server/env.ts
Normal file
27
server/env.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const envPath = path.resolve(__dirname, "../.env");
|
||||
|
||||
export function setupEnvironment() {
|
||||
const result = dotenv.config({ path: envPath });
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`Failed to load .env file from ${envPath}: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!process.env.GOOGLE_API_KEY) {
|
||||
throw new Error(
|
||||
"GOOGLE_API_KEY environment variable must be set in .env file"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
};
|
||||
}
|
||||
78
server/index.ts
Normal file
78
server/index.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { setupEnvironment } from "./env";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import { registerRoutes } from "./routes";
|
||||
import { setupVite, serveStatic, log } from "./vite";
|
||||
|
||||
// Setup environment variables first
|
||||
const env = setupEnvironment();
|
||||
console.log("\n--- Environment Setup Debug ---");
|
||||
console.log("Environment variables loaded:", env);
|
||||
console.log("--- End Debug ---\n");
|
||||
|
||||
// Get the directory name properly with ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
const path = req.path;
|
||||
let capturedJsonResponse: Record<string, any> | undefined = undefined;
|
||||
|
||||
const originalResJson = res.json;
|
||||
res.json = function (bodyJson, ...args) {
|
||||
capturedJsonResponse = bodyJson;
|
||||
return originalResJson.apply(res, [bodyJson, ...args]);
|
||||
};
|
||||
|
||||
res.on("finish", () => {
|
||||
const duration = Date.now() - start;
|
||||
if (path.startsWith("/api")) {
|
||||
let logLine = `${req.method} ${path} ${res.statusCode} in ${duration}ms`;
|
||||
if (capturedJsonResponse) {
|
||||
logLine += ` :: ${JSON.stringify(capturedJsonResponse)}`;
|
||||
}
|
||||
|
||||
if (logLine.length > 80) {
|
||||
logLine = logLine.slice(0, 79) + "…";
|
||||
}
|
||||
|
||||
log(logLine);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const server = registerRoutes(app);
|
||||
|
||||
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const status = err.status || err.statusCode || 500;
|
||||
const message = err.message || "Internal Server Error";
|
||||
|
||||
res.status(status).json({ message });
|
||||
throw err;
|
||||
});
|
||||
|
||||
// importantly only setup vite in development and after
|
||||
// setting up all the other routes so the catch-all route
|
||||
// doesn't interfere with the other routes
|
||||
if (app.get("env") === "development") {
|
||||
await setupVite(app, server);
|
||||
} else {
|
||||
serveStatic(app);
|
||||
}
|
||||
|
||||
// ALWAYS serve the app on port 3000
|
||||
// this serves both the API and the client
|
||||
const PORT = 3000;
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
log(`serving on port ${PORT}`);
|
||||
});
|
||||
})();
|
||||
290
server/routes.ts
Normal file
290
server/routes.ts
Normal file
@ -0,0 +1,290 @@
|
||||
import type { Express } from "express";
|
||||
import { createServer, type Server } from "http";
|
||||
import {
|
||||
GoogleGenerativeAI,
|
||||
type ChatSession,
|
||||
type GenerateContentResult,
|
||||
} from "@google/generative-ai";
|
||||
import { marked } from "marked";
|
||||
import { setupEnvironment } from "./env";
|
||||
|
||||
const env = setupEnvironment();
|
||||
const genAI = new GoogleGenerativeAI(env.GOOGLE_API_KEY);
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: "gemini-2.0-flash-exp",
|
||||
generationConfig: {
|
||||
temperature: 0.9,
|
||||
topP: 1,
|
||||
topK: 1,
|
||||
maxOutputTokens: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
// Store chat sessions in memory
|
||||
const chatSessions = new Map<string, ChatSession>();
|
||||
|
||||
// Format raw text into proper markdown
|
||||
async function formatResponseToMarkdown(
|
||||
text: string | Promise<string>
|
||||
): Promise<string> {
|
||||
// Ensure we have a string to work with
|
||||
const resolvedText = await Promise.resolve(text);
|
||||
|
||||
// First, ensure consistent newlines
|
||||
let processedText = resolvedText.replace(/\r\n/g, "\n");
|
||||
|
||||
// Process main sections (lines that start with word(s) followed by colon)
|
||||
processedText = processedText.replace(
|
||||
/^([A-Za-z][A-Za-z\s]+):(\s*)/gm,
|
||||
"## $1$2"
|
||||
);
|
||||
|
||||
// Process sub-sections (any remaining word(s) followed by colon within text)
|
||||
processedText = processedText.replace(
|
||||
/(?<=\n|^)([A-Za-z][A-Za-z\s]+):(?!\d)/gm,
|
||||
"### $1"
|
||||
);
|
||||
|
||||
// Process bullet points
|
||||
processedText = processedText.replace(/^[•●○]\s*/gm, "* ");
|
||||
|
||||
// Split into paragraphs
|
||||
const paragraphs = processedText.split("\n\n").filter(Boolean);
|
||||
|
||||
// Process each paragraph
|
||||
const formatted = paragraphs
|
||||
.map((p) => {
|
||||
// If it's a header or list item, preserve it
|
||||
if (p.startsWith("#") || p.startsWith("*") || p.startsWith("-")) {
|
||||
return p;
|
||||
}
|
||||
// Add proper paragraph formatting
|
||||
return `${p}\n`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
// Configure marked options for better header rendering
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
// Convert markdown to HTML using marked
|
||||
return marked.parse(formatted);
|
||||
}
|
||||
|
||||
interface WebSource {
|
||||
uri: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface GroundingChunk {
|
||||
web?: WebSource;
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface GroundingSupport {
|
||||
segment: TextSegment;
|
||||
groundingChunkIndices: number[];
|
||||
confidenceScores: number[];
|
||||
}
|
||||
|
||||
interface GroundingMetadata {
|
||||
groundingChunks: GroundingChunk[];
|
||||
groundingSupports: GroundingSupport[];
|
||||
searchEntryPoint?: any;
|
||||
webSearchQueries?: string[];
|
||||
}
|
||||
|
||||
export function registerRoutes(app: Express): Server {
|
||||
// Search endpoint - creates a new chat session
|
||||
app.get("/api/search", async (req, res) => {
|
||||
try {
|
||||
const query = req.query.q as string;
|
||||
|
||||
if (!query) {
|
||||
return res.status(400).json({
|
||||
message: "Query parameter 'q' is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new chat session with search capability
|
||||
const chat = model.startChat({
|
||||
tools: [
|
||||
{
|
||||
// @ts-ignore - google_search is a valid tool but not typed in the SDK yet
|
||||
google_search: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Generate content with search tool
|
||||
const result = await chat.sendMessage(query);
|
||||
const response = await result.response;
|
||||
console.log(
|
||||
"Raw Google API Response:",
|
||||
JSON.stringify(
|
||||
{
|
||||
text: response.text(),
|
||||
candidates: response.candidates,
|
||||
groundingMetadata: response.candidates?.[0]?.groundingMetadata,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const text = response.text();
|
||||
|
||||
// Format the response text to proper markdown/HTML
|
||||
const formattedText = await formatResponseToMarkdown(text);
|
||||
|
||||
// Extract sources from grounding metadata
|
||||
const sourceMap = new Map<
|
||||
string,
|
||||
{ title: string; url: string; snippet: string }
|
||||
>();
|
||||
|
||||
// Get grounding metadata from response
|
||||
const metadata = response.candidates?.[0]?.groundingMetadata as any;
|
||||
if (metadata) {
|
||||
const chunks = metadata.groundingChunks || [];
|
||||
const supports = metadata.groundingSupports || [];
|
||||
|
||||
chunks.forEach((chunk: any, index: number) => {
|
||||
if (chunk.web?.uri && chunk.web?.title) {
|
||||
const url = chunk.web.uri;
|
||||
if (!sourceMap.has(url)) {
|
||||
// Find snippets that reference this chunk
|
||||
const snippets = supports
|
||||
.filter((support: any) =>
|
||||
support.groundingChunkIndices.includes(index)
|
||||
)
|
||||
.map((support: any) => support.segment.text)
|
||||
.join(" ");
|
||||
|
||||
sourceMap.set(url, {
|
||||
title: chunk.web.title,
|
||||
url: url,
|
||||
snippet: snippets || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sources = Array.from(sourceMap.values());
|
||||
|
||||
// Generate a session ID and store the chat
|
||||
const sessionId = Math.random().toString(36).substring(7);
|
||||
chatSessions.set(sessionId, chat);
|
||||
|
||||
res.json({
|
||||
sessionId,
|
||||
summary: formattedText,
|
||||
sources,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Search error:", error);
|
||||
res.status(500).json({
|
||||
message:
|
||||
error.message || "An error occurred while processing your search",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Follow-up endpoint - continues existing chat session
|
||||
app.post("/api/follow-up", async (req, res) => {
|
||||
try {
|
||||
const { sessionId, query } = req.body;
|
||||
|
||||
if (!sessionId || !query) {
|
||||
return res.status(400).json({
|
||||
message: "Both sessionId and query are required",
|
||||
});
|
||||
}
|
||||
|
||||
const chat = chatSessions.get(sessionId);
|
||||
if (!chat) {
|
||||
return res.status(404).json({
|
||||
message: "Chat session not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Send follow-up message in existing chat
|
||||
const result = await chat.sendMessage(query);
|
||||
const response = await result.response;
|
||||
console.log(
|
||||
"Raw Google API Follow-up Response:",
|
||||
JSON.stringify(
|
||||
{
|
||||
text: response.text(),
|
||||
candidates: response.candidates,
|
||||
groundingMetadata: response.candidates?.[0]?.groundingMetadata,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
const text = response.text();
|
||||
|
||||
// Format the response text to proper markdown/HTML
|
||||
const formattedText = await formatResponseToMarkdown(text);
|
||||
|
||||
// Extract sources from grounding metadata
|
||||
const sourceMap = new Map<
|
||||
string,
|
||||
{ title: string; url: string; snippet: string }
|
||||
>();
|
||||
|
||||
// Get grounding metadata from response
|
||||
const metadata = response.candidates?.[0]?.groundingMetadata as any;
|
||||
if (metadata) {
|
||||
const chunks = metadata.groundingChunks || [];
|
||||
const supports = metadata.groundingSupports || [];
|
||||
|
||||
chunks.forEach((chunk: any, index: number) => {
|
||||
if (chunk.web?.uri && chunk.web?.title) {
|
||||
const url = chunk.web.uri;
|
||||
if (!sourceMap.has(url)) {
|
||||
// Find snippets that reference this chunk
|
||||
const snippets = supports
|
||||
.filter((support: any) =>
|
||||
support.groundingChunkIndices.includes(index)
|
||||
)
|
||||
.map((support: any) => support.segment.text)
|
||||
.join(" ");
|
||||
|
||||
sourceMap.set(url, {
|
||||
title: chunk.web.title,
|
||||
url: url,
|
||||
snippet: snippets || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sources = Array.from(sourceMap.values());
|
||||
|
||||
res.json({
|
||||
summary: formattedText,
|
||||
sources,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Follow-up error:", error);
|
||||
res.status(500).json({
|
||||
message:
|
||||
error.message ||
|
||||
"An error occurred while processing your follow-up question",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const httpServer = createServer(app);
|
||||
return httpServer;
|
||||
}
|
||||
93
server/vite.ts
Normal file
93
server/vite.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import express, { type Express } from "express";
|
||||
import fs from "fs";
|
||||
import path, { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { createServer as createViteServer, createLogger } from "vite";
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
import { type Server } from "http";
|
||||
import viteConfig from "../vite.config";
|
||||
|
||||
const viteLogger = createLogger();
|
||||
|
||||
export function log(message: string, source = "express") {
|
||||
const formattedTime = new Date().toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
console.log(`${formattedTime} [${source}] ${message}`);
|
||||
}
|
||||
|
||||
export async function setupVite(app: Express, server: Server) {
|
||||
const vite = await createViteServer({
|
||||
...viteConfig,
|
||||
configFile: false,
|
||||
customLogger: {
|
||||
...viteLogger,
|
||||
error: (msg, options) => {
|
||||
if (
|
||||
msg.includes("[TypeScript] Found 0 errors. Watching for file changes")
|
||||
) {
|
||||
log("no errors found", "tsc");
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.includes("[TypeScript] ")) {
|
||||
const [errors, summary] = msg.split("[TypeScript] ", 2);
|
||||
log(`${summary} ${errors}\u001b[0m`, "tsc");
|
||||
return;
|
||||
} else {
|
||||
viteLogger.error(msg, options);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
},
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: { server },
|
||||
},
|
||||
appType: "custom",
|
||||
});
|
||||
|
||||
app.use(vite.middlewares);
|
||||
app.use("*", async (req, res, next) => {
|
||||
const url = req.originalUrl;
|
||||
|
||||
try {
|
||||
const clientTemplate = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"client",
|
||||
"index.html",
|
||||
);
|
||||
|
||||
// always reload the index.html file from disk incase it changes
|
||||
const template = await fs.promises.readFile(clientTemplate, "utf-8");
|
||||
const page = await vite.transformIndexHtml(url, template);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(page);
|
||||
} catch (e) {
|
||||
vite.ssrFixStacktrace(e as Error);
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function serveStatic(app: Express) {
|
||||
const distPath = path.resolve(__dirname, "public");
|
||||
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Could not find the build directory: ${distPath}, make sure to build the client first`,
|
||||
);
|
||||
}
|
||||
|
||||
app.use(express.static(distPath));
|
||||
|
||||
// fall through to index.html if the file doesn't exist
|
||||
app.use("*", (_req, res) => {
|
||||
res.sendFile(path.resolve(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user