@johndimm/constellations 1.0.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.
Files changed (44) hide show
  1. package/App.tsx +480 -0
  2. package/FullPageConstellations.tsx +74 -0
  3. package/FullPageConstellationsHostShell.tsx +27 -0
  4. package/README.md +116 -0
  5. package/components/AppConfirmDialog.tsx +46 -0
  6. package/components/AppHeader.tsx +73 -0
  7. package/components/AppNotifications.tsx +21 -0
  8. package/components/BrowsePeople.tsx +832 -0
  9. package/components/ControlPanel.tsx +1023 -0
  10. package/components/Graph.tsx +1525 -0
  11. package/components/HelpOverlay.tsx +168 -0
  12. package/components/NodeContextMenu.tsx +160 -0
  13. package/components/PeopleBrowserSidebar.tsx +690 -0
  14. package/components/Sidebar.tsx +271 -0
  15. package/components/TimelineView.tsx +4 -0
  16. package/hooks/useExpansion.ts +889 -0
  17. package/hooks/useGraphActions.ts +325 -0
  18. package/hooks/useGraphState.ts +414 -0
  19. package/hooks/useKioskMode.ts +47 -0
  20. package/hooks/useNodeClickHandler.ts +172 -0
  21. package/hooks/useSearchHandlers.ts +369 -0
  22. package/host.ts +16 -0
  23. package/index.css +101 -0
  24. package/index.tsx +16 -0
  25. package/kioskDomains.ts +307 -0
  26. package/package.json +78 -0
  27. package/services/aiUtils.ts +364 -0
  28. package/services/cacheService.ts +76 -0
  29. package/services/crossrefService.ts +107 -0
  30. package/services/geminiService.ts +1359 -0
  31. package/services/get-local-graphs.js +5 -0
  32. package/services/graphUtils.ts +347 -0
  33. package/services/imageService.ts +39 -0
  34. package/services/llmClient.ts +194 -0
  35. package/services/openAlexService.ts +173 -0
  36. package/services/wikipediaImage.ts +40 -0
  37. package/services/wikipediaService.ts +1175 -0
  38. package/sessionHandoff.ts +132 -0
  39. package/types.ts +99 -0
  40. package/useFullPageConstellationsHost.ts +116 -0
  41. package/utils/evidenceUtils.ts +107 -0
  42. package/utils/graphLogicUtils.ts +32 -0
  43. package/utils/graphNodeToChannelNotes.ts +71 -0
  44. package/utils/wikiUtils.ts +34 -0
@@ -0,0 +1,364 @@
1
+ export const getEnvVar = (name: string): string => {
2
+ // Try process.env first (Node.js / Server)
3
+ try {
4
+ if (typeof process !== 'undefined' && process.env) {
5
+ const val = process.env[name];
6
+ if (val) return val;
7
+ }
8
+ } catch (e) { }
9
+
10
+ // Try import.meta.env (Vite / Browser)
11
+ try {
12
+ // @ts-ignore
13
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
14
+ // @ts-ignore
15
+ const val = import.meta.env[name];
16
+ if (val) return val;
17
+ }
18
+ } catch (e) { }
19
+
20
+ return "";
21
+ };
22
+
23
+ export const getEnvCacheUrl = (): string => {
24
+ // Use literal access for Vite static replacement
25
+ let url = "";
26
+ try {
27
+ // @ts-ignore
28
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
29
+ // @ts-ignore
30
+ url = import.meta.env.VITE_CACHE_URL || import.meta.env.VITE_CACHE_API_URL || "";
31
+ }
32
+ } catch (e) { }
33
+
34
+ if (url) return url;
35
+
36
+ return getEnvVar("VITE_CACHE_URL") || getEnvVar("VITE_CACHE_API_URL");
37
+ };
38
+
39
+ export const getEnvGeminiModel = (): string => {
40
+ // Literal access for Vite
41
+ let urlModel = "";
42
+ try {
43
+ // @ts-ignore
44
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
45
+ // @ts-ignore
46
+ urlModel = import.meta.env.VITE_GEMINI_MODEL || "";
47
+ }
48
+ } catch (e) { }
49
+ if (urlModel) return urlModel;
50
+
51
+ return getEnvVar("VITE_GEMINI_MODEL") || "gemini-2.5-flash";
52
+ };
53
+
54
+ export const getEnvGeminiModelClassify = (): string => {
55
+ // Literal access for Vite
56
+ let urlModel = "";
57
+ try {
58
+ // @ts-ignore
59
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
60
+ // @ts-ignore
61
+ urlModel = import.meta.env.VITE_GEMINI_MODEL_CLASSIFY || "";
62
+ }
63
+ } catch (e) { }
64
+ if (urlModel) return urlModel;
65
+
66
+ return getEnvVar("VITE_GEMINI_MODEL_CLASSIFY") || getEnvGeminiModel();
67
+ };
68
+
69
+ export type LlmProviderId = "gemini" | "openai" | "deepseek" | "anthropic";
70
+
71
+ /** Node cache server only: per-request override from JSON body `llmProvider`. */
72
+ let readServerRequestLlm: () => LlmProviderId | null = () => null;
73
+
74
+ /** Register reader from server.ts (uses AsyncLocalStorage). No-op in the browser bundle. */
75
+ export function registerServerRequestLlmReader(reader: () => LlmProviderId | null): void {
76
+ readServerRequestLlm = reader;
77
+ }
78
+
79
+ const BROWSER_LLM_KEY = "constellations_llm_provider";
80
+
81
+ /** In-browser override (ControlPanel); ignored on the Node cache server. */
82
+ export function getBrowserLlmOverride(): LlmProviderId | null {
83
+ if (typeof window === "undefined") return null;
84
+ try {
85
+ const v = window.localStorage.getItem(BROWSER_LLM_KEY)?.trim().toLowerCase();
86
+ if (v === "openai" || v === "deepseek" || v === "anthropic" || v === "gemini") {
87
+ return v;
88
+ }
89
+ } catch {
90
+ /* ignore */
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /** Persist or clear browser LLM choice. Pass null to follow .env / VITE_LLM_PROVIDER again. */
96
+ export function setBrowserLlmOverride(provider: LlmProviderId | null): void {
97
+ if (typeof window === "undefined") return;
98
+ try {
99
+ if (provider === null) {
100
+ window.localStorage.removeItem(BROWSER_LLM_KEY);
101
+ } else {
102
+ window.localStorage.setItem(BROWSER_LLM_KEY, provider);
103
+ }
104
+ } catch {
105
+ /* ignore */
106
+ }
107
+ }
108
+
109
+ /** Set LLM_PROVIDER (preferred on servers) or VITE_LLM_PROVIDER to openai | deepseek | anthropic | gemini (default). In the browser, a ControlPanel choice overrides via localStorage. On the cache server, an optional JSON field llmProvider overrides for that request only. */
110
+ export function getLlmProvider(): LlmProviderId {
111
+ const req = readServerRequestLlm();
112
+ if (req) return req;
113
+
114
+ const browser = getBrowserLlmOverride();
115
+ if (browser) return browser;
116
+
117
+ // LLM_PROVIDER first: Render/Heroku/etc. set this; VITE_* must not override it if both exist.
118
+ const raw = (getEnvVar("LLM_PROVIDER") || getEnvVar("VITE_LLM_PROVIDER") || "gemini")
119
+ .trim()
120
+ .toLowerCase();
121
+ if (raw === "openai" || raw === "deepseek" || raw === "anthropic" || raw === "gemini") {
122
+ return raw;
123
+ }
124
+ return "gemini";
125
+ }
126
+
127
+ /** API key for the active provider (Gemini uses existing getApiKey / AI Studio). */
128
+ export async function getLlmApiKey(): Promise<string> {
129
+ const p = getLlmProvider();
130
+ if (p === "gemini") {
131
+ return getApiKey();
132
+ }
133
+ // Prefer plain env names on servers (OPENAI_API_KEY); VITE_* is for local Vite client.
134
+ const keys: Record<Exclude<LlmProviderId, "gemini">, [string, string]> = {
135
+ openai: ["OPENAI_API_KEY", "VITE_OPENAI_API_KEY"],
136
+ deepseek: ["DEEPSEEK_API_KEY", "VITE_DEEPSEEK_API_KEY"],
137
+ anthropic: ["ANTHROPIC_API_KEY", "VITE_ANTHROPIC_API_KEY"],
138
+ };
139
+ const [primary, secondary] = keys[p];
140
+ return getEnvVar(primary) || getEnvVar(secondary);
141
+ }
142
+
143
+ // Robust text extraction from Gemini API response
144
+ export function getResponseText(response: any): string {
145
+ if (!response) return "";
146
+
147
+ // 1. Check if this is the GenerateContentResult wrapper
148
+ const actualResponse = response.response || response;
149
+
150
+ // 2. Check for .text() method (Standard SDK)
151
+ if (typeof actualResponse.text === 'function') {
152
+ try {
153
+ const t = actualResponse.text();
154
+ if (t) return t;
155
+ } catch (e) { }
156
+ }
157
+
158
+ // 3. Check for .text property
159
+ if (typeof actualResponse.text === 'string') return actualResponse.text;
160
+
161
+ // 4. Deep dive into candidates
162
+ try {
163
+ const candidates = actualResponse.candidates || [];
164
+ if (candidates.length > 0) {
165
+ const parts = candidates[0].content?.parts || [];
166
+ const textPart = parts.find((p: any) => p.text);
167
+ if (textPart) return textPart.text;
168
+ }
169
+ } catch (e) { }
170
+
171
+ return "";
172
+ }
173
+
174
+ // Clean JSON response from markdown wrappers
175
+ export function cleanJson(text: unknown): string {
176
+ if (typeof text !== "string") return "";
177
+ // Remove markdown code blocks if present (e.g. ```json ... ``` or ``` ...)
178
+ return text.replace(/```(?:json)?\s*([\s\S]*?)\s*```/g, '$1').trim();
179
+ }
180
+
181
+ // Safely retrieve API key
182
+ export async function getApiKey() {
183
+ let key = "";
184
+
185
+ // Try process.env first (Node.js)
186
+ try {
187
+ if (typeof process !== 'undefined' && process.env) {
188
+ const env = process.env;
189
+ key = env.VITE_API_KEY ||
190
+ env.NEXT_PUBLIC_API_KEY ||
191
+ env.REACT_APP_API_KEY ||
192
+ env.API_KEY ||
193
+ env.VITE_GEMINI_API_KEY ||
194
+ env.GEMINI_API_KEY ||
195
+ "";
196
+ }
197
+ } catch (e) { }
198
+
199
+ if (!key) {
200
+ try {
201
+ // @ts-ignore
202
+ if (typeof import.meta !== 'undefined' && import.meta.env) {
203
+ // Use literal access for Vite static replacement
204
+ // @ts-ignore
205
+ key = import.meta.env.VITE_API_KEY ||
206
+ // @ts-ignore
207
+ import.meta.env.VITE_GEMINI_API_KEY ||
208
+ "";
209
+ }
210
+ } catch (e) { }
211
+ }
212
+
213
+ if (!key && typeof window !== 'undefined' && (window as any).aistudio) {
214
+ try {
215
+ key = await (window as any).aistudio.getSelectedApiKey();
216
+ } catch (e) { }
217
+ }
218
+
219
+ // Log once whether a key was found (prefix only), to debug missing-key issues without leaking it.
220
+ if (typeof window !== 'undefined') {
221
+ (window as any).__codex_key_logged = (window as any).__codex_key_logged || false;
222
+ if (!(window as any).__codex_key_logged) {
223
+ console.log(`[Key] resolved ${key ? 'present' : 'missing'}${key ? ` (prefix: ${key.slice(0, 6)})` : ''}`);
224
+ (window as any).__codex_key_logged = true;
225
+ }
226
+ }
227
+
228
+ return key;
229
+ }
230
+
231
+ /**
232
+ * `fetch` with a hard timeout so a hung cache/LLM endpoint cannot leave expansions spinning forever.
233
+ * Do not pass `signal` in init unless you compose with this controller (not supported here).
234
+ */
235
+ export async function fetchWithTimeout(
236
+ input: RequestInfo | URL,
237
+ init: RequestInit = {},
238
+ timeoutMs = 45000,
239
+ ): Promise<Response> {
240
+ const controller = new AbortController();
241
+ const id = setTimeout(() => controller.abort(), timeoutMs);
242
+ try {
243
+ return await fetch(input, { ...init, signal: controller.signal });
244
+ } finally {
245
+ clearTimeout(id);
246
+ }
247
+ }
248
+
249
+ /** Truncate for console; LLM prompts/contexts can be huge. */
250
+ export function clipForLlmLog(text: string, maxChars = 16000): string {
251
+ const s = String(text ?? "");
252
+ if (s.length <= maxChars) return s;
253
+ return `${s.slice(0, maxChars)}\n… [truncated ${s.length - maxChars} more chars]`;
254
+ }
255
+
256
+ // Wrap promise with timeout
257
+ export function withTimeout<T>(promise: Promise<T>, ms: number, errorMsg: string): Promise<T> {
258
+ return new Promise((resolve, reject) => {
259
+ const timer = setTimeout(() => {
260
+ reject(new Error(errorMsg));
261
+ }, ms);
262
+
263
+ promise
264
+ .then(value => {
265
+ clearTimeout(timer);
266
+ resolve(value);
267
+ })
268
+ .catch(reason => {
269
+ clearTimeout(timer);
270
+ reject(reason);
271
+ });
272
+ });
273
+ }
274
+
275
+ /** Collects message / nested API fields / JSON so 429s are not missed as "[object Object]". */
276
+ function errorTextForMatch(e: any): string {
277
+ const parts: string[] = [];
278
+ if (e?.message) parts.push(String(e.message));
279
+ if (e?.error) {
280
+ parts.push(
281
+ typeof e.error === "string" ? e.error : JSON.stringify(e.error)
282
+ );
283
+ }
284
+ if (e?.status) parts.push(String(e.status));
285
+ if (e?.code !== undefined && e?.code !== null) parts.push(String(e.code));
286
+ if (typeof e === "string") parts.push(e);
287
+ if (typeof e === "object" && parts.length === 0) {
288
+ try {
289
+ parts.push(JSON.stringify(e));
290
+ } catch {
291
+ parts.push(String(e));
292
+ }
293
+ }
294
+ return parts.join(" ").toLowerCase();
295
+ }
296
+
297
+ /** True for HTTP 429 / RESOURCE_EXHAUSTED (e.g. Vertex quota). */
298
+ export function isRateLimitError(e: any): boolean {
299
+ if (e?.error?.code === 429) return true;
300
+ if (e?.code === 429) return true;
301
+ const s = String(e?.error?.status || "").toLowerCase();
302
+ if (s === "resource_exhausted") return true;
303
+ const t = errorTextForMatch(e);
304
+ return t.includes("429") || t.includes("resource_exhausted");
305
+ }
306
+
307
+ function isTransientError(errText: string): boolean {
308
+ return (
309
+ errText.includes("429") ||
310
+ errText.includes("resource_exhausted") ||
311
+ errText.includes("rate limit") ||
312
+ errText.includes("timeout") ||
313
+ errText.includes("fetch") ||
314
+ errText.includes("network") ||
315
+ errText.includes("econnreset") ||
316
+ errText.includes("etimedout") ||
317
+ errText.includes("503") ||
318
+ errText.includes("unavailable")
319
+ );
320
+ }
321
+
322
+ /**
323
+ * Retries on transient API failures. If a 429 (rate / quota) is seen, the run can extend
324
+ * to `rateLimitAttempts` tries with longer waits (Vertex often needs many seconds between retries).
325
+ */
326
+ export async function withRetry<T>(
327
+ fn: () => Promise<T>,
328
+ attempts = 3,
329
+ backoffMs = 1000,
330
+ rateLimitAttempts = 8
331
+ ): Promise<T> {
332
+ let lastError: any;
333
+ let maxTries = Math.max(1, attempts);
334
+ for (let i = 0; i < maxTries; i++) {
335
+ try {
336
+ return await fn();
337
+ } catch (error: any) {
338
+ lastError = error;
339
+ if (isRateLimitError(error)) {
340
+ maxTries = Math.max(maxTries, rateLimitAttempts);
341
+ }
342
+ const errText = errorTextForMatch(error);
343
+ const retryable = isTransientError(errText);
344
+ const isLast = i + 1 >= maxTries;
345
+ if (isLast || !retryable) {
346
+ throw error;
347
+ }
348
+ const isRate = isRateLimitError(error);
349
+ // Longer waits for 429/RESOURCE_EXHAUSTED (capped) vs generic transient errors
350
+ const baseDelay = isRate
351
+ ? Math.min(90_000, 5_000 * Math.pow(1.45, i))
352
+ : backoffMs * Math.pow(2, i);
353
+ const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1);
354
+ const delay = Math.max(0, baseDelay + jitter);
355
+
356
+ console.warn(
357
+ `[Retry] Attempt ${i + 1} failed. Retrying in ${Math.round(delay)}ms...`,
358
+ errText.slice(0, 500)
359
+ );
360
+ await new Promise((res) => setTimeout(res, delay));
361
+ }
362
+ }
363
+ throw lastError;
364
+ }
@@ -0,0 +1,76 @@
1
+ import { getEnvCacheUrl } from "./aiUtils";
2
+
3
+ // Logic to determine effective cache base URL
4
+ // If running in extension, we might need a fixed URL or env var.
5
+ // For now, defaulting to localhost:4000 if not set, similar to App.tsx logic.
6
+ export const getEffectiveCacheBaseUrl = () => {
7
+ return getEnvCacheUrl();
8
+ };
9
+
10
+ export const fetchCacheExpansion = async (sourceId: number, baseUrl: string) => {
11
+ if (!baseUrl) return null;
12
+ try {
13
+ const url = new URL("/expansion", baseUrl);
14
+ url.searchParams.set("sourceId", sourceId.toString());
15
+ const res = await fetch(url.toString());
16
+ if (!res.ok) return null;
17
+ return res.json();
18
+ } catch (e) {
19
+ // console.warn("Cache fetch failed", e);
20
+ return null;
21
+ }
22
+ };
23
+
24
+ export const saveCacheExpansion = async (sourceId: number, nodesToSave: any[], baseUrl: string) => {
25
+ if (!baseUrl) return null;
26
+ try {
27
+ const res = await fetch(new URL("/expansion", baseUrl).toString(), {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify({
31
+ sourceId,
32
+ nodes: nodesToSave.map(n => ({
33
+ title: n.title || n.id,
34
+ type: n.type,
35
+ description: n.description || "",
36
+ year: n.year || null,
37
+ meta: n.meta || {},
38
+ wikipedia_id: n.wikipedia_id,
39
+ edge_label: n.edge_label || null,
40
+ edge_meta: n.edge_meta || null
41
+ }))
42
+ })
43
+ });
44
+ if (!res.ok) {
45
+ const text = await res.text();
46
+ return { ok: false, status: res.status, body: text };
47
+ }
48
+ return await res.json();
49
+ } catch (e) {
50
+ // console.warn("Cache save failed", e);
51
+ return { ok: false, error: String(e) };
52
+ }
53
+ };
54
+
55
+ export const upsertCacheNode = async (node: {
56
+ title?: string;
57
+ type?: string;
58
+ description?: string | null;
59
+ year?: number | null;
60
+ meta?: Record<string, any> | null;
61
+ wikipedia_id?: string | null;
62
+ }, baseUrl: string) => {
63
+ if (!baseUrl) return null;
64
+ try {
65
+ const res = await fetch(new URL("/node", baseUrl).toString(), {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify(node)
69
+ });
70
+ if (!res.ok) return null;
71
+ return await res.json();
72
+ } catch (e) {
73
+ // console.warn("Node upsert failed", e);
74
+ return null;
75
+ }
76
+ };
@@ -0,0 +1,107 @@
1
+ type CrossrefWork = {
2
+ DOI?: string;
3
+ title?: string[];
4
+ author?: Array<{ given?: string; family?: string }>;
5
+ "container-title"?: string[];
6
+ published?: { "date-parts"?: number[][] };
7
+ created?: { "date-parts"?: number[][] };
8
+ URL?: string;
9
+ };
10
+
11
+ function clean(s?: string) {
12
+ return String(s || "").replace(/\s+/g, " ").trim();
13
+ }
14
+
15
+ function yearFromParts(parts?: number[][]) {
16
+ const y = parts?.[0]?.[0];
17
+ return typeof y === "number" && Number.isFinite(y) ? y : undefined;
18
+ }
19
+
20
+ function bestYear(msg: CrossrefWork) {
21
+ return yearFromParts(msg.published?.["date-parts"]) ?? yearFromParts(msg.created?.["date-parts"]);
22
+ }
23
+
24
+ function bestTitle(msg: CrossrefWork) {
25
+ const t = msg.title?.[0];
26
+ return clean(t);
27
+ }
28
+
29
+ function bestUrl(msg: CrossrefWork) {
30
+ const doi = clean(msg.DOI);
31
+ if (doi) return `https://doi.org/${doi}`;
32
+ const u = clean(msg.URL);
33
+ return u || undefined;
34
+ }
35
+
36
+ async function fetchJson(url: string) {
37
+ const res = await fetch(url, { headers: { Accept: "application/json" } });
38
+ if (!res.ok) throw new Error(`Crossref request failed: ${res.status} ${res.statusText}`);
39
+ return await res.json();
40
+ }
41
+
42
+ export async function fetchCrossrefWorkByDoi(doi: string): Promise<CrossrefWork | null> {
43
+ const d = clean(doi).replace(/^https?:\/\/doi\.org\//i, "");
44
+ if (!d) return null;
45
+
46
+ // Prefer backend proxy (avoids CORS / rate-limit issues).
47
+ const proxyUrl = `/api/crossref/work?doi=${encodeURIComponent(d)}`;
48
+ try {
49
+ const json = await fetchJson(proxyUrl);
50
+ const msg: CrossrefWork | undefined = json?.message;
51
+ return msg || null;
52
+ } catch {
53
+ // Fallback to direct Crossref if proxy isn't running.
54
+ const directUrl = `https://api.crossref.org/works/${encodeURIComponent(d)}`;
55
+ const json = await fetchJson(directUrl);
56
+ const msg: CrossrefWork | undefined = json?.message;
57
+ return msg || null;
58
+ }
59
+ }
60
+
61
+ export function crossrefWorkToPaperNode(msg: CrossrefWork) {
62
+ const title = bestTitle(msg) || "Untitled";
63
+ const year = bestYear(msg);
64
+ const venue = clean(msg["container-title"]?.[0] || "");
65
+ const doi = clean(msg.DOI);
66
+ const authors = (msg.author || [])
67
+ .map(a => clean([a.given, a.family].filter(Boolean).join(" ")))
68
+ .filter(Boolean);
69
+
70
+ const descParts = [
71
+ year ? `Published ${year}.` : "",
72
+ venue ? `Venue: ${venue}.` : "",
73
+ doi ? `DOI: ${doi}.` : "",
74
+ authors.length ? `Authors: ${authors.slice(0, 8).join(", ")}${authors.length > 8 ? "…" : ""}` : ""
75
+ ].filter(Boolean);
76
+
77
+ return {
78
+ title,
79
+ type: "Paper",
80
+ description: descParts.join(" "),
81
+ year,
82
+ is_atomic: false,
83
+ meta: {
84
+ doi: doi || undefined,
85
+ crossrefUrl: bestUrl(msg),
86
+ source: "crossref"
87
+ }
88
+ };
89
+ }
90
+
91
+ export function crossrefAuthors(msg: CrossrefWork) {
92
+ return (msg.author || [])
93
+ .map(a => clean([a.given, a.family].filter(Boolean).join(" ")))
94
+ .filter(Boolean);
95
+ }
96
+
97
+ export function makeCrossrefAuthorshipEvidence(msg: CrossrefWork, authorName: string) {
98
+ const paperTitle = bestTitle(msg) || "this paper";
99
+ const snippet = `${clean(authorName) || "This author"} is listed as an author of "${paperTitle}" (Crossref metadata).`;
100
+ return {
101
+ kind: "crossref" as const,
102
+ pageTitle: paperTitle,
103
+ snippet,
104
+ url: bestUrl(msg)
105
+ };
106
+ }
107
+