@guuey/agent-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Web (browser / Next.js) host adapters for {@link useAgentInvoke}.
3
+ *
4
+ * Studio builds its bundle via {@link createWebAdapters}. The implementations
5
+ * touch `window.localStorage`, `crypto`, and `fetch` only inside their
6
+ * functions — never at module load — so this file is import-safe under SSR
7
+ * (the functions guard on `typeof window`).
8
+ */
9
+ import type {
10
+ AgentInvokeAdapters,
11
+ InvokeRequest,
12
+ InvokeTransport,
13
+ ThreadIdStore,
14
+ } from "./types";
15
+ import { fetchThreadHistory } from "./history";
16
+
17
+ /**
18
+ * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
19
+ * SSE stream opens). Carries the pod's structured `{ code, message }` when
20
+ * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
21
+ * generation limit…") the chat UI should surface — falling back to the bare
22
+ * status for non-JSON failures.
23
+ */
24
+ export class AgentResponseError extends Error {
25
+ constructor(
26
+ message: string,
27
+ readonly status: number,
28
+ readonly code?: string,
29
+ ) {
30
+ super(message);
31
+ this.name = "AgentResponseError";
32
+ }
33
+ }
34
+
35
+ /** Persists the threadId in `window.localStorage` (synchronously). */
36
+ export const localStorageThreadStore: ThreadIdStore = {
37
+ load(key) {
38
+ if (typeof window === "undefined") return null;
39
+ try {
40
+ return window.localStorage.getItem(key);
41
+ } catch {
42
+ return null;
43
+ }
44
+ },
45
+ save(key, threadId) {
46
+ if (typeof window === "undefined") return;
47
+ try {
48
+ window.localStorage.setItem(key, threadId);
49
+ } catch {
50
+ /* private mode / blocked storage — threadId stays in-memory only */
51
+ }
52
+ },
53
+ };
54
+
55
+ /** Crypto-strong client-message id, with a non-crypto fallback. */
56
+ export function webGenerateId(): string {
57
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
58
+ return crypto.randomUUID();
59
+ }
60
+ return `cmid-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
61
+ }
62
+
63
+ /**
64
+ * Web SSE transport. When `accessToken` is present the pod identifies the
65
+ * caller by their verified Cognito access token (the same identity the
66
+ * history read plane uses, so persisted threads round-trip on reload).
67
+ * Otherwise it falls back to `credentials: "include"`, which round-trips the
68
+ * HttpOnly `guuey_guest` cookie the pod mints for anonymous browser callers.
69
+ * Reads the body via `ReadableStream.getReader()` (browser).
70
+ */
71
+ export async function* fetchStreamTransport(
72
+ req: InvokeRequest,
73
+ accessToken?: string | null,
74
+ ): AsyncGenerator<string> {
75
+ const headers: Record<string, string> = {
76
+ "Content-Type": "application/json",
77
+ Accept: "text/event-stream",
78
+ };
79
+ const init: RequestInit = {
80
+ method: "POST",
81
+ signal: req.signal,
82
+ headers,
83
+ body: JSON.stringify(req.body),
84
+ };
85
+ if (accessToken) {
86
+ headers.Authorization = `Bearer ${accessToken}`;
87
+ } else {
88
+ init.credentials = "include";
89
+ }
90
+ const resp = await fetch(req.url, init);
91
+ if (!resp.ok || !resp.body) {
92
+ // Surface a structured pod error ({ code, message }) when present — e.g. a
93
+ // QUOTA_EXCEEDED 429 carries an upgrade message the UI should show. Fall
94
+ // back to the bare status for non-JSON failures.
95
+ const body: unknown = await resp.json().catch(() => null);
96
+ let message = `agent responded ${resp.status}`;
97
+ let code: string | undefined;
98
+ if (body !== null && typeof body === "object") {
99
+ if ("message" in body && typeof body.message === "string" && body.message) {
100
+ message = body.message;
101
+ }
102
+ if ("code" in body && typeof body.code === "string") {
103
+ code = body.code;
104
+ }
105
+ }
106
+ throw new AgentResponseError(message, resp.status, code);
107
+ }
108
+ const reader = resp.body.getReader();
109
+ const decoder = new TextDecoder();
110
+ for (;;) {
111
+ const { value, done } = await reader.read();
112
+ if (done) break;
113
+ yield decoder.decode(value, { stream: true });
114
+ }
115
+ }
116
+
117
+ export interface CreateWebAdaptersOptions {
118
+ /**
119
+ * Public read-plane base (ending in `/v1`) for transcript history. When
120
+ * omitted, no history adapter is installed and reloads start empty.
121
+ */
122
+ apiBaseUrl?: string;
123
+ /**
124
+ * Resolve the caller's Cognito access token (fresh), or `null` when signed
125
+ * out. When a token is present the chat transport AND the history read
126
+ * authenticate as that user, so a reload restores the transcript. Without
127
+ * a token the transport falls back to the guest cookie and history is
128
+ * skipped — the read plane can't identify a cookie-only browser caller
129
+ * (it reads the `x-guuey-guest` header or a Bearer, not the HttpOnly
130
+ * guest cookie), so there is no identity to replay.
131
+ */
132
+ getAccessToken?: () => Promise<string | null>;
133
+ }
134
+
135
+ /**
136
+ * Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
137
+ * access-token resolver (and the read-plane base) to authenticate the chat
138
+ * transport and enable transcript restore on reload; omit them for an
139
+ * anonymous, history-less bundle.
140
+ */
141
+ export function createWebAdapters(
142
+ opts: CreateWebAdaptersOptions = {},
143
+ ): AgentInvokeAdapters {
144
+ const { apiBaseUrl, getAccessToken } = opts;
145
+
146
+ const transport: InvokeTransport = async function* (req) {
147
+ const token = getAccessToken ? await getAccessToken() : null;
148
+ yield* fetchStreamTransport(req, token);
149
+ };
150
+
151
+ const adapters: AgentInvokeAdapters = {
152
+ storage: localStorageThreadStore,
153
+ generateId: webGenerateId,
154
+ transport,
155
+ };
156
+
157
+ if (apiBaseUrl && getAccessToken) {
158
+ adapters.history = {
159
+ load: async (threadId) => {
160
+ const token = await getAccessToken();
161
+ // No readable identity → leave the chat empty (skip) rather than
162
+ // `gone`, which would clear the persisted threadId.
163
+ if (!token) return { messages: [] };
164
+ return fetchThreadHistory({
165
+ baseUrl: apiBaseUrl,
166
+ threadId,
167
+ requestInit: { headers: { Authorization: `Bearer ${token}` } },
168
+ });
169
+ },
170
+ };
171
+ }
172
+
173
+ return adapters;
174
+ }