File size: 3,535 Bytes
6c3af4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import type { PreviewServer, ViteDevServer } from "vite";
import { z } from "zod";
import { handleTokenVerification } from "./handleTokenVerification.ts";
import { fetchPageContents } from "./pageContentService.ts";
import { recordGroundingOutcome } from "./pageReadsSinceLastRestart.ts";
import { sendValidationError } from "./utils/httpResponse.ts";

const MAX_QUERY_LENGTH = 2000;
const MAX_URL_LENGTH = 2048;
/** Matches `searchResultsToConsider` on the client: the pages that reach the prompt. */
const MAX_URLS = 6;

const pageContentParamsSchema = z.object({
  query: z
    .string({ error: "Missing query parameter" })
    .trim()
    .min(1, "Missing query parameter")
    .max(
      MAX_QUERY_LENGTH,
      `Query parameter must not exceed ${MAX_QUERY_LENGTH} characters`,
    ),
  urls: z
    .array(
      z
        .url({ protocol: /^https?$/, error: "Invalid URL parameter" })
        .max(MAX_URL_LENGTH),
    )
    .min(1, "Missing url parameter")
    .max(MAX_URLS, `No more than ${MAX_URLS} URLs can be read per request`)
    // Repeats would otherwise multiply the requests one caller can aim at a
    // single site, and a page read twice adds nothing to the excerpt.
    .transform((urls) => [...new Set(urls)]),
});

/**
 * Sets up the `/page-content` endpoint, which reads the pages behind a handful
 * of search results and answers with the passages most relevant to the query.
 *
 * Kept off the `/search/` prefix on purpose: `searchEndpointServerHook` claims
 * every path under it, so a sibling route there would depend on middleware
 * ordering to ever be reached.
 *
 * Unlike `/search/`, which only ever reaches the configured SearXNG instance,
 * this endpoint fetches URLs the caller chose. It is always available; each
 * browser decides whether to call it, under AI Settings > Read Page Content.
 */
export function pageContentEndpointServerHook<
  T extends ViteDevServer | PreviewServer,
>(server: T) {
  server.middlewares.use(async (request, response, next) => {
    if (!request.url?.startsWith("/page-content")) return next();

    const url = new URL(request.url, `http://${request.headers.host}`);
    if (url.pathname !== "/page-content") return next();

    // Verified before the parameters are read, so malformed requests from an
    // unauthenticated caller still count against the rate limiter.
    const { shouldContinue } = await handleTokenVerification(
      url.searchParams.get("token"),
      response,
      request,
    );

    if (!shouldContinue) return;

    const parsedParams = pageContentParamsSchema.safeParse({
      query: url.searchParams.get("q") ?? undefined,
      urls: url.searchParams.getAll("url"),
    });

    if (!parsedParams.success) {
      sendValidationError(response, parsedParams.error);
      return;
    }

    try {
      const { query, urls } = parsedParams.data;
      const contents = await fetchPageContents(query, urls);
      recordGroundingOutcome(contents.length > 0);

      response.setHeader("Content-Type", "application/json");
      response.end(
        JSON.stringify(
          Object.fromEntries(
            contents.map(({ url: page, content }) => [page, content]),
          ),
        ),
      );
    } catch (error) {
      console.error(
        "Error reading page content:",
        error instanceof Error ? error.message : error,
      );
      response.statusCode = 500;
      response.setHeader("Content-Type", "application/json");
      response.end(JSON.stringify({ error: "Internal server error" }));
    }
  });
}