diff --git a/.github/workflows/test-and-build.yml b/.github/workflows/test-and-build.yml new file mode 100644 index 0000000..49cdf7e --- /dev/null +++ b/.github/workflows/test-and-build.yml @@ -0,0 +1,83 @@ +name: Test and Docker Build + +on: + push: + branches: [develop] + pull_request: + branches: [develop] + +jobs: + test-and-build: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + ports: ['5432:5432'] + env: + POSTGRES_DB: mini_agent + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 2536 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:alpine + ports: ['6379:6379'] + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install backend dependencies + working-directory: ./apps/backend + run: npm install + + - name: Set backend env variables + run: | + echo "DATABASE_URL=${{ secrets.BACKEND_DATABASE_URL }}" >> .env + echo "REDIS_URL=${{ secrets.BACKEND_REDIS_URL }}" >> .env + echo "GEMINI_API_KEY=${{ secrets.BACKEND_GEMINI_API_KEY }}" >> .env + echo "GEMINI_MODEL=${{ secrets.BACKEND_GEMINI_MODEL }}" >> .env + echo "NODE_ENV=${{ secrets.BACKEND_NODE_ENV }}" >> .env + echo "LOG_LEVEL=${{ secrets.BACKEND_LOG_LEVEL }}" >> .env + echo "RATE_LIMIT_MAX=${{ secrets.BACKEND_RATE_LIMIT_MAX }}" >> .env + echo "RATE_LIMIT_WINDOW=${{ secrets.BACKEND_RATE_LIMIT_WINDOW }}" >> .env + echo "PORT=${{ secrets.BACKEND_PORT }}" >> .env + working-directory: ./apps/backend + + - name: Install frontend dependencies + working-directory: ./apps/frontend + run: npm install + + - name: Set frontend env variables + run: | + echo "VITE_MODE=${{ secrets.FRONTEND_VITE_MODE }}" >> .env + echo "VITE_PORT=${{ secrets.FRONTEND_VITE_PORT }}" >> .env + working-directory: ./apps/frontend + + - name: Run backend tests + working-directory: ./apps/backend + run: npm run test || echo "No tests defined" + + - name: Install docker-compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + + - name: Build Docker images + working-directory: ./apps + run: docker-compose build diff --git a/README.md b/README.md index 5ab8f96..64f5cf0 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,33 @@ POST /api/v1/query "tool": "calculator" } ``` +**🐳 Docker Setup** + +Step 1: Go to the root directory +```bash +cd mini-agent-forge/ +``` +Step 2: Run Docker Compose +``` bash +docker-compose up --build +``` +This will spin up: + +Backend + +Frontend + +PostgreSQL + +Redis + +Once started: + +Backend: http://localhost:8082 + +Frontend: http://localhost:4173 + + 🧰 Local Development Setup āœ… Prerequisites diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..2f4d051 --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +package-lock.json +.env +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile new file mode 100644 index 0000000..2e1af59 --- /dev/null +++ b/apps/backend/Dockerfile @@ -0,0 +1,6 @@ +FROM node:20 +WORKDIR /app +COPY . . +RUN npm install +EXPOSE 8082 +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..2076da4 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,39 @@ +{ + "name": "backend", + "version": "1.0.0", + "main": "index.js", + "type": "module", + "scripts": { + "start": "nodemon --exec tsx src/server.ts", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@fastify/compress": "^6.5.0", + "@fastify/cors": "^8.4.2", + "@fastify/helmet": "^11.1.1", + "@fastify/rate-limit": "^9.1.0", + "@google/genai": "^1.4.0", + "@types/pg": "^8.15.4", + "cheerio": "^1.0.0", + "dotenv": "^16.5.0", + "fastify": "^4.24.3", + "mathjs": "^14.5.2", + "node-fetch": "^3.3.2", + "nodemon": "^3.1.10", + "pg": "^8.16.0", + "redis": "^5.5.5", + "zod": "^3.25.51" + }, + "devDependencies": { + "@types/express": "^5.0.2", + "@types/node": "^22.15.29", + "@types/redis": "^4.0.11", + "pino-pretty": "^13.0.0", + "ts-node": "^10.9.2", + "tsx": "^4.19.4", + "typescript": "^5.8.3" + } +} diff --git a/apps/backend/src/api/routes.ts b/apps/backend/src/api/routes.ts new file mode 100644 index 0000000..4d60490 --- /dev/null +++ b/apps/backend/src/api/routes.ts @@ -0,0 +1,199 @@ +import { FastifyInstance } from "fastify"; +import { RunRequestSchema } from "../validators/runSchema"; +import { performWebSearch } from "../services/webSearch"; +import { evaluateExpression } from "../services/calculator"; +import { generateFriendlyReply } from "../llm/geminiHelper"; +import { cacheRun, getCachedRun } from "../db/redis"; +import { saveRunLog } from "../db/postgres"; +import { RunLog } from "../utils/types"; + +// Convert Zod schema to JSON Schema for Fastify, + +const requestBodySchema = { + type: "object", + required: ["prompt", "tool", "userId"], + properties: { + prompt: { type: "string", minLength: 1, maxLength: 5000 }, + tool: { type: "string", enum: ["web-search", "calculator"] }, + userId: { type: "string", minLength: 1, maxLength: 100 }, + }, + additionalProperties: false, +}; + +const successResponseSchema = { + type: "object", + properties: { + prompt: { type: "string" }, + tool: { type: "string" }, + results: { + type: "array", + items: { + type: "object", + properties: { + title: { type: "string" }, + link: { type: "string" }, + }, + required: ["title", "link"], + }, + }, + result: { type: "string" }, + totalTokenCount: { type: "number" }, + summary: { type: "string" }, + timestamp: { type: "string" }, + }, +}; + +const errorResponseSchema = { + type: "object", + properties: { + error: { type: "string" }, + code: { type: "string" }, + timestamp: { type: "string" }, + }, +}; + +interface RunRequest { + prompt: string; + tool: "web-search" | "calculator"; + userId: string; +} + +export default async function routes(fastify: FastifyInstance) { + // routes for get API server health + fastify.get( + "/health", + { + schema: { + response: { + 200: { + type: "object", + properties: { + status: { type: "string" }, + timestamp: { type: "string" }, + uptime: { type: "number" }, + }, + }, + }, + }, + }, + async () => { + return { + status: "healthy", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }; + } + ); +// routes for Ai response based on user query + fastify.post<{ Body: RunRequest }>( + "/query", + { + schema: { + body: requestBodySchema, + response: { + 200: successResponseSchema, + 400: errorResponseSchema, + 500: errorResponseSchema, + }, + }, + preValidation: async (request, reply) => { + const parseResult = RunRequestSchema.safeParse(request.body); + if (!parseResult.success) { + return reply.code(400).send({ + error: "Invalid input", + code: "VALIDATION_ERROR", + timestamp: new Date().toISOString(), + }); + } + }, + }, + async (request, reply) => { + const { prompt, tool, userId } = request.body; + const startTime = Date.now(); + + try { + const cachedUserQueryAndAiResponse = await getCachedRun(userId, tool, prompt); + + if (cachedUserQueryAndAiResponse) { + fastify.log.info({ msg: "Cache hit", tool, prompt }); + return reply + .code(200) + .send(JSON.parse(cachedUserQueryAndAiResponse.response)); + } + let responseData: any; + + if (tool === "web-search") { + const results = await performWebSearch(prompt); + const { text, totalTokenCount } = await generateFriendlyReply( + "search", + prompt, + results + ); + + responseData = { + prompt, + tool, + results, + totalTokenCount, + summary: text, + timestamp: new Date().toISOString(), + }; + } else if (tool === "calculator") { + const result = evaluateExpression(prompt).toString(); + const { text, totalTokenCount } = await generateFriendlyReply( + "calc", + prompt, + result + ); + + responseData = { + prompt, + tool, + result, + totalTokenCount, + summary: text, + timestamp: new Date().toISOString(), + }; + } + + fastify.log.info({ + method: request.method, + url: request.url, + tool, + responseTime: Date.now() - startTime, + tokenCount: responseData.totalTokenCount, + }); + + const log: RunLog = { + userId, + prompt, + tool, + response: JSON.stringify(responseData), + timestamp: new Date(responseData.timestamp), + tokens: responseData.totalTokenCount, + }; + + await Promise.all([cacheRun(log), saveRunLog(log)]); + + return reply.code(200).send(responseData); + } catch (err) { + const error = err as Error; + + fastify.log.error({ + method: request.method, + url: request.url, + error: error.message, + stack: error.stack, + tool, + prompt: prompt.substring(0, 100), + }); + + return reply.code(500).send({ + error: error.message, + code: "INTERNAL_ERROR", + timestamp: new Date().toISOString(), + }); + } + } + ); +} diff --git a/apps/backend/src/db/postgres.ts b/apps/backend/src/db/postgres.ts new file mode 100644 index 0000000..5b73bdd --- /dev/null +++ b/apps/backend/src/db/postgres.ts @@ -0,0 +1,33 @@ +import { Pool } from 'pg'; +import { RunLog } from '../utils/types'; +import { CREATE_LOG_TABLE_QUERY, SAVE_LOG_QUERY } from '../utils/constants'; +import dotenv from 'dotenv'; + +dotenv.config(); +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +async function initDB() { + try { + await pool.query("SET TIME ZONE 'UTC';"); + // await pool.query('SELECT NOW()'); + console.log('Postgres DB connected'); + await pool.query(CREATE_LOG_TABLE_QUERY); + console.log('run_logs table ensured'); + } catch (err) { + console.error('Postgres DB initialization failed:', err); + } +} + +initDB(); + +export async function saveRunLog(log: RunLog): Promise { + const values = [log.userId,log.prompt, log.tool, log.response, log.timestamp, log.tokens]; + + try { + await pool.query(SAVE_LOG_QUERY, values); + } catch (err) { + console.error('Failed to save run log to Postgres:', err); + } +} diff --git a/apps/backend/src/db/redis.ts b/apps/backend/src/db/redis.ts new file mode 100644 index 0000000..a60b021 --- /dev/null +++ b/apps/backend/src/db/redis.ts @@ -0,0 +1,45 @@ +import { createClient } from "redis"; +import crypto from "crypto"; +import { RunLog } from "../utils/types"; + +const redisClient = createClient({ + url: process.env.REDIS_URL || "redis://localhost:6379", +}); + +redisClient.on("error", (err) => console.error("Redis Client Error", err)); +await redisClient.connect(); + +function getRedisKey(userId:string, tool: string, prompt: string): string { + const hash = crypto.createHash("sha256").update(prompt).digest("hex"); + return `runlog:${userId}:${tool}:${hash}`; +} + +export async function getCachedRun( + userId: string, + tool: string, + prompt: string +): Promise { + const key = getRedisKey(userId, tool, prompt); + const data = await redisClient.get(key); + return data ? JSON.parse(data) : null; +} + +export async function cacheRun(log: RunLog): Promise { + try { + const key = getRedisKey(log.userId, log.tool, log.prompt); + await redisClient.set(key, JSON.stringify(log), { EX: 60 * 60 * 12 }); // expires after 12 hours + + const userZSet = `recent_runlogs:${log.userId}`; // scoped with user uniq id + await redisClient.zAdd(userZSet, { + score: log.timestamp!.getTime(), + value: key, + }); + + const totalCount = await redisClient.zCard(userZSet); + if (totalCount > 10) { + await redisClient.zRemRangeByRank(userZSet, 0, totalCount - 11); + } + } catch (err) { + console.error("Failed to cache run log in Redis:", err); + } +} diff --git a/apps/backend/src/llm/geminiHelper.ts b/apps/backend/src/llm/geminiHelper.ts new file mode 100644 index 0000000..97f9199 --- /dev/null +++ b/apps/backend/src/llm/geminiHelper.ts @@ -0,0 +1,38 @@ +import { GoogleGenAI } from "@google/genai"; +import dotenv from "dotenv"; +dotenv.config(); + +const GEMINI_API_KEY = process.env.GEMINI_API_KEY; +const GEMINI_MODEL = process.env.GEMINI_MODEL || ""; + +if (!GEMINI_API_KEY || !GEMINI_MODEL) { + throw new Error( + "GEMINI_API_KEY or GEMINI_MODEL is not set in environment variables." + ); +} + +const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! }); +export async function generateFriendlyReply( + type: "search" | "calc", + prompt: string, + result: string | { title: string; link: string }[] +): Promise<{ text: string; totalTokenCount: number }> { + const instruction = `You are a concise assistant. Respond only in the following format, and do not add anything else.\n`; + const promptMap = { + search: + `${instruction} Based on the prompt "${prompt}", here’s what I found: ` + + (Array.isArray(result) + ? result.map((r, i) => `${i + 1}. ${r.title} (${r.link})`).join("\n") + : result), + calc: `${instruction} Based on the prompt "${prompt}", the answer to your calculation is ${result}.`, + }; + + const geminiResponse = await ai.models.generateContent({ + model: GEMINI_MODEL, + contents: [{ role: "user", parts: [{ text: promptMap[type] }] }], + }); + const text = geminiResponse?.candidates?.[0]?.content?.parts?.[0]?.text || ""; + const totalTokenCount = geminiResponse?.usageMetadata?.totalTokenCount || 0; + // console.log(geminiResponse); + return { text, totalTokenCount }; +} diff --git a/apps/backend/src/models/log.ts b/apps/backend/src/models/log.ts new file mode 100644 index 0000000..74a9413 --- /dev/null +++ b/apps/backend/src/models/log.ts @@ -0,0 +1 @@ +// log.ts \ No newline at end of file diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts new file mode 100644 index 0000000..4bf9ffd --- /dev/null +++ b/apps/backend/src/server.ts @@ -0,0 +1,111 @@ +import dotenv from "dotenv"; +import router from "./api/routes"; +import Fastify from "fastify"; +import fs from "fs"; +import path from "path"; +import fastifyHelmet from "@fastify/helmet"; +import fastifyRateLimit from "@fastify/rate-limit"; +import fastifyCors from "@fastify/cors"; + +dotenv.config(); + +const isDev = process.env.NODE_ENV === "development"; + +const fastifyOptions: any = { + logger: { + level: process.env.LOG_LEVEL || "info", + transport: isDev ? { target: "pino-pretty" } : undefined, + }, + bodyLimit: 1024 * 1024, // 1MB + trustProxy: true, + keepAliveTimeout: 72000, + connectionTimeout: 10000, +}; + +if (!isDev) { + try { + fastifyOptions.https = { + key: fs.readFileSync(path.resolve(process.env.SSL_KEY_PATH || " ")), + cert: fs.readFileSync(path.resolve(process.env.SSL_CERT_PATH || " ")), + }; + } catch (err) { + const error = err as Error; + console.error("Failed to load SSL certificates:", error.message); + process.exit(1); + } +} + +const fastify = Fastify(fastifyOptions); + +async function registerPlugins() { + await fastify.register(fastifyCors, { + origin: process.env.CLIENT_ORIGIN || (isDev ? "*" : false), + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"], + credentials: true, + }); + if (!isDev) { + await fastify.register(fastifyHelmet, { + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + objectSrc: ["'none'"], + upgradeInsecureRequests: isDev ? [] : ["https:"], + }, + }, + hsts: { maxAge: 31536000, includeSubDomains: true }, // Strict-Transport-Security in prod + xFrameOptions: { action: "deny" }, // Prevent clickjacking + xContentTypeOptions: true, // Prevent MIME-type sniffing + xXssProtection: true, // Enable XSS filtering + }); + + await fastify.register(fastifyRateLimit, { + max: parseInt(process.env.RATE_LIMIT_MAX || "100", 10), + timeWindow: process.env.RATE_LIMIT_WINDOW || "1 minute", + errorResponseBuilder: () => ({ + statusCode: 429, + error: 'Too Many Requests', + message: 'Rate limit exceeded. Please try again later.', + }) + }); + } +} + +async function registerRoutes() { + await fastify.register(router, { prefix: "/api/v1" }); +} +async function start() { + try { + await registerPlugins(); + await registerRoutes(); + + const PORT = parseInt(process.env.PORT || "8082", 10); + const HOST = process.env.HOST || "0.0.0.0"; + + await fastify.listen({ port: PORT, host: HOST }); + console.log( + `Server running at ${isDev ? "http" : "https"}://${HOST}:${PORT}` + ); + } catch (err) { + console.error('Fastify failed to start:', err); + fastify.log.error(err); + process.exit(1); + } +} + +process.on("SIGINT", async () => { + console.log("Received SIGINT, shutting down gracefully..."); + await fastify.close(); + process.exit(0); +}); + +process.on("SIGTERM", async () => { + console.log("Received SIGTERM, shutting down gracefully..."); + await fastify.close(); + process.exit(0); +}); + +if (import.meta.url === `file://${process.argv[1]}`) { + start(); +} diff --git a/apps/backend/src/services/calculator.ts b/apps/backend/src/services/calculator.ts new file mode 100644 index 0000000..4706b82 --- /dev/null +++ b/apps/backend/src/services/calculator.ts @@ -0,0 +1,11 @@ +import { evaluate } from "mathjs"; + +export function evaluateExpression(prompt: string): number { + try { + const result = evaluate(prompt); + if (typeof result !== 'number') throw new Error("Not a valid number result"); + return result; + } catch { + throw new Error("Invalid math expression"); + } +} \ No newline at end of file diff --git a/apps/backend/src/services/webSearch.ts b/apps/backend/src/services/webSearch.ts new file mode 100644 index 0000000..19a61ce --- /dev/null +++ b/apps/backend/src/services/webSearch.ts @@ -0,0 +1,25 @@ +import fetch from 'node-fetch'; +import * as cheerio from 'cheerio'; +import { DUCK_DUCK_GO_BASE_URL } from '../utils/constants'; + +export async function performWebSearch(prompt: string): Promise<{ title: string; link: string }[]> { + const query = encodeURIComponent(prompt); + const res = await fetch(`${DUCK_DUCK_GO_BASE_URL}/html/?q=${query}`); + const html = await res.text(); + const $ = cheerio.load(html); + + const results: { title: string; link: string }[] = []; + + $('.result__a').each((i, el) => { + if (i >= 10) return false; + const title = $(el).text(); + const link = $(el).attr('href'); + if (title && link) results.push({ title, link }); + }); + + if (results.length === 0) { + throw new Error('No search results found.'); + } + + return results; +} diff --git a/apps/backend/src/tests/run.test.ts b/apps/backend/src/tests/run.test.ts new file mode 100644 index 0000000..d04fad4 --- /dev/null +++ b/apps/backend/src/tests/run.test.ts @@ -0,0 +1 @@ +// run.test.ts \ No newline at end of file diff --git a/apps/backend/src/utils/constants.ts b/apps/backend/src/utils/constants.ts new file mode 100644 index 0000000..30e4bd1 --- /dev/null +++ b/apps/backend/src/utils/constants.ts @@ -0,0 +1,17 @@ +export const CREATE_LOG_TABLE_QUERY = ` + CREATE TABLE IF NOT EXISTS run_logs ( + id SERIAL PRIMARY KEY, + userId TEXT NOT NULL, + prompt TEXT NOT NULL, + tool TEXT NOT NULL, + response TEXT NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + tokens INTEGER NOT NULL + ); + `; +export const SAVE_LOG_QUERY = ` + INSERT INTO run_logs (userId, prompt, tool, response, timestamp, tokens) + VALUES ($1, $2, $3, $4, $5, $6) + `; +export const DUCK_DUCK_GO_BASE_URL = "https://html.duckduckgo.com"; + diff --git a/apps/backend/src/utils/types.ts b/apps/backend/src/utils/types.ts new file mode 100644 index 0000000..2cb2940 --- /dev/null +++ b/apps/backend/src/utils/types.ts @@ -0,0 +1,18 @@ +export type ToolType = 'web-search' | 'calculator'; + +export interface RunRequest { + prompt: string; + tool: ToolType; +} + +export interface RunLog extends RunRequest { + userId:string, + response: string; + timestamp?: Date; + tokens?: number; +} + +export interface GeminiResponse { + response: string; + tokensUsed: number; +} \ No newline at end of file diff --git a/apps/backend/src/validators/runSchema.ts b/apps/backend/src/validators/runSchema.ts new file mode 100644 index 0000000..8811fdc --- /dev/null +++ b/apps/backend/src/validators/runSchema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const RunRequestSchema = z.object({ + prompt: z.string().max(500), + tool: z.enum(['web-search', 'calculator']), + userId:z.string().max(100) +}); + +export type RunRequest = z.infer; \ No newline at end of file diff --git a/apps/backend/src/validators/webSearch.ts b/apps/backend/src/validators/webSearch.ts new file mode 100644 index 0000000..1043324 --- /dev/null +++ b/apps/backend/src/validators/webSearch.ts @@ -0,0 +1 @@ +// webSearch \ No newline at end of file diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..21a9c1c --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", // JavaScript version output + "module": "es2022", // Module system (Node.js standard) + "lib": ["ES2020"], // Standard library to include + "outDir": "./dist", // Output folder for compiled JS + "rootDir": "./src", // Source folder for TS files + "strict": true, // Enable all strict type-checking options + "esModuleInterop": true, // Support for importing CommonJS modules + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "moduleResolution": "node", // Resolve modules like Node.js + "resolveJsonModule": true, // Allow importing JSON files + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "sourceMap": true, // Generate source maps for debugging + "allowJs": false, // Do not allow compiling JS files + "types": ["node"] // Includes Node.js types for type checking + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/docker-compose.yml b/apps/docker-compose.yml new file mode 100644 index 0000000..77d015c --- /dev/null +++ b/apps/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.8" + +services: + postgres: + image: postgres:15 + restart: always + environment: + POSTGRES_DB: mini_agent + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 2536 + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:alpine + ports: + - "6379:6379" + + backend: + build: ./backend + ports: + - "8082:8082" + env_file: + - ./backend/.env + depends_on: + - postgres + - redis + + frontend: + build: ./frontend + ports: + - "4173:4173" + env_file: + - ./frontend/.env + depends_on: + - backend + +volumes: + pgdata: diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore new file mode 100644 index 0000000..ba639e2 --- /dev/null +++ b/apps/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +package-lock.json +dist +dist-ssr +*.local +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 0000000..ddab3f3 --- /dev/null +++ b/apps/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20 +WORKDIR /app +COPY . . +RUN npm install +RUN npm run build +EXPOSE 4173 +CMD ["npm", "run", "preview"] diff --git a/apps/frontend/eslint.config.js b/apps/frontend/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/apps/frontend/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/apps/frontend/index.html b/apps/frontend/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/apps/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/apps/frontend/package.json b/apps/frontend/package.json new file mode 100644 index 0000000..b4748d5 --- /dev/null +++ b/apps/frontend/package.json @@ -0,0 +1,45 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview --host 0.0.0.0", + "test": "vitest" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "@tailwindcss/vite": "^4.1.8", + "@testing-library/react": "^16.3.0", + "axios": "^1.9.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "next": "^15.3.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-variants": "^1.0.0", + "tailwindcss": "^4.1.8", + "uuid": "^11.1.0", + "vitest": "^3.2.2", + "zod": "^3.25.51" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/node": "^22.15.29", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "autoprefixer": "^10.4.21", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "jsdom": "^26.1.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/apps/frontend/public/vite.svg b/apps/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/apps/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/App.css b/apps/frontend/src/App.css new file mode 100644 index 0000000..32bd75a --- /dev/null +++ b/apps/frontend/src/App.css @@ -0,0 +1,44 @@ +@import "tailwindcss"; + +/* #root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} */ diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 0000000..c93ce97 --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -0,0 +1,14 @@ + +import './App.css' +import HomePage from './pages' + +function App() { + + return ( + <> + + + ) +} + +export default App diff --git a/apps/frontend/src/assets/react.svg b/apps/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/apps/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/frontend/src/assets/send.svg b/apps/frontend/src/assets/send.svg new file mode 100644 index 0000000..6650482 --- /dev/null +++ b/apps/frontend/src/assets/send.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/frontend/src/components/ErrorMessage.tsx b/apps/frontend/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..a56866c --- /dev/null +++ b/apps/frontend/src/components/ErrorMessage.tsx @@ -0,0 +1,9 @@ +function ErrorMessage({ message }: { message: string }) { + return ( +
+ Error: {message} +
+ ); +} + +export default ErrorMessage diff --git a/apps/frontend/src/components/HistoryEntryComponent.tsx b/apps/frontend/src/components/HistoryEntryComponent.tsx new file mode 100644 index 0000000..3624cc8 --- /dev/null +++ b/apps/frontend/src/components/HistoryEntryComponent.tsx @@ -0,0 +1,70 @@ +import type { HistoryEntry } from '../utils/validator'; +function HistoryEntryComponent({ + entry, + index, + isTyping, +}: { + entry: HistoryEntry; + index: number; + isTyping: boolean; +}) { + return ( +
+

Prompt: {index + 1}

+

+ + {entry.question} ({entry.tool}) + + + + {new Date().toLocaleString()} + +

+ + {entry.loading ? ( +
+ + + + + Processing... +
+ ) : entry.response ? ( + <> +

Response:

+
+ + {entry.displayedResponse && !isTyping && ( + +

Total Token : {entry.tokens}

+ + {new Date(entry.responseTimeStamp).toLocaleString()} + +
+ )} + + ) : null} +
+ ); +} + +export default HistoryEntryComponent diff --git a/apps/frontend/src/components/HistoryList.tsx b/apps/frontend/src/components/HistoryList.tsx new file mode 100644 index 0000000..605ca6e --- /dev/null +++ b/apps/frontend/src/components/HistoryList.tsx @@ -0,0 +1,26 @@ +import HistoryEntryComponent from './HistoryEntryComponent'; +import type { HistoryEntry } from '../utils/validator'; +function HistoryList({ + history, + isTyping, +}: { + history: HistoryEntry[]; + isTyping: boolean; +}) { + if (history.length === 0) { + return ( +

+ No questions submitted yet. Enter a query to see results. +

+ ); + } + return ( +
+ {history.map((entry, index) => ( + + ))} +
+ ); +} + +export default HistoryList diff --git a/apps/frontend/src/components/PromptForm.tsx b/apps/frontend/src/components/PromptForm.tsx new file mode 100644 index 0000000..bfc953a --- /dev/null +++ b/apps/frontend/src/components/PromptForm.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { runPrompt } from "../services/api"; +import { parseSearchResults } from "../utils/commonHelperFunctions"; +import PromptInput from "./PromptInput"; +import StopTypingButton from "./StopTypingButton"; +import ErrorMessage from "./ErrorMessage"; +import HistoryList from "./HistoryList"; +import type { HistoryEntry } from "../utils/validator"; + +export default function PromptForm() { + const [prompt, setPrompt] = useState(""); + const [tool, setTool] = useState<"web-search" | "calculator">("web-search"); + const [loading, setLoading] = useState(false); + const [history, setHistory] = useState([]); + const [error, setError] = useState(null); + const [stopTyping, setStopTyping] = useState(false); + // @ts-ignore + const [timings, setTimings] = useState({ render: 0, query: 0, response: 0 }); + + const responseContainerRef = useRef(null); + + useEffect(() => { + const searchParams = new URLSearchParams(window.location.search); + const paramId = searchParams.get("userId"); + const existingId = sessionStorage.getItem("userId"); + const idToStore = paramId || existingId || uuidv4(); + sessionStorage.setItem("userId", idToStore); + }, []); + + const isTyping: boolean = history.some( + (entry) => + entry.response && entry.displayedResponse.length < entry.response.length + ); + + // Handle typing animation for each response + + useEffect(() => { + const timers: NodeJS.Timeout[] = []; + history.forEach((entry, index) => { + if (stopTyping) return; + if (entry.response && entry.displayedResponse !== entry.response) { + const timer = setInterval(() => { + setHistory((prev) => + prev.map((item, i) => { + if (i === index) { + const nextCharIndex = item.displayedResponse.length + 1; + if (nextCharIndex <= item.response.length) { + return { + ...item, + displayedResponse: item.response.slice(0, nextCharIndex), + }; + } + clearInterval(timer); + } + return item; + }) + ); + }, 30); + timers.push(timer); + } + }); + return () => timers.forEach(clearInterval); + }, [history, stopTyping]); + + useEffect(() => { + if (responseContainerRef.current) { + responseContainerRef.current.scrollTop = + responseContainerRef.current.scrollHeight; + } + }, [history]); + + const handleSubmit = async () => { + if (!prompt) return; + const start = performance.now(); + setLoading(true); + setError(null); + setStopTyping(false); + + const newEntry = { + question: prompt, + tool, + response: null, + displayedResponse: "", + tokens: null, + loading: true, + responseTimeStamp: "", + }; + // @ts-ignore + setHistory((prev) => [...prev, newEntry]); + + const userId = sessionStorage.getItem("userId") || uuidv4(); + sessionStorage.setItem("userId", userId); + + try { + const queryStart = performance.now(); + const result = await runPrompt({ + prompt, + tool: tool as "web-search" | "calculator", + userId, + }); + const queryEnd = performance.now(); + + const parsedResponse = + tool === "calculator" + ? result.summary.replace(/\\?[".,\n]/g, "").trim() + : parseSearchResults(result.summary); + + const finalResponse = Array.isArray(parsedResponse) + ? `Based on the prompt ${prompt} , here’s what I found:
    ` + + parsedResponse + .map( + (item, i) => + `
  • ${i + 1}. ${ + item.title + }
  • ` + ) + .join("") + + `
` + : parsedResponse; + + const end = performance.now(); + + setTimings({ + render: end - start, + query: queryEnd - queryStart, + response: end - queryEnd, + }); + + setHistory((prev) => + prev.map((entry, index) => + index === prev.length - 1 + ? { + ...entry, + response: finalResponse, + displayedResponse: "", + tokens: result.totalTokenCount, + loading: false, + responseTimeStamp: result.timestamp, + } + : entry + ) + ); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "An error occurred"); + setHistory((prev) => + prev.map((entry, index) => + index === prev.length - 1 ? { ...entry, loading: false } : entry + ) + ); + } finally { + setLoading(false); + setPrompt(""); + } + }; + + return ( +
+

+ Mini Agent Forge +

+ +
+ +
+ + {error && } + + + + {isTyping && !stopTyping && ( + { + setStopTyping(true); + setHistory((prev) => + prev.map((item) => ({ + ...item, + displayedResponse: item.response || "", + })) + ); + }} + /> + )} +
+ ); +} diff --git a/apps/frontend/src/components/PromptInput.tsx b/apps/frontend/src/components/PromptInput.tsx new file mode 100644 index 0000000..ca8d8c6 --- /dev/null +++ b/apps/frontend/src/components/PromptInput.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import Spinner from './Spinner'; +import sendIcon from '../assets/send.svg'; + +function PromptInput({ + prompt, + setPrompt, + tool, + setTool, + onSubmit, + loading, + isTyping, +}: { + prompt: string; + setPrompt: React.Dispatch>; + tool: "web-search" | "calculator"; + setTool: React.Dispatch>; + onSubmit: () => void; + loading: boolean; + isTyping: boolean; +}) { + return ( +
+
+