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
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,21 @@
NEXT_PUBLIC_INSFORGE_BASE_URL=https://your-app.region.insforge.app
NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key-here

# ─── TACK Search (web search backend) ─────────────────────────────────────────
# TACK's /api/tack/search endpoint queries a real web search API.
#
# Default provider: Serper.dev (Google Search results — title, snippet, link).
# Sign up at https://serper.dev, create an API key, and paste it here.
# Pricing (as of 2026): 2,500 free queries, then ~$0.30–$1.00 per 1,000
# queries depending on plan. This key is already used by the existing
# /summarize, /read, and /search chat commands.
SERPER_API_KEY=your-serper-api-key-here
#
# Alternative provider: Google Programmable Search JSON API.
# The Bing Web Search API was retired by Microsoft in August 2025, so Google's
# Programmable Search is the recommended drop-in alternative. To use it,
# swap the serperSearch() call in src/app/api/tack/search/route.ts and set:
# Docs: https://developers.google.com/custom-search/v1/overview
# Pricing: 100 queries/day free, then $5 per 1,000 queries (max 10k/day).
# GOOGLE_SEARCH_API_KEY=your-google-api-key-here
# GOOGLE_SEARCH_CX=your-programmable-search-engine-id
2 changes: 2 additions & 0 deletions src/app/(protected)/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"use client";

import { useEffect, useRef } from "react";
import Link from "next/link";
import { ChatHistory, ChatInput } from "@/components/chat";
import { LiveRegion } from "@/components/a11y";
import { Button } from "@/components/ui/button";
import { useChat } from "@/hooks/useChat";
//import { useVoice } from "@/hooks/useVoice";

Expand Down
150 changes: 150 additions & 0 deletions src/app/api/tack/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@insforge/nextjs/server";
import { z } from "zod";
import { serperSearch } from "@/lib/serper";
import { checkRateLimit } from "@/lib/rate-limit";

/**
* TACK search API — an accessibility-first web search endpoint.
*
* Backend choice: this reuses the repo's existing Serper.dev integration
* (`SERPER_API_KEY`), which returns Google search results (title, snippet,
* link) — exactly the fields TACK needs. Serper was chosen over the two APIs
* named in the brief because:
* • Microsoft retired the Bing Web Search API in August 2025.
* • Serper is already wired up and keyed in this codebase, so no new
* credential or dependency is required.
* To swap in Google's Programmable Search JSON API instead, replace the
* `serperSearch` call below and set GOOGLE_SEARCH_API_KEY / GOOGLE_SEARCH_CX
* (see .env.example).
*
* Every response — success OR error — carries a plain-text `message` field
* written to be read aloud by a screen reader.
*/

const searchSchema = z.object({
q: z.string().trim().min(1, "Please type something to search for.").max(500),
});

// How many results TACK returns per search. Kept modest so a screen-reader
// user can page through the whole list with arrow keys without fatigue.
const RESULTS_PER_SEARCH = 8;

export interface TackResult {
title: string;
snippet: string;
url: string;
source: string;
}

interface TackSearchResponseBody {
query: string;
count: number;
results: TackResult[];
message: string;
}

/** Extract a clean, human-readable source domain from a result URL. */
function sourceDomain(link: string): string {
try {
return new URL(link).hostname.replace(/^www\./, "");
} catch {
return "";
}
}

export async function POST(request: NextRequest) {
// ── Auth ──────────────────────────────────────────────────────────────
let userId: string | null = null;
try {
const session = await auth();
userId = session.userId;
} catch {
userId = null;
}
if (!userId) {
return NextResponse.json(
{ error: "You need to be signed in to search." },
{ status: 401 }
);
}

// ── Rate limit ────────────────────────────────────────────────────────
const { allowed } = checkRateLimit(`tack-search:${userId}`, 30, 60000);
if (!allowed) {
return NextResponse.json(
{ error: "Too many searches. Please wait a moment and try again." },
{ status: 429 }
);
}

// ── Parse & validate ──────────────────────────────────────────────────
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ error: "Please type something to search for." },
{ status: 400 }
);
}

const parsed = searchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error:
parsed.error.issues[0]?.message ??
"Please type something to search for.",
},
{ status: 400 }
);
}

const query = parsed.data.q;

// ── Search ────────────────────────────────────────────────────────────
let organic: Awaited<ReturnType<typeof serperSearch>>["organic"];
try {
const data = await serperSearch(query, RESULTS_PER_SEARCH);
organic = data.organic ?? [];
} catch (err) {
console.error("TACK search failed:", err);
return NextResponse.json(
{
error:
"Search is temporarily unavailable. Please try again in a moment.",
},
{ status: 502 }
);
}

const results: TackResult[] = organic
.filter((r) => r.title && r.link)
.map((r) => ({
title: r.title,
snippet: r.snippet ?? "",
url: r.link,
source: sourceDomain(r.link),
}));

if (results.length === 0) {
const emptyBody: TackSearchResponseBody = {
query,
count: 0,
results: [],
message: `No results found for “${query}”. Try different or more general words.`,
};
return NextResponse.json(emptyBody, { status: 200 });
}

const responseBody: TackSearchResponseBody = {
query,
count: results.length,
results,
message: `${results.length} result${
results.length === 1 ? "" : "s"
} found for “${query}”.`,
};
return NextResponse.json(responseBody, { status: 200 });
}
27 changes: 25 additions & 2 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,8 @@ html {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
max-width: 100%;
padding: 0 2rem;
margin: 0 auto;
height: 72px;
}
Expand All @@ -528,6 +529,29 @@ html {
opacity: 0.85;
}

/* Dashboard Navigation */
.landing-nav__dashboard {
display: flex;
align-items: center;
gap: 3rem;
margin-left: auto;
}

.landing-nav__dashboard-link {
color: rgba(240, 237, 237, 0.7);
text-decoration: none;
font-size: 0.9rem;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: capitalize;
transition: color 0.3s ease;
white-space: nowrap;
}

.landing-nav__dashboard-link:hover {
color: rgba(240, 237, 237, 1);
}

/* Nav links */
.landing-nav__links {
display: flex;
Expand Down Expand Up @@ -1134,7 +1158,6 @@ html {
═══════════════════════════════════════════════════════════════════ */
.app-sidebar {
background: rgba(10, 10, 18, 0.95) !important;
border-right: 1px solid rgba(140, 120, 200, 0.08) !important;
}

.app-sidebar__new-chat {
Expand Down
33 changes: 11 additions & 22 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,33 +103,22 @@ export default function Home() {
<span>TACK</span>
</Link>

<nav aria-label="Site navigation" className="landing-nav__links">
<Link href="/" className="landing-nav__link landing-nav__link--active">
<LandingNavMobile />

<nav aria-label="Site navigation" className="landing-nav__dashboard">
<Link href="/" className="landing-nav__dashboard-link">
Home
</Link>
<Link href="/#team" className="landing-nav__link">
About Us
<Link href="/chat" className="landing-nav__dashboard-link">
Chat
</Link>
<Link href="/contact" className="landing-nav__link">
Contact Us
<Link href="/search" className="landing-nav__dashboard-link">
Search
</Link>
<Link href="/#" className="landing-nav__dashboard-link">
Browser Agent
</Link>
</nav>

<LandingNavMobile />

<div className="landing-nav__actions">
<SignedOut>
<SignInButton className="landing-signin-btn">Sign In</SignInButton>
</SignedOut>
<SignedIn>
<Link href="/chat">
<button className="landing-signin-btn" type="button">Open Chat</button>
</Link>
<Link href="/pdf-reading">
<button className="landing-signin-btn" type="button">PDF Reader</button>
</Link>
</SignedIn>
</div>
</div>
</header>

Expand Down
Loading