@opengeni/react 1.2.1-canary.37222965366001 → 1.3.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.
@@ -1,6 +1,9 @@
1
- import type { OpenGeniClient } from "@opengeni/sdk";
2
- import { ArrowUpIcon, LoaderCircleIcon, MenuIcon, XIcon } from "lucide-react";
1
+ import type { FileResourceRef, LatencyMode, OpenGeniClient, ReasoningEffort } from "@opengeni/sdk";
2
+ import { MenuIcon, XIcon } from "lucide-react";
3
3
  import { useCallback, useRef, useState, type CSSProperties, type FormEvent } from "react";
4
+ import { useWorkspaceModelCatalog } from "../hooks/use-available-models";
5
+ import { FILE_ONLY_MESSAGE_TEXT, type ComposerState } from "../hooks/use-composer";
6
+ import { useFileAttachments } from "../hooks/use-file-attachments";
4
7
  import { cn } from "../lib/cn";
5
8
  import { useErrorMessage } from "../lib/error-message";
6
9
  import {
@@ -9,7 +12,13 @@ import {
9
12
  type HostThemePreference,
10
13
  } from "../lib/host-theme";
11
14
  import { useOpenGeni, type ClientOverride } from "../session-context";
12
- import { SessionConversation, type SessionConversationProps } from "./session-conversation";
15
+ import { ChatComposer } from "./chat-composer";
16
+ import { ModelPolicyPicker } from "./model-policy-picker";
17
+ import {
18
+ SessionConversation,
19
+ useClientConfigFlags,
20
+ type SessionConversationProps,
21
+ } from "./session-conversation";
13
22
  import { SessionList, type SessionListLabels, type SessionListProps } from "./session-list";
14
23
  import { SessionProxyScope, type SessionProxyBaseUrl } from "./session-proxy-scope";
15
24
 
@@ -32,6 +41,16 @@ const DEFAULT_LABELS: Omit<OpenGeniChatLabels, keyof SessionListLabels> = {
32
41
  newChatUnavailable: "New chats are not enabled for this product.",
33
42
  };
34
43
 
44
+ /** What the first message carries besides its text. */
45
+ export type OpenGeniChatCreateOptions = {
46
+ /** Files attached in the new-chat composer. */
47
+ resources?: FileResourceRef[] | undefined;
48
+ /** Present only when the user changed it in the model picker. */
49
+ model?: string | undefined;
50
+ reasoningEffort?: ReasoningEffort | undefined;
51
+ latencyMode?: LatencyMode | undefined;
52
+ };
53
+
35
54
  export type OpenGeniChatProps = ClientOverride &
36
55
  SessionProxyBaseUrl & {
37
56
  /** Controlled selection. `null` shows the new-chat composer. */
@@ -41,18 +60,27 @@ export type OpenGeniChatProps = ClientOverride &
41
60
  onSessionChange?: ((sessionId: string | null) => void) | undefined;
42
61
  /**
43
62
  * Create a chat from its first message. Defaults to `client.createSession`
44
- * with `{ initialMessage, idempotencyKey }`, which `createSessionProxyHandler`
45
- * accepts when the server supplies a `createSession` hook. Return the new id.
63
+ * with `{ initialMessage, idempotencyKey }` plus the attached files and any
64
+ * model choice, which `createSessionProxyHandler` accepts when the server
65
+ * supplies a `createSession` hook. Return the new id.
46
66
  */
47
67
  createSession?:
48
- | ((initialMessage: string, idempotencyKey: string) => Promise<string>)
68
+ | ((
69
+ initialMessage: string,
70
+ idempotencyKey: string,
71
+ options: OpenGeniChatCreateOptions,
72
+ ) => Promise<string>)
49
73
  | undefined;
50
74
  /** Hide the "New chat" entry point. */
51
75
  newChat?: boolean | undefined;
52
- /** Forwarded to the conversation (message rendering, tool renderers, composer...). */
76
+ /**
77
+ * Forwarded to the conversation (message rendering, tool renderers,
78
+ * composer...). `attachments`, `modelPicker`, `modelPickerProps`, and
79
+ * `composerProps` also apply to the new-chat composer.
80
+ */
53
81
  conversationProps?: Omit<
54
82
  SessionConversationProps,
55
- "sessionId" | "client" | "workspaceId" | "baseUrl"
83
+ "sessionId" | "client" | "workspaceId" | "baseUrl" | "headers" | "fetch"
56
84
  >;
57
85
  /** Forwarded to the list (rename/archive toggles, page size). */
58
86
  listProps?: Pick<SessionListProps, "rename" | "archive" | "pageSize"> | undefined;
@@ -83,11 +111,17 @@ type CreateClient = Partial<Pick<OpenGeniClient, "createSession">>;
83
111
  * `<OpenGeniChat baseUrl="/api/opengeni" />` needs no provider: it talks to
84
112
  * your session proxy and uses the workspace the proxy resolves.
85
113
  */
86
- export function OpenGeniChat({ baseUrl, ...props }: OpenGeniChatProps) {
114
+ export function OpenGeniChat({ baseUrl, headers, fetch, ...props }: OpenGeniChatProps) {
87
115
  if (baseUrl === undefined) return <Chat {...props} />;
88
- const { client: _client, workspaceId, ...rest } = props;
116
+ const { client, workspaceId, ...rest } = props;
89
117
  return (
90
- <SessionProxyScope baseUrl={baseUrl} workspaceId={workspaceId}>
118
+ <SessionProxyScope
119
+ baseUrl={baseUrl}
120
+ workspaceId={workspaceId}
121
+ client={client}
122
+ headers={headers}
123
+ fetch={fetch}
124
+ >
91
125
  <Chat {...rest} />
92
126
  </SessionProxyScope>
93
127
  );
@@ -108,7 +142,7 @@ function Chat({
108
142
  height = "100%",
109
143
  theme,
110
144
  surface,
111
- }: Omit<OpenGeniChatProps, "baseUrl">) {
145
+ }: Omit<OpenGeniChatProps, "baseUrl" | "headers" | "fetch">) {
112
146
  const labels = { ...DEFAULT_LABELS, ...labelOverrides } as OpenGeniChatLabels;
113
147
  const scope = { client, workspaceId };
114
148
  const context = useOpenGeni(scope);
@@ -129,13 +163,18 @@ function Chat({
129
163
  );
130
164
 
131
165
  const create = useCallback(
132
- async (initialMessage: string, idempotencyKey: string): Promise<string> => {
133
- if (createSession) return await createSession(initialMessage, idempotencyKey);
166
+ async (
167
+ initialMessage: string,
168
+ idempotencyKey: string,
169
+ options: OpenGeniChatCreateOptions,
170
+ ): Promise<string> => {
171
+ if (createSession) return await createSession(initialMessage, idempotencyKey, options);
134
172
  const creator = (context.client as unknown as CreateClient).createSession;
135
173
  if (typeof creator !== "function") throw new Error(labels.newChatUnavailable);
136
174
  const created = await creator.call(context.client, context.workspaceId, {
137
175
  initialMessage,
138
176
  idempotencyKey,
177
+ ...options,
139
178
  } as Parameters<NonNullable<CreateClient["createSession"]>>[1]);
140
179
  return created.id;
141
180
  },
@@ -223,7 +262,10 @@ function Chat({
223
262
  />
224
263
  ) : (
225
264
  <NewChat
265
+ scope={scope}
226
266
  labels={labels}
267
+ placeholderOverride={labelOverrides?.newChatPlaceholder}
268
+ conversationProps={conversationProps}
227
269
  create={create}
228
270
  onCreated={(id) => {
229
271
  setListRevision((revision) => revision + 1);
@@ -237,43 +279,137 @@ function Chat({
237
279
  );
238
280
  }
239
281
 
282
+ const NOOP_ASYNC = async () => {};
283
+
284
+ /**
285
+ * The first message of a new chat, in the same composer as follow-ups: file
286
+ * attachments, the model picker when offered, and the host's `composerProps`
287
+ * (custom controls, voice input, copy).
288
+ */
240
289
  function NewChat({
290
+ scope,
241
291
  labels,
292
+ placeholderOverride,
293
+ conversationProps,
242
294
  create,
243
295
  onCreated,
244
296
  }: {
297
+ scope: ClientOverride;
245
298
  labels: OpenGeniChatLabels;
246
- create: (initialMessage: string, idempotencyKey: string) => Promise<string>;
299
+ placeholderOverride: string | undefined;
300
+ conversationProps: OpenGeniChatProps["conversationProps"];
301
+ create: (
302
+ initialMessage: string,
303
+ idempotencyKey: string,
304
+ options: OpenGeniChatCreateOptions,
305
+ ) => Promise<string>;
247
306
  onCreated: (sessionId: string) => void;
248
307
  }) {
308
+ const context = useOpenGeni(scope);
309
+ const config = useClientConfigFlags(context.client);
310
+ const composerProps = conversationProps?.composerProps;
311
+ const modelPickerProps = conversationProps?.modelPickerProps;
312
+ const showModelPicker = conversationProps?.modelPicker ?? config.modelSelection;
313
+ const catalog = useWorkspaceModelCatalog({
314
+ client: context.client,
315
+ workspaceId: context.workspaceId,
316
+ enabled: showModelPicker,
317
+ });
318
+ const files = useFileAttachments(scope);
319
+ const uploadsEnabled = (conversationProps?.attachments ?? true) && config.uploads;
249
320
  const [text, setText] = useState("");
250
321
  const [sending, setSending] = useState(false);
251
322
  const [error, setError] = useState<{ cause: unknown } | null>(null);
323
+ // Only what the user changed is sent; everything else stays server-owned.
324
+ const [choice, setChoice] = useState<
325
+ Pick<OpenGeniChatCreateOptions, "model" | "reasoningEffort" | "latencyMode">
326
+ >({});
252
327
  const formatError = useErrorMessage();
253
- // One key per draft, so a retried send returns the same chat.
254
- const idempotencyKey = useRef(crypto.randomUUID());
328
+ // One key per attempted payload, so a retried send returns the same chat
329
+ // and an edited retry is a new request rather than an idempotency conflict.
330
+ const attempt = useRef<{ fingerprint: string; key: string } | null>(null);
331
+ const resources = uploadsEnabled ? files.readyResources : [];
332
+ const pendingFiles = uploadsEnabled && files.hasUnresolved;
255
333
 
256
- const submit = async (event: FormEvent) => {
257
- event.preventDefault();
258
- const initialMessage = text.trim();
259
- if (!initialMessage || sending) return;
334
+ const submit = async (): Promise<boolean> => {
335
+ const initialMessage = text.trim() || (resources.length > 0 ? FILE_ONLY_MESSAGE_TEXT : "");
336
+ if (!initialMessage || sending || pendingFiles) return false;
337
+ const options: OpenGeniChatCreateOptions = {
338
+ ...(resources.length > 0 ? { resources } : {}),
339
+ ...(showModelPicker ? choice : {}),
340
+ };
341
+ const fingerprint = JSON.stringify([initialMessage, options]);
342
+ if (attempt.current?.fingerprint !== fingerprint) {
343
+ attempt.current = { fingerprint, key: crypto.randomUUID() };
344
+ }
260
345
  setSending(true);
261
346
  setError(null);
262
347
  try {
263
- const id = await create(initialMessage, idempotencyKey.current);
264
- idempotencyKey.current = crypto.randomUUID();
348
+ const id = await create(initialMessage, attempt.current.key, options);
349
+ attempt.current = null;
265
350
  setText("");
351
+ files.removeReadyFiles(resources.map((resource) => resource.fileId));
266
352
  onCreated(id);
353
+ return true;
267
354
  } catch (cause) {
268
355
  setError({ cause });
356
+ return false;
269
357
  } finally {
270
358
  setSending(false);
271
359
  }
272
360
  };
273
361
 
362
+ const defaultModel = catalog.defaultModel;
363
+ const policy =
364
+ showModelPicker && (choice.model ?? defaultModel)
365
+ ? {
366
+ model: (choice.model ?? defaultModel)!,
367
+ reasoningEffort:
368
+ choice.reasoningEffort ?? config.defaultReasoningEffort ?? ("medium" as const),
369
+ latencyMode: choice.latencyMode ?? ("standard" as const),
370
+ }
371
+ : null;
372
+
373
+ const composer: ComposerState = {
374
+ value: text,
375
+ setValue: setText,
376
+ hasDraftContent: () => text.length > 0 || files.attachments.length > 0,
377
+ send: submit,
378
+ steer: submit,
379
+ sending,
380
+ canSend: (text.trim().length > 0 || resources.length > 0) && !sending && !pendingFiles,
381
+ pause: NOOP_ASYNC,
382
+ pausing: false,
383
+ resume: NOOP_ASYNC,
384
+ resumeScope: NOOP_ASYNC,
385
+ resuming: false,
386
+ draft: null,
387
+ draftRevision: 0,
388
+ draftLoading: false,
389
+ draftSaving: false,
390
+ draftConflict: null,
391
+ policy,
392
+ setModel: (model) => setChoice((current) => ({ ...current, model })),
393
+ setReasoningEffort: (reasoningEffort) =>
394
+ setChoice((current) => ({ ...current, reasoningEffort })),
395
+ setLatencyMode: (latencyMode) => setChoice((current) => ({ ...current, latencyMode })),
396
+ draftPersistence: "disabled",
397
+ applyDraft: () => {},
398
+ reloadDraft: NOOP_ASYNC,
399
+ resolveDraftConflict: NOOP_ASYNC,
400
+ restoredResources: [],
401
+ removeRestoredResource: () => {},
402
+ // Shown below with the product's own copy.
403
+ error: null,
404
+ clearError: () => setError(null),
405
+ };
406
+
274
407
  return (
275
408
  <form
276
- onSubmit={(event) => void submit(event)}
409
+ onSubmit={(event: FormEvent) => {
410
+ event.preventDefault();
411
+ void submit();
412
+ }}
277
413
  className="mx-auto box-border flex h-full w-full max-w-3xl flex-col gap-4 p-3"
278
414
  data-og-new-chat-composer=""
279
415
  >
@@ -282,46 +418,43 @@ function NewChat({
282
418
  {labels.newChatTitle ? (
283
419
  <p className="text-center text-og-md font-medium text-og-fg">{labels.newChatTitle}</p>
284
420
  ) : null}
285
- <div
286
- className={cn(
287
- "flex items-end gap-2 rounded-og-lg border border-og-border/90 bg-og-surface-1 p-2 pl-3.5 shadow-og-sm",
288
- "transition-[border-color,box-shadow] duration-200 ease-og-out",
289
- "focus-within:border-og-accent/50 focus-within:shadow-og-glow",
290
- )}
291
- >
292
- <textarea
293
- value={text}
294
- onChange={(event) => setText(event.target.value)}
295
- onKeyDown={(event) => {
296
- if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
297
- event.preventDefault();
298
- event.currentTarget.form?.requestSubmit();
299
- }
300
- }}
301
- placeholder={labels.newChatPlaceholder}
302
- aria-label={labels.newChatPlaceholder}
303
- rows={2}
304
- disabled={sending}
305
- className="min-h-12 flex-1 resize-none bg-transparent py-1 text-og-composer text-og-fg outline-hidden placeholder:text-og-fg-subtle md:text-og-composer-wide"
306
- />
307
- <button
308
- type="submit"
309
- aria-label={labels.send}
310
- disabled={sending || !text.trim()}
311
- className={cn(
312
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
313
- "border border-og-primary-border bg-og-primary text-og-primary-fg",
314
- "transition-[background-color,transform,opacity] duration-150 ease-og-spring",
315
- "hover:bg-og-primary-hover active:scale-95 disabled:cursor-not-allowed disabled:opacity-50",
316
- )}
317
- >
318
- {sending ? (
319
- <LoaderCircleIcon className="size-4 animate-og-spin" aria-hidden />
320
- ) : (
321
- <ArrowUpIcon className="size-4" aria-hidden />
322
- )}
323
- </button>
324
- </div>
421
+ <ChatComposer
422
+ {...composerProps}
423
+ composer={composer}
424
+ attachments={uploadsEnabled ? files : undefined}
425
+ runControl="none"
426
+ running={false}
427
+ placeholder={placeholderOverride ?? composerProps?.placeholder ?? labels.newChatPlaceholder}
428
+ inputProps={{
429
+ "aria-label": placeholderOverride ?? labels.newChatPlaceholder,
430
+ ...composerProps?.inputProps,
431
+ }}
432
+ messages={{ sendMessageAriaLabel: labels.send, ...composerProps?.messages }}
433
+ controlsStart={
434
+ composerProps?.controlsStart ??
435
+ (policy ? (
436
+ <ModelPolicyPicker
437
+ groupPresentation={modelPickerProps?.groupPresentation}
438
+ messages={modelPickerProps?.messages}
439
+ rows={catalog.rows}
440
+ model={policy.model}
441
+ effort={policy.reasoningEffort}
442
+ latencyMode={policy.latencyMode}
443
+ loading={catalog.loading}
444
+ error={catalog.error?.message}
445
+ disabled={sending}
446
+ menuSide="bottom"
447
+ onOpenChange={(open) => {
448
+ if (open) void catalog.refresh();
449
+ }}
450
+ onModelChange={composer.setModel!}
451
+ onEffortChange={composer.setReasoningEffort!}
452
+ onLatencyModeChange={composer.setLatencyMode!}
453
+ />
454
+ ) : undefined)
455
+ }
456
+ responsiveBasis={composerProps?.responsiveBasis ?? "container"}
457
+ />
325
458
  {error ? (
326
459
  <p role="alert" className="text-center text-og-sm text-og-status-failed">
327
460
  {formatError(
@@ -1,4 +1,4 @@
1
- import { SESSION_SCOPE_HEADER, type SendMessageInput } from "@opengeni/sdk";
1
+ import { SESSION_SCOPE_HEADER, type ReasoningEffort, type SendMessageInput } from "@opengeni/sdk";
2
2
  import {
3
3
  lazy,
4
4
  Suspense,
@@ -134,11 +134,22 @@ export type SessionConversationProps = ClientOverride &
134
134
  * (including Site clients), one shared event feed, and authoritative queue state.
135
135
  * `<SessionConversation baseUrl="/api/opengeni" sessionId={id} />` needs no
136
136
  * provider: it talks to your session proxy and its resolved workspace. */
137
- export function SessionConversation({ baseUrl, ...props }: SessionConversationProps) {
137
+ export function SessionConversation({
138
+ baseUrl,
139
+ headers,
140
+ fetch,
141
+ ...props
142
+ }: SessionConversationProps) {
138
143
  if (baseUrl === undefined) return <RetryingConversation {...props} />;
139
- const { client: _client, workspaceId, ...rest } = props;
144
+ const { client, workspaceId, ...rest } = props;
140
145
  return (
141
- <SessionProxyScope baseUrl={baseUrl} workspaceId={workspaceId}>
146
+ <SessionProxyScope
147
+ baseUrl={baseUrl}
148
+ workspaceId={workspaceId}
149
+ client={client}
150
+ headers={headers}
151
+ fetch={fetch}
152
+ >
142
153
  <RetryingConversation {...rest} />
143
154
  </SessionProxyScope>
144
155
  );
@@ -494,17 +505,24 @@ function useDefaultInteractiveBlock(
494
505
  }
495
506
 
496
507
  /** Deployment/proxy flags from the client config: uploads, and whether model choice is open. */
497
- function useClientConfigFlags(client: {
508
+ export function useClientConfigFlags(client: {
498
509
  getClientConfig: () => Promise<{
499
510
  fileUploads?: { enabled?: boolean };
500
511
  modelSelection?: boolean | undefined;
501
512
  sandboxFiles?: boolean | undefined;
513
+ defaultReasoningEffort?: ReasoningEffort | undefined;
502
514
  }>;
503
- }): { uploads: boolean; modelSelection: boolean; sandboxFiles: boolean } {
504
- const [flags, setFlags] = useState({
515
+ }): {
516
+ uploads: boolean;
517
+ modelSelection: boolean;
518
+ sandboxFiles: boolean;
519
+ defaultReasoningEffort: ReasoningEffort | null;
520
+ } {
521
+ const [flags, setFlags] = useState<ReturnType<typeof useClientConfigFlags>>({
505
522
  uploads: false,
506
523
  modelSelection: false,
507
524
  sandboxFiles: false,
525
+ defaultReasoningEffort: null,
508
526
  });
509
527
  useEffect(() => {
510
528
  let live = true;
@@ -516,6 +534,7 @@ function useClientConfigFlags(client: {
516
534
  // Only an explicit offer shows end users the picker.
517
535
  modelSelection: config.modelSelection === true,
518
536
  sandboxFiles: config.sandboxFiles !== false,
537
+ defaultReasoningEffort: config.defaultReasoningEffort ?? null,
519
538
  });
520
539
  }
521
540
  },
@@ -1,16 +1,29 @@
1
- import { OpenGeniBrowserClient } from "@opengeni/sdk/browser";
2
- import { useEffect, useMemo, useState, type ReactNode } from "react";
1
+ import { OpenGeniBrowserClient, type FetchLike } from "@opengeni/sdk/browser";
2
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3
+ import type { SessionClientLike } from "../client";
3
4
  import { OpenGeniProvider } from "../provider";
4
5
 
6
+ /** Request headers for every proxy call: static, or computed per request. */
7
+ export type SessionProxyHeaders = Record<string, string> | (() => Record<string, string>);
8
+
5
9
  export type SessionProxyBaseUrl = {
6
10
  /**
7
11
  * Mount path of your `createSessionProxyHandler` (for example
8
12
  * `"/api/opengeni"`). The component then needs no `OpenGeniProvider`,
9
13
  * client, or workspace id: it creates the browser client for that path and
10
14
  * uses the workspace the proxy resolved for the signed-in user. An explicit
11
- * `workspaceId` skips that lookup.
15
+ * `workspaceId` skips that lookup. A `client` you pass is used instead of
16
+ * the created one (point it at the same proxy).
12
17
  */
13
18
  baseUrl?: string | undefined;
19
+ /**
20
+ * `baseUrl` mode: headers added to every proxy request, for hosts that
21
+ * authenticate with a bearer token instead of cookies. A function is called
22
+ * per request, so it can return the current token.
23
+ */
24
+ headers?: SessionProxyHeaders | undefined;
25
+ /** `baseUrl` mode: custom fetch for every proxy request (async auth, retries). */
26
+ fetch?: FetchLike | undefined;
14
27
  };
15
28
 
16
29
  type ScopeState =
@@ -26,18 +39,45 @@ type ScopeState =
26
39
  export function SessionProxyScope({
27
40
  baseUrl,
28
41
  workspaceId,
42
+ client: suppliedClient,
43
+ headers,
44
+ fetch,
29
45
  children,
30
- }: {
46
+ }: Omit<SessionProxyBaseUrl, "baseUrl"> & {
31
47
  baseUrl: string;
32
48
  workspaceId?: string | undefined;
49
+ client?: SessionClientLike | undefined;
33
50
  children: ReactNode;
34
51
  }) {
52
+ if (suppliedClient && (headers !== undefined || fetch !== undefined)) {
53
+ throw new TypeError(
54
+ "@opengeni/react: pass headers/fetch either to your own client or as props, not both.",
55
+ );
56
+ }
57
+ // Latest values without recreating the client (inline props change identity).
58
+ const headersRef = useRef(headers);
59
+ headersRef.current = headers;
60
+ const fetchRef = useRef(fetch);
61
+ fetchRef.current = fetch;
62
+ const hasFetch = fetch !== undefined;
35
63
  // Version skew between this bundle and the server SDK is tolerated, as for
36
64
  // any embedded host: the proxy reports its own contract revision.
37
- const client = useMemo(
38
- () => new OpenGeniBrowserClient({ baseUrl, apiContract: "compatible" }),
39
- [baseUrl],
65
+ const ownClient = useMemo(
66
+ () =>
67
+ new OpenGeniBrowserClient({
68
+ baseUrl,
69
+ apiContract: "compatible",
70
+ headers: () => {
71
+ const current = headersRef.current;
72
+ return typeof current === "function" ? current() : (current ?? {});
73
+ },
74
+ ...(hasFetch
75
+ ? { fetch: (input, init) => (fetchRef.current ?? globalThis.fetch)(input, init) }
76
+ : {}),
77
+ }),
78
+ [baseUrl, hasFetch],
40
79
  );
80
+ const client: SessionClientLike = suppliedClient ?? ownClient;
41
81
  const [state, setState] = useState<ScopeState>(
42
82
  workspaceId ? { status: "ready", workspaceId } : { status: "loading" },
43
83
  );