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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

on:
pull_request:
branches: ["*"]
push:
branches: ["main", "master"]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm run test
3 changes: 3 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env sh
# Run linter and fail if there are any errors
npm run lint
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ nvm use
Create a `.env.local` file in the root directory with the following:

```bash
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN
DISCORD_WEBHOOK_URL=your_url
DISCORD_BOT_TOKEN=your_discord_bot_token
DISCORD_CHANNEL_ID=your_channel_id
AI_GATEWAY_API_KEY=your_vercel_ai_gateway_api_key
```

## Running the Development Server
Expand Down
151 changes: 67 additions & 84 deletions app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from "next/link";

import { ProfileCard } from "@/components/about/ProfileCard";
import { FooterSpeechBubble } from "@/components/common/FooterSpeechBubble";
import { SlimPageHeader } from "@/components/common/SlimPageHeader";

Expand All @@ -11,6 +12,70 @@ export default function AboutPage() {
description="We design and build digital systems that help businesses run smoothly, look credible, and grow at a sustainable pace, without adding operational complexity."
/>

<section className="border-b border-zinc-100 py-16 dark:border-zinc-800 md:py-20">
<div className="container mx-auto px-6">
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-4">
<span className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
The Team
</span>
<h2 className="text-2xl font-semibold tracking-tight md:text-3xl">
The Humans of Infinite Robots
</h2>
<p className="text-lg leading-relaxed text-zinc-600 dark:text-zinc-300">
We&rsquo;re a small, focused team. We value thoughtful
engineering, steady collaboration, and systems that feel good to
use.
</p>
</div>
<div className="grid gap-8 md:grid-cols-2 md:gap-12">
<ProfileCard
name="Andrew"
title="Founder / Product &amp; UI/UX"
bio="Andrew is the founder and product lead, responsible for
setting the vision and ensuring everything we build is
thoughtful, polished, and effective. He brings over 10 years
of full-stack experience across startups and enterprise
systems. His strength is turning big ideas into software that
actually delivers."
imageUrl="/drew.jpg"
/>
<ProfileCard
name="Ralph"
title="Distributed Systems &amp; Backend Engineering"
bio="Ralph is our resident systems wizard — the kind of engineer
who can summon scalable architecture from thin air and tame
even the most unruly distributed systems. He's led teams,
built platforms, and guided products from vague idea to
production reality. Around here, we just say: if it's
complicated, give it to Ralph."
imageUrl="/ralph.jpg"
/>
<ProfileCard
name="Brad"
title="Brand &amp; Marketing Strategy"
bio="Brad brings a deep understanding of both the technical and
creative sides of digital presence. He's been building on the web
long enough to see it evolve from simple pages to complex
platforms, and he knows how to make businesses look and feel
as polished on the outside as they are solid underneath."
imageUrl="/brad.jpg"
/>
<ProfileCard
name="Will"
title="Creative Design &amp; Visual Art"
bio="Will brings the kind of creative vision and artistic sensibility
that makes everything we build not just functional, but genuinely
beautiful. He's the steady hand that keeps projects moving, the
creative problem-solver who makes the impossible feel
straightforward, and frankly, just the coolest person on the team."
imageUrl="/will.jpg"
/>
</div>
</div>
</div>
</section>

<section className="py-16 md:py-20">
<div className="container mx-auto px-6">
<div className="grid gap-12 md:grid-cols-2 md:gap-16">
Expand Down Expand Up @@ -60,8 +125,8 @@ export default function AboutPage() {
business runs day-to-day. From there, we design and build
systems that are straightforward to use, dependable over time,
and flexible enough to evolve as your needs change. Our work
doesnt end at launch — we stay available to support, refine,
and improve as your business grows.
doesn&rsquo;t end at launch — we stay available to support,
refine, and improve as your business grows.
</p>
<div className="space-y-5 text-lg leading-relaxed text-zinc-600 dark:text-zinc-300">
<div>
Expand Down Expand Up @@ -109,88 +174,6 @@ export default function AboutPage() {
</div>
</section>

<section className="border-y border-zinc-100 py-16 dark:border-zinc-800 md:py-20">
<div className="container mx-auto px-6">
<div className="flex flex-col gap-6">
<span className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
The Team
</span>
<h2 className="text-2xl font-semibold tracking-tight md:text-3xl">
The People Behind Infinite Robots
</h2>
<p className="text-lg leading-relaxed text-zinc-600 dark:text-zinc-300">
We&rsquo;re a small, focused team. We value thoughtful
engineering, steady collaboration, and systems that feel good to
use.
</p>
<div className="grid gap-16 md:grid-cols-2">
<article className="flex flex-col items-center gap-3 text-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-zinc-200 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
Photo
</div>
<h3 className="text-xl font-semibold tracking-tight">Andrew</h3>
<p className="text-sm font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Founder / Product &amp; UI/UX
</p>
<p className="text-base leading-relaxed text-zinc-700 dark:text-zinc-300">
Andrew is the founder and product lead, responsible for
setting the vision and ensuring everything we build is
thoughtful, polished, and effective. He brings over 10 years
of full-stack experience across startups and enterprise
systems. His strength is turning big ideas into software that
actually delivers.
</p>
</article>
<article className="flex flex-col items-center gap-3 text-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-zinc-200 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
Photo
</div>
<h3 className="text-xl font-semibold tracking-tight">Ralph</h3>
<p className="text-sm font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Distributed Systems &amp; Backend Engineering
</p>
<p className="text-base leading-relaxed text-zinc-700 dark:text-zinc-300">
Ralph is our resident systems wizard — the kind of engineer
who can summon scalable architecture from thin air and tame
even the most unruly distributed systems. He’s led teams,
built platforms, and guided products from vague idea to
production reality. Around here, we just say: if it’s
complicated, give it to Ralph.
</p>
</article>
<article className="flex flex-col items-center gap-3 text-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-zinc-200 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
Photo
</div>
<h3 className="text-xl font-semibold tracking-tight">Brad</h3>
<p className="text-sm font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Brand &amp; Marketing Strategy
</p>
<p className="text-base leading-relaxed text-zinc-700 dark:text-zinc-300">
Brad has been making websites since the dawn of time, back
when you could only Ask Jeeves for help. He has now helped
over a trillion businesses look and feel as beautiful on the
outside as they do on the inside with his full stack
engineering skills to pay the bills.
</p>
</article>
<article className="flex flex-col items-center gap-3 text-center">
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-zinc-200 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300">
Photo
</div>
<h3 className="text-xl font-semibold tracking-tight">Will</h3>
<p className="text-sm font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
All-around Badass and Jack of All Trades
</p>
<p className="text-base leading-relaxed text-zinc-700 dark:text-zinc-300">
Bio coming soon...
</p>
</article>
</div>
</div>
</div>
</section>

<section className="py-20">
<div className="container mx-auto px-6 text-center">
<h2 className="text-2xl font-semibold tracking-tight md:text-3xl">
Expand Down
42 changes: 42 additions & 0 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
streamText,
createUIMessageStreamResponse,
convertToModelMessages,
createGateway,
} from "ai";
import { CHAT_SYSTEM_PROMPT } from "@/lib/prompts/chat-system-prompt";

export const runtime = "edge";

// Use Vercel AI Gateway - model-agnostic!
// Just specify the model as a string like "anthropic/claude-3.5-haiku"
// The gateway handles routing to the correct provider
const gateway = createGateway({
apiKey: process.env.AI_GATEWAY_API_KEY,
});

export async function POST(req: Request) {
try {
const { messages } = await req.json();

// Convert UIMessages to ModelMessages for streamText
// Previous messages are now included in the messages array (prepended on first message)
const modelMessages = convertToModelMessages(messages);

// Stream AI response (no Discord involvement - that's handled client-side)
const result = streamText({
model: gateway("anthropic/claude-haiku-4.5"),
system: CHAT_SYSTEM_PROMPT,
messages: modelMessages,
});

const stream = result.toUIMessageStream();

return createUIMessageStreamResponse({
stream,
});
} catch (error) {
console.error("Chat API error:", error);
return new Response("Internal Server Error", { status: 500 });
}
}
134 changes: 134 additions & 0 deletions app/api/discord/message/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
export const runtime = "edge";

function getClientIP(req: Request): string | null {
// Try various headers that might contain the IP
const forwardedFor = req.headers.get("x-forwarded-for");
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs, take the first one
return forwardedFor.split(",")[0].trim();
}

const realIP = req.headers.get("x-real-ip");
if (realIP) {
return realIP;
}

const cfConnectingIP = req.headers.get("cf-connecting-ip"); // Cloudflare
if (cfConnectingIP) {
return cfConnectingIP;
}

return null;
}

// Discord message content limit (2000 characters)
const DISCORD_MESSAGE_MAX_LENGTH = 2000;

export async function POST(req: Request) {
try {
const { threadId, content, role } = await req.json();

if (!threadId || !content || !role) {
return new Response(
JSON.stringify({ error: "threadId, content, and role are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}

// Validate content length (Discord limit is 2000 characters)
if (
typeof content !== "string" ||
content.length > DISCORD_MESSAGE_MAX_LENGTH
) {
return new Response(
JSON.stringify({
error: `Content must be a string and less than ${DISCORD_MESSAGE_MAX_LENGTH} characters`,
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}

// Validate role
if (role !== "user" && role !== "assistant") {
return new Response(
JSON.stringify({ error: "role must be 'user' or 'assistant'" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}

const botToken = process.env.DISCORD_BOT_TOKEN;

if (!botToken) {
return new Response(JSON.stringify({ error: "Discord not configured" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}

// Get client IP address
const clientIP = getClientIP(req);

// Format message for Discord
const formattedMessage = formatDiscordMessage({
content,
role,
clientIP,
});

// Post message to thread using Bot API
const response = await fetch(
`https://discord.com/api/v10/channels/${threadId}/messages`,
{
method: "POST",
headers: {
Authorization: `Bot ${botToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: formattedMessage,
}),
},
);

if (!response.ok) {
const error = await response.text();
console.error("Failed to post to Discord:", error);
return new Response(JSON.stringify({ error: "Failed to post message" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}

return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error posting to Discord:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

function formatDiscordMessage(message: {
content: string;
role: "user" | "assistant";
clientIP?: string | null;
}): string {
if (message.role === "user") {
const ipLabel = message.clientIP ? ` (${message.clientIP})` : "";
return `**User${ipLabel}:**\n${message.content}`;
}
return `**AI Assistant:**\n${message.content}`;
}
Loading