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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Optional: set when frontend and backend are on different origins.
# local dev with Vite proxy can leave this unset.
# VITE_CHAT_API_BASE_URL=https://your-backend.example.com
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
backend/.env

# Editor directories and files
.vscode/*
Expand Down
32 changes: 23 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A modern, responsive portfolio website built with React and TypeScript. This pro
- **Routing**: Client-side navigation with react-router-dom
- **Data Visualization**: Charts and graphs with recharts
- **Accessibility**: Built with accessibility in mind
- **Portfolio Chat**: RAG-style chat backed by a dedicated backend and Cerebras API

## 🛠️ Tech Stack

Expand All @@ -33,51 +34,64 @@ cd portfolio_site

# Install dependencies
pnpm install

# Install backend dependencies
cd backend
pnpm install
cd ..
```

## 🧰 Available Scripts

```bash
# Start development server
bun run dev
pnpm dev
pnpm run dev

# Build for production
bun run build
pnpm build
pnpm run build

# Build for development
bun run build:dev
pnpm build:dev
pnpm run build:dev

# Lint code
bun run lint
pnpm lint
pnpm run lint

# Preview production build
bun run preview
pnpm preview
pnpm run preview
```

## 🚧 Development

1. Start the development server:
```bash
bun run dev
pnpm dev
pnpm run dev
```

2. Start the chat backend in another terminal:
```bash
cd backend
cp .env.example .env.local
# set CEREBRAS_API_KEY in backend/.env.local
bun run dev
```
Comment on lines +76 to +82

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The root README uses bun run dev to start the backend, while the backend’s own README/scripts are written for pnpm dev (and the repo’s packageManager is pnpm). This inconsistency can confuse setup.

Align the command in the root README with the backend package.json scripts (e.g., pnpm dev from within backend/).

Copilot uses AI. Check for mistakes.

2. Open your browser and visit [http://localhost:5173/](http://localhost:5173/)
4. Open your browser and visit [http://localhost:8080/](http://localhost:8080/)

3. Make changes to files in the `src` directory to see live updates
5. Make changes to files in the `src` directory to see live updates

## 🌐 Deployment

Build the project for production:

```bash
bun run build
pnpm build
pnpm run build
```

The build artifacts will be stored in the `dist/` directory, ready to be deployed to your hosting provider of choice.
Expand Down
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
PORT=8787
FRONTEND_ORIGIN=http://localhost:8080,http://localhost:5173
CEREBRAS_API_KEY=
CEREBRAS_MODEL=llama-3.3-70b
25 changes: 25 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Portfolio Chat Backend

Express + TypeScript backend for portfolio chat.

## Features

- `/api/chat` endpoint for frontend chat UI
- Retrieval from:
- `public/llm.txt`
- `src/data/**/*`
- `backend/knowledge/cv/**/*`
- `backend/knowledge/about/**/*`
- Cerebras chat completion integration

## Setup

```bash
cd backend
bun install
cp .env.example .env
# set CEREBRAS_API_KEY in .env
bun run dev
```

Server runs at `http://localhost:8787` by default.
255 changes: 255 additions & 0 deletions backend/bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions backend/knowledge/about/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# About Knowledge

Put additional markdown files here.
These files are included in RAG context automatically.
4 changes: 4 additions & 0 deletions backend/knowledge/cv/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# CV Knowledge

Put CV text/markdown files here.
For PDF, convert to text/markdown before ingest for now.
24 changes: 24 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "portfolio-chat-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.5.5",
"tsx": "^4.19.2",
"typescript": "^5.5.3"
}
}
86 changes: 86 additions & 0 deletions backend/src/ingest/loadDocuments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { SourceDocument } from '../types/chat.js';

const SUPPORTED_EXTENSIONS = new Set(['.md', '.markdown', '.txt', '.json', '.ts', '.tsx']);

type SourceConfig = {
type: SourceDocument['sourceType'];
path: string;
};

const readDirRecursive = async (dir: string): Promise<string[]> => {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files: string[] = [];

for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await readDirRecursive(fullPath)));
continue;
}

if (entry.isFile() && SUPPORTED_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
files.push(fullPath);
}
}

return files;
};

const readSourcePath = async (source: SourceConfig): Promise<SourceDocument[]> => {
try {
const stat = await fs.stat(source.path);

if (stat.isFile()) {
const content = await fs.readFile(source.path, 'utf8');
return [
{
id: source.path,
title: path.basename(source.path),
path: source.path,
content,
sourceType: source.type,
},
];
}

if (stat.isDirectory()) {
const files = await readDirRecursive(source.path);
const docs: SourceDocument[] = [];

for (const file of files) {
const content = await fs.readFile(file, 'utf8');
docs.push({
id: file,
title: path.basename(file),
path: file,
content,
sourceType: source.type,
});
}

return docs;
}
} catch (error) {
console.error(`Failed to read source path "${source.path}":`, error);
return [];
}

return [];
};

export const loadPortfolioDocuments = async (): Promise<SourceDocument[]> => {
const backendRoot = process.cwd();
const projectRoot = path.resolve(backendRoot, '..');

const sources: SourceConfig[] = [
{ type: 'site', path: path.join(projectRoot, 'public', 'llm.txt') },
{ type: 'site', path: path.join(projectRoot, 'src', 'data') },
{ type: 'cv', path: path.join(backendRoot, 'knowledge', 'cv') },
{ type: 'about', path: path.join(backendRoot, 'knowledge', 'about') },
];

const all = await Promise.all(sources.map((source) => readSourcePath(source)));
return all.flat().filter((doc) => doc.content.trim().length > 0);
};
43 changes: 43 additions & 0 deletions backend/src/routes/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Router } from 'express';
import { z } from 'zod';
import { ChatService } from '../services/chatService.js';

const schema = z.object({
message: z.string().min(1),

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message field has a minimum length validation of 1, but no maximum length constraint. This could allow extremely long messages that:

  1. Could hit the 1mb body limit
  2. Generate excessive tokens in the API call
  3. Impact performance

Consider adding a reasonable maximum length constraint, for example:

  • message: z.string().min(1).max(5000)

This would ensure the API is used within expected bounds.

Suggested change
message: z.string().min(1),
message: z.string().min(1).max(5000),

Copilot uses AI. Check for mistakes.
history: z
.array(
z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1),
}),
)

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chat history array has no maximum length validation. This could allow unbounded history arrays to be sent to the API, potentially causing:

  1. Large payload sizes that could trigger the 1mb body limit
  2. Excessive token usage in the Cerebras API call
  3. Potential performance issues

Consider adding a maximum length constraint to the history array validation, for example:

  • history: z.array(...).max(50).default([])

This would prevent abuse and ensure predictable API usage.

Suggested change
)
)
.max(50)

Copilot uses AI. Check for mistakes.
.default([]),
});

export const createChatRouter = (chatService: ChatService) => {
const router = Router();

router.post('/chat', async (req, res) => {
console.log('[api] POST /api/chat');
const parsed = schema.safeParse(req.body);

if (!parsed.success) {
console.warn('[api] invalid request body');
return res.status(400).json({
error: 'Invalid request body',
details: parsed.error.flatten(),
});
}

try {
const result = await chatService.ask(parsed.data.message, parsed.data.history);
return res.status(200).json(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unexpected server error';
console.error(`[api] chat failed: ${message}`);
return res.status(500).json({ error: message });
}
});

return router;
};
53 changes: 53 additions & 0 deletions backend/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import cors from "cors";
import dotenv from "dotenv";
import express from "express";
import { createChatRouter } from "./routes/chat.js";
import { CerebrasClient } from "./services/cerebras.js";
import { ChatService } from "./services/chatService.js";

dotenv.config();
const BASE_MODEL = "llama-3.3-70b";
const port = Number(process.env.PORT ?? 8787);

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PORT is parsed with Number(...), so an invalid value (e.g. PORT=abc) becomes NaN and will cause app.listen(...) to throw at runtime. Consider validating the parsed value (Number.isFinite) and either defaulting to 8787 or throwing a clear configuration error.

Suggested change
const port = Number(process.env.PORT ?? 8787);
const envPort = process.env.PORT;
const port = envPort === undefined ? 8787 : Number(envPort);
if (!Number.isFinite(port)) {
throw new Error(`Invalid PORT environment variable: "${envPort}". Expected a numeric port.`);
}

Copilot uses AI. Check for mistakes.
const cerebrasApiKey = process.env.CEREBRAS_API_KEY;
const cerebrasModel = process.env.CEREBRAS_MODEL ?? BASE_MODEL;
const frontendOrigins = (
process.env.FRONTEND_ORIGIN ?? "http://localhost:8080,http://localhost:5173"
)
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);

if (!cerebrasApiKey) {
throw new Error("CEREBRAS_API_KEY is required. Add it to backend/.env");
}

const app = express();
app.use(
cors({
origin: (origin, callback) => {
if (!origin || frontendOrigins.includes(origin)) {
callback(null, true);
return;
Comment on lines +27 to +30

Copilot AI Feb 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CORS origin check allows requests with no origin (line 26). While this is necessary for some scenarios (like server-to-server requests or certain development tools), it could potentially allow requests from contexts where the origin header is stripped or not sent.

In production, consider:

  1. Being more restrictive about requests without an origin header
  2. Adding additional validation for requests without an origin
  3. Documenting why requests without origins are allowed

However, this is often necessary for development and non-browser clients, so this may be intentional. Just ensure it aligns with your security requirements.

Copilot uses AI. Check for mistakes.
}
callback(new Error(`Origin not allowed by CORS: ${origin}`));
},
}),
);
app.use(express.json({ limit: "1mb" }));

const chatService = new ChatService(
new CerebrasClient({
apiKey: cerebrasApiKey,
model: cerebrasModel,
}),
);

app.get("/health", (_req, res) => {
res.status(200).json({ ok: true });
});

app.use("/api", createChatRouter(chatService));

app.listen(port, () => {
console.log(`Portfolio chat backend listening on http://localhost:${port}`);
});
Loading