File size: 4,448 Bytes
b3def84
6b3d40b
 
98d4633
 
6b3d40b
 
 
 
98d4633
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6b3d40b
98d4633
 
 
 
 
 
 
 
6b3d40b
 
 
 
 
 
 
001f1df
 
 
 
6b3d40b
 
 
001f1df
6b3d40b
 
 
001f1df
b3def84
b03e13f
001f1df
 
 
 
 
 
 
 
e719504
001f1df
b3def84
001f1df
 
 
f3a0f20
9eeb459
001f1df
 
 
 
9eeb459
001f1df
b3def84
 
 
001f1df
 
 
98d4633
9eeb459
b3def84
001f1df
98d4633
 
 
 
001f1df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98d4633
 
 
 
 
 
 
6b3d40b
 
 
 
 
 
 
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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
import type { ChatTurn, MetricsSummary, RunResult, SourceImage } from "./types";

// Same origin in production (FastAPI serves the static export);
// the local backend during `next dev`. On Vercel this MUST be set to the
// backend (HF Space) URL, since the frontend and API are different origins.
const API_BASE =
  process.env.NEXT_PUBLIC_API_BASE ??
  (process.env.NODE_ENV === "development" ? "http://localhost:8000" : "");

// --- Access code (shared demo gate) -----------------------------------------
// Entered at runtime, kept in sessionStorage — never baked into the bundle.
const CODE_KEY = "ar_access_code";

export function getAccessCode(): string {
  if (typeof window === "undefined") return "";
  return window.sessionStorage.getItem(CODE_KEY) ?? "";
}
export function setAccessCode(code: string): void {
  window.sessionStorage.setItem(CODE_KEY, code);
}
export function clearAccessCode(): void {
  window.sessionStorage.removeItem(CODE_KEY);
}

let authFailureHandler: (() => void) | null = null;
export function onAuthFailure(fn: () => void): void {
  authFailureHandler = fn;
}
function handle401(): void {
  clearAccessCode();
  authFailureHandler?.();
}

function authHeaders(extra: Record<string, string> = {}): Record<string, string> {
  const code = getAccessCode();
  return code ? { ...extra, "X-Access-Code": code } : extra;
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${API_BASE}${path}`, {
    ...init,
    headers: authHeaders(init?.headers as Record<string, string>),
  });
  if (res.status === 401) {
    handle401();
    throw new Error("access code rejected");
  }
  if (!res.ok) {
    const body = await res.text().catch(() => "");
    throw new Error(`${res.status}: ${body.slice(0, 300)}`);
  }
  return res.json();
}

export function submitQuery(
  query: string,
  history: ChatTurn[] = [],
): Promise<RunResult> {
  return request<RunResult>("/api/query", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, history }),
  });
}

export interface StreamEvent {
  type: "stage" | "auction" | "token" | "reset" | "reasoning" | "verification" | "images" | "frontier_failed" | "error" | "done";
  stage?: "bidding" | "drafting" | "searching" | "verifying" | "delivering" | "escalating";
  model?: string;
  text?: string;
  reason?: string | null;
  message?: string;
  score?: number;
  passed?: boolean;
  feedback?: string;
  winner?: string | null;
  verified?: boolean;
  escalated?: boolean;
  images?: SourceImage[];
  run?: RunResult;
}

export type QueryHint = "general" | "coding" | "reasoning";

// POST + NDJSON reader (EventSource is GET-only)
export async function streamQuery(
  query: string,
  history: ChatTurn[],
  hint: QueryHint,
  onEvent: (ev: StreamEvent) => void,
  // Aborting rejects the in-flight reader.read() below, which surfaces to the
  // caller as an AbortError — that's how "stop generating" unwinds
  signal?: AbortSignal,
): Promise<void> {
  const res = await fetch(`${API_BASE}/api/query/stream`, {
    method: "POST",
    headers: authHeaders({ "Content-Type": "application/json" }),
    body: JSON.stringify({ query, history, hint }),
    signal,
  });
  if (res.status === 401) {
    handle401();
    throw new Error("access code rejected");
  }
  if (!res.ok || !res.body) {
    const body = await res.text().catch(() => "");
    throw new Error(`${res.status}: ${body.slice(0, 300)}`);
  }
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    let nl;
    while ((nl = buffer.indexOf("\n")) >= 0) {
      const line = buffer.slice(0, nl).trim();
      buffer = buffer.slice(nl + 1);
      if (line) onEvent(JSON.parse(line) as StreamEvent);
    }
  }
}

// Open endpoint (no code) — tells the frontend whether to show the gate
export async function fetchHealth(): Promise<{ access_required: boolean }> {
  const res = await fetch(`${API_BASE}/health`);
  if (!res.ok) throw new Error(`${res.status}`);
  return res.json();
}

export function fetchMetrics(): Promise<MetricsSummary> {
  return request<MetricsSummary>("/api/metrics");
}

export function fetchRuns(limit = 50): Promise<RunResult[]> {
  return request<RunResult[]>(`/api/runs?limit=${limit}`);
}