Skip to content
Merged
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
99 changes: 42 additions & 57 deletions app/bot_scripts/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ async function sendMessage() {
const typing = showTyping();

try {
const response = await fetch(`${config.apiUrl}/chat/stream`, {
const isLocal =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
const endpoint = isLocal
? `${config.apiUrl}/chat/stream`
: `${config.apiUrl}/chat`;

const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand All @@ -68,73 +75,51 @@ async function sendMessage() {
}),
});

// Create bot message container (but don't remove typing yet)
const div = document.createElement("div");
div.className = "chat-message bot";
const label = document.createElement("div");
label.className = "bot-label";
label.textContent = config.botName;
div.appendChild(label);
typing.remove();

// Stream the response
let fullResponse = "";
let firstChunk = true;
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;

// Remove typing indicator on first chunk
if (firstChunk) {
typing.remove();
chatMessages.appendChild(div);
firstChunk = false;
}

const chunk = decoder.decode(value, { stream: true });
console.log('[STREAM] chunk:', chunk.substring(0, 20)); // STREAM_DEBUG
fullResponse += chunk;

// Update the message content
while (div.childNodes.length > 1) {
div.removeChild(div.lastChild);
}

const formatter = config.formatMessage || defaultFormatMessage;
formatter(fullResponse, div);

chatMessages.scrollTop = chatMessages.scrollHeight;
}

while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
fullResponse += chunk;

// Update the message content
// Remove old text nodes (keep the label)
while (div.childNodes.length > 1) {
div.removeChild(div.lastChild);
if (isLocal) {
// Streaming: render chunks as they arrive
const div = document.createElement("div");
div.className = "chat-message bot";
const label = document.createElement("div");
label.className = "bot-label";
label.textContent = config.botName;
div.appendChild(label);
chatMessages.appendChild(div);

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
console.log("[STREAM] chunk:", chunk.substring(0, 20)); // STREAM_DEBUG
fullResponse += chunk;

while (div.childNodes.length > 1) {
div.removeChild(div.lastChild);
}

const formatter = config.formatMessage || defaultFormatMessage;
formatter(fullResponse, div);
chatMessages.scrollTop = chatMessages.scrollHeight;
}

// Re-render with formatter
const formatter = config.formatMessage || defaultFormatMessage;
formatter(fullResponse, div);

chatMessages.scrollTop = chatMessages.scrollHeight;
} else {
// Production: parse JSON response
const data = await response.json();
fullResponse = data.response;
addMessage(fullResponse, "bot");
}

// Track both sides of the exchange for follow-ups
conversationHistory.push(
{ role: "user", content: message },
{ role: "assistant", content: fullResponse },
);

// Cap at 10 exchanges (20 messages) to keep token costs in check
const maxMessages = 20;
if (conversationHistory.length > maxMessages) {
conversationHistory.splice(0, conversationHistory.length - maxMessages);
Expand Down
13 changes: 6 additions & 7 deletions terraform/cloudfront.tf
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ resource "aws_cloudfront_distribution" "website" {
origin_access_control_id = aws_cloudfront_origin_access_control.website.id
}

# API Gateway origin
# Lambda Function URL origin (replaces API Gateway)
origin {
domain_name = "${var.api_gateway_id}.execute-api.us-east-1.amazonaws.com"
origin_id = "APIGateway"
origin_path = "/prod"
domain_name = replace(replace(aws_lambda_function_url.streaming.function_url, "https://", ""), "/", "")
origin_id = "LambdaFunctionURL"

custom_origin_config {
http_port = 80
Expand All @@ -36,12 +35,12 @@ resource "aws_cloudfront_distribution" "website" {
}
}

# Route /api/* to API Gateway — must come before default_cache_behavior
# Route /api/* to Lambda Function URL — must come before default_cache_behavior
ordered_cache_behavior {
path_pattern = "/api/*"
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "APIGateway"
target_origin_id = "LambdaFunctionURL"
viewer_protocol_policy = "redirect-to-https"
compress = true

Expand Down Expand Up @@ -112,4 +111,4 @@ resource "aws_cloudfront_distribution" "website" {
}

depends_on = [aws_acm_certificate_validation.website]
}
}
3 changes: 2 additions & 1 deletion terraform/iam.tf
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ resource "aws_iam_role_policy" "lambda_bedrock" {
{
Effect = "Allow"
Action = [
"bedrock:InvokeModel"
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
]
Resource = [
"arn:aws:bedrock:*::foundation-model/*",
Expand Down
40 changes: 33 additions & 7 deletions terraform/lambda.tf
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,27 @@ resource "aws_lambda_function" "fastapi_app" {
s3_key = "lambda/fastapi-app.zip"
function_name = "${var.project_name}-api"
role = aws_iam_role.lambda_execution.arn
handler = "lambda_handler.handler"
handler = "run.sh"
source_code_hash = filebase64sha256("${path.module}/builds/fastapi-app.zip")
runtime = "python3.12"
timeout = 30
memory_size = 512

layers = [
"arn:aws:lambda:us-east-1:753240598075:layer:LambdaAdapterLayerX86:24"
]

environment {
variables = {
DYNAMODB_TABLE = aws_dynamodb_table.resume_data.name
RECAPTCHA_SECRET_KEY = var.recaptcha_secret_key
SES_FROM_EMAIL = "robmrose@me.com"
SES_TO_EMAIL = "robmrose@me.com"
OPENAI_API_KEY = var.openai_api_key
ANTHROPIC_API_KEY = var.anthropic_api_key
DYNAMODB_TABLE = aws_dynamodb_table.resume_data.name
RECAPTCHA_SECRET_KEY = var.recaptcha_secret_key
SES_FROM_EMAIL = "robmrose@me.com"
SES_TO_EMAIL = "robmrose@me.com"
OPENAI_API_KEY = var.openai_api_key
ANTHROPIC_API_KEY = var.anthropic_api_key
AWS_LWA_PORT = "8080"
AWS_LAMBDA_EXEC_WRAPPER = "/opt/bootstrap"
AWS_LWA_INVOKE_MODE = "response_stream"
}
}

Expand All @@ -38,3 +45,22 @@ resource "aws_lambda_permission" "api_gateway" {
principal = "apigateway.amazonaws.com"
source_arn = "${aws_api_gateway_rest_api.resume_api.execution_arn}/*/*"
}

# Lambda Function URL for streaming (bypasses API Gateway)
resource "aws_lambda_function_url" "streaming" {
function_name = aws_lambda_function.fastapi_app.function_name
authorization_type = "NONE"
invoke_mode = "RESPONSE_STREAM"
}

output "streaming_url" {
value = aws_lambda_function_url.streaming.function_url
}

resource "aws_lambda_permission" "function_url_public" {
statement_id = "FunctionURLAllowPublicAccess"
action = "lambda:InvokeFunctionUrl"
function_name = aws_lambda_function.fastapi_app.function_name
principal = "*"
function_url_auth_type = "NONE"
}