| 1 | /** |
| 2 | * Ask g1t (components/ask-composer): `GET -/ask` says what Ask answers with |
| 3 | * in the workspace, or why it cannot yet; `POST -/ask` with |
| 4 | * `{ messages, page }` streams one answer back as newline-delimited JSON |
| 5 | * (`AskEvent`s), from the integrations service, which calls the AI provider |
| 6 | * an owner chose with the workspace's own key. The key never reaches the |
| 7 | * browser. |
| 8 | */ |
| 9 | import { env } from "cloudflare:workers"; |
| 10 | import { data } from "react-router"; |
| 11 | |
| 12 | import { |
| 13 | ASK_MAX_MESSAGES, |
| 14 | ASK_MAX_TOTAL_CHARS, |
| 15 | type AskEvent, |
| 16 | type AskMessage, |
| 17 | type AskPage, |
| 18 | checkLimit, |
| 19 | integrationsClient, |
| 20 | } from "@g1t/contracts"; |
| 21 | |
| 22 | import type { Route } from "./+types/ask"; |
| 23 | import { integrations } from "../../lib/services.server"; |
| 24 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; |
| 25 | |
| 26 | /** The most a request may carry: the conversation's cap, with room for JSON. */ |
| 27 | const MAX_BODY_BYTES = ASK_MAX_TOTAL_CHARS * 4 + 16_384; |
| 28 | |
| 29 | const NDJSON = { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" }; |
| 30 | |
| 31 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 32 | const viewer = getViewer(context); |
| 33 | const role = roleIn(viewer, params.owner); |
| 34 | if (!role) throw data(null, { status: 404 }); |
| 35 | const setup = await integrations.askSettings(params.owner.toLowerCase(), viewer); |
| 36 | return Response.json({ ...setup, owner: role === "owner" }, { headers: { "cache-control": "no-store" } }); |
| 37 | } |
| 38 | |
| 39 | /** An answer that never started: one `error` line, with the status that says why. */ |
| 40 | function refuse(message: string, status: number, headers: Record<string, string> = {}): Response { |
| 41 | const event: AskEvent = { type: "error", message }; |
| 42 | return new Response(`${JSON.stringify(event)}\n`, { status, headers: { ...NDJSON, ...headers } }); |
| 43 | } |
| 44 | |
| 45 | function isMessage(value: unknown): value is AskMessage { |
| 46 | if (!value || typeof value !== "object") return false; |
| 47 | const { role, content } = value as AskMessage; |
| 48 | return (role === "user" || role === "assistant") && typeof content === "string"; |
| 49 | } |
| 50 | |
| 51 | /** The page as sent, with only what Ask reads, of the shapes it reads. */ |
| 52 | function pageFrom(value: unknown): AskPage { |
| 53 | if (!value || typeof value !== "object") return {}; |
| 54 | const sent = value as Record<string, unknown>; |
| 55 | const number = (n: unknown) => (typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31 ? n : null); |
| 56 | const repo = sent.repo as { namespace?: unknown; name?: unknown } | null | undefined; |
| 57 | return { |
| 58 | path: typeof sent.path === "string" ? sent.path.slice(0, 500) : null, |
| 59 | repo: |
| 60 | repo && typeof repo.namespace === "string" && typeof repo.name === "string" && repo.namespace && repo.name |
| 61 | ? { namespace: repo.namespace.slice(0, 100), name: repo.name.slice(0, 100) } |
| 62 | : null, |
| 63 | issue: number(sent.issue), |
| 64 | pull: number(sent.pull), |
| 65 | }; |
| 66 | } |
| 67 | |
| 68 | export async function action({ params, context, request }: Route.ActionArgs) { |
| 69 | if (request.method !== "POST") throw data(null, { status: 405 }); |
| 70 | assertSameOrigin(request); |
| 71 | const viewer = requireUser(context, request); |
| 72 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 73 | const slug = params.owner.toLowerCase(); |
| 74 | // Each question spends the workspace's own provider and key: a few a |
| 75 | // minute per person is plenty for a conversation. |
| 76 | if ((await checkLimit(env.WEB_ASK_LIMIT, `ask:${viewer.id}`)) === "limited") { |
| 77 | return refuse("You have asked a lot in the last minute. Wait a moment, then ask again.", 429, { "retry-after": "60" }); |
| 78 | } |
| 79 | const length = Number(request.headers.get("content-length") ?? 0); |
| 80 | if (length > MAX_BODY_BYTES) return refuse("The conversation is too long. Start a new one.", 413); |
| 81 | const text = await request.text(); |
| 82 | if (text.length > MAX_BODY_BYTES) return refuse("The conversation is too long. Start a new one.", 413); |
| 83 | let sent: { messages?: unknown; page?: unknown }; |
| 84 | try { |
| 85 | sent = JSON.parse(text) as typeof sent; |
| 86 | } catch { |
| 87 | return refuse("The question did not arrive whole. Ask again.", 400); |
| 88 | } |
| 89 | const messages = Array.isArray(sent.messages) ? sent.messages.filter(isMessage).slice(-ASK_MAX_MESSAGES) : []; |
| 90 | if (messages.length === 0) return refuse("Write a question to ask.", 400); |
| 91 | // The binding itself: the timed one (lib/perf.server.ts) reads the whole |
| 92 | // answer before handing it on, and this one streams. The person closing |
| 93 | // the tab or pressing Stop ends the provider's answer too. |
| 94 | const answer = await integrationsClient(env.INTEGRATIONS) |
| 95 | .ask(viewer, slug, messages, pageFrom(sent.page), request.signal) |
| 96 | .catch(() => null); |
| 97 | if (!answer) return refuse("Ask could not reach g1t's integrations. Try again in a moment.", 502); |
| 98 | if (!answer.ok || !answer.body) return refuse("Ask could not reach g1t's integrations. Try again in a moment.", 502); |
| 99 | return new Response(answer.body, { headers: NDJSON }); |
| 100 | } |