adds search support with exa

This commit is contained in:
2026-02-13 23:49:55 -08:00
parent 5340c55aa6
commit 393dac37a7
14 changed files with 948 additions and 155 deletions

View File

@@ -12,6 +12,7 @@ const EnvSchema = z.object({
OPENAI_API_KEY: z.string().optional(),
ANTHROPIC_API_KEY: z.string().optional(),
XAI_API_KEY: z.string().optional(),
EXA_API_KEY: z.string().optional(),
});
export type Env = z.infer<typeof EnvSchema>;

View File

@@ -1,3 +1,4 @@
import { performance } from "node:perf_hooks";
import { z } from "zod";
import type { FastifyInstance } from "fastify";
import { prisma } from "./db.js";
@@ -5,6 +6,7 @@ import { requireAdmin } from "./auth.js";
import { env } from "./env.js";
import { runMultiplex } from "./llm/multiplexer.js";
import { runMultiplexStream } from "./llm/streaming.js";
import { exaClient } from "./search/exa.js";
type IncomingChatMessage = {
role: "system" | "user" | "assistant" | "tool";
@@ -73,6 +75,141 @@ export async function registerRoutes(app: FastifyInstance) {
return { chat };
});
app.get("/v1/searches", async (req) => {
requireAdmin(req);
const searches = await prisma.search.findMany({
orderBy: { updatedAt: "desc" },
take: 100,
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
});
return { searches };
});
app.post("/v1/searches", async (req) => {
requireAdmin(req);
const Body = z.object({ title: z.string().optional(), query: z.string().optional() });
const body = Body.parse(req.body ?? {});
const title = body.title?.trim() || body.query?.trim()?.slice(0, 80);
const query = body.query?.trim() || null;
const search = await prisma.search.create({
data: {
title: title || null,
query,
},
select: { id: true, title: true, query: true, createdAt: true, updatedAt: true },
});
return { search };
});
app.get("/v1/searches/:searchId", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const { searchId } = Params.parse(req.params);
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) return app.httpErrors.notFound("search not found");
return { search };
});
app.post("/v1/searches/:searchId/run", async (req) => {
requireAdmin(req);
const Params = z.object({ searchId: z.string() });
const Body = z.object({
query: z.string().trim().min(1).optional(),
title: z.string().trim().min(1).optional(),
type: z.enum(["auto", "fast", "deep", "instant"]).optional(),
numResults: z.number().int().min(1).max(25).optional(),
includeDomains: z.array(z.string().trim().min(1)).max(50).optional(),
excludeDomains: z.array(z.string().trim().min(1)).max(50).optional(),
});
const { searchId } = Params.parse(req.params);
const body = Body.parse(req.body ?? {});
const existing = await prisma.search.findUnique({
where: { id: searchId },
select: { id: true, query: true },
});
if (!existing) return app.httpErrors.notFound("search not found");
const query = body.query?.trim() || existing.query?.trim();
if (!query) return app.httpErrors.badRequest("query is required");
const startedAt = performance.now();
try {
const response = await exaClient().searchAndContents(query, {
type: body.type ?? "auto",
numResults: body.numResults ?? 10,
includeDomains: body.includeDomains,
excludeDomains: body.excludeDomains,
text: { maxCharacters: 1200 },
highlights: {
query,
maxCharacters: 320,
numSentences: 2,
highlightsPerUrl: 2,
},
moderation: true,
userLocation: "US",
} as any);
const latencyMs = Math.round(performance.now() - startedAt);
const normalizedTitle = body.title?.trim() || query.slice(0, 80);
const rows = (response.results ?? []).map((result: any, index: number) => ({
searchId,
rank: index,
title: result.title ?? null,
url: result.url,
publishedDate: result.publishedDate ?? null,
author: result.author ?? null,
text: result.text ?? null,
highlights: Array.isArray(result.highlights) ? (result.highlights as any) : null,
highlightScores: Array.isArray(result.highlightScores) ? (result.highlightScores as any) : null,
score: typeof result.score === "number" ? result.score : null,
favicon: result.favicon ?? null,
image: result.image ?? null,
}));
await prisma.$transaction(async (tx) => {
await tx.search.update({
where: { id: searchId },
data: {
query,
title: normalizedTitle,
requestId: response.requestId ?? null,
rawResponse: response as any,
latencyMs,
error: null,
},
});
await tx.searchResult.deleteMany({ where: { searchId } });
if (rows.length) {
await tx.searchResult.createMany({ data: rows as any });
}
});
const search = await prisma.search.findUnique({
where: { id: searchId },
include: { results: { orderBy: { rank: "asc" } } },
});
if (!search) return app.httpErrors.notFound("search not found");
return { search };
} catch (err: any) {
await prisma.search.update({
where: { id: searchId },
data: {
latencyMs: Math.round(performance.now() - startedAt),
error: err?.message ?? String(err),
},
});
throw err;
}
});
app.get("/v1/chats/:chatId", async (req) => {
requireAdmin(req);
const Params = z.object({ chatId: z.string() });

14
server/src/search/exa.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Exa } from "exa-js";
import { env } from "../env.js";
let client: Exa | null = null;
export function exaClient() {
if (!env.EXA_API_KEY) {
throw new Error("EXA_API_KEY not set");
}
if (!client) {
client = new Exa(env.EXA_API_KEY);
}
return client;
}