@oceanum/oceanumlab 4.6.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 (83) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +116 -0
  3. package/lib/DatameshUI.d.ts +5 -0
  4. package/lib/DatameshUI.js +8 -0
  5. package/lib/DatameshUI.js.map +1 -0
  6. package/lib/DatameshWidget.d.ts +59 -0
  7. package/lib/DatameshWidget.js +573 -0
  8. package/lib/DatameshWidget.js.map +1 -0
  9. package/lib/DatasourceItem.d.ts +12 -0
  10. package/lib/DatasourceItem.js +47 -0
  11. package/lib/DatasourceItem.js.map +1 -0
  12. package/lib/aiLoop.d.ts +60 -0
  13. package/lib/aiLoop.js +85 -0
  14. package/lib/aiLoop.js.map +1 -0
  15. package/lib/chatRouter.d.ts +64 -0
  16. package/lib/chatRouter.js +225 -0
  17. package/lib/chatRouter.js.map +1 -0
  18. package/lib/constants.d.ts +14 -0
  19. package/lib/constants.js +15 -0
  20. package/lib/constants.js.map +1 -0
  21. package/lib/conversationPin.d.ts +68 -0
  22. package/lib/conversationPin.js +120 -0
  23. package/lib/conversationPin.js.map +1 -0
  24. package/lib/handler.d.ts +8 -0
  25. package/lib/handler.js +36 -0
  26. package/lib/handler.js.map +1 -0
  27. package/lib/index.d.ts +16 -0
  28. package/lib/index.js +273 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/kernelHandoff.d.ts +61 -0
  31. package/lib/kernelHandoff.js +236 -0
  32. package/lib/kernelHandoff.js.map +1 -0
  33. package/lib/notebookContext.d.ts +48 -0
  34. package/lib/notebookContext.js +118 -0
  35. package/lib/notebookContext.js.map +1 -0
  36. package/lib/notebookHost.d.ts +6 -0
  37. package/lib/notebookHost.js +72 -0
  38. package/lib/notebookHost.js.map +1 -0
  39. package/lib/notebookRun.d.ts +37 -0
  40. package/lib/notebookRun.js +70 -0
  41. package/lib/notebookRun.js.map +1 -0
  42. package/lib/progress.d.ts +43 -0
  43. package/lib/progress.js +103 -0
  44. package/lib/progress.js.map +1 -0
  45. package/lib/sse.d.ts +26 -0
  46. package/lib/sse.js +112 -0
  47. package/lib/sse.js.map +1 -0
  48. package/package.json +151 -0
  49. package/schema/datamesh-connect.json +35 -0
  50. package/src/DatameshUI.tsx +15 -0
  51. package/src/DatameshWidget.tsx +909 -0
  52. package/src/DatasourceItem.tsx +124 -0
  53. package/src/__tests__/aiLoop.spec.ts +304 -0
  54. package/src/__tests__/chatRouter.spec.ts +315 -0
  55. package/src/__tests__/conversationPin.spec.ts +236 -0
  56. package/src/__tests__/kernelHandoff.spec.ts +106 -0
  57. package/src/__tests__/notebookContext.spec.ts +97 -0
  58. package/src/__tests__/notebookHost.spec.ts +195 -0
  59. package/src/__tests__/notebookRun.spec.ts +95 -0
  60. package/src/__tests__/notebookSnapshot.spec.ts +62 -0
  61. package/src/__tests__/oceanumlab.spec.ts +9 -0
  62. package/src/__tests__/sse.spec.ts +304 -0
  63. package/src/aiLoop.ts +140 -0
  64. package/src/chatRouter.ts +329 -0
  65. package/src/constants.ts +16 -0
  66. package/src/conversationPin.ts +148 -0
  67. package/src/global.d.ts +5 -0
  68. package/src/handler.ts +46 -0
  69. package/src/index.ts +327 -0
  70. package/src/kernelHandoff.ts +284 -0
  71. package/src/notebookContext.ts +144 -0
  72. package/src/notebookHost.ts +97 -0
  73. package/src/notebookRun.ts +104 -0
  74. package/src/progress.ts +121 -0
  75. package/src/sse.ts +127 -0
  76. package/src/svg.d.ts +4 -0
  77. package/style/base.css +5 -0
  78. package/style/icons/oceanum-dark.svg +1 -0
  79. package/style/icons/oceanum-light.svg +1 -0
  80. package/style/icons/oceanum.png +0 -0
  81. package/style/icons/oceanum.svg +1 -0
  82. package/style/index.css +477 -0
  83. package/style/index.js +1 -0
@@ -0,0 +1,329 @@
1
+ import { ISettingRegistry } from '@jupyterlab/settingregistry';
2
+ import { OCEANUM_AI_BACKEND_URL } from './constants';
3
+ import type { Progress } from './progress';
4
+ import { readSse } from './sse';
5
+ import type { ObservedRun } from './notebookRun';
6
+ import type { INotebookSnapshot } from './notebookContext';
7
+ import { formatNotebookCells } from './notebookContext';
8
+
9
+ /** One thing the backend asks us to place in the notebook. */
10
+ export interface Block {
11
+ type: 'code' | 'markdown';
12
+ content: string;
13
+ }
14
+
15
+ /**
16
+ * What `/api/chat` answers with: one message, plus anything to place.
17
+ *
18
+ * This replaced a discriminated union of text/code/markdown responses
19
+ * (OCE-173). The union was exclusive, so the backend could not send a markdown
20
+ * table AND the query that produced it — it had to drop one. `message` is what
21
+ * goes in the chat window; `blocks` is what goes in the notebook, in order.
22
+ *
23
+ * `message` is always present now. On the old markdown variant it was optional,
24
+ * which is why the handoff used to fall back to 'Added markdown to notebook.'
25
+ */
26
+ export interface OceanumResponse {
27
+ message: string;
28
+ blocks: Block[];
29
+ }
30
+
31
+ export interface ChatMessage {
32
+ role: 'user' | 'assistant';
33
+ content: string;
34
+ }
35
+
36
+ export interface RouteResult {
37
+ response: OceanumResponse;
38
+ hasCodeCellSelected: boolean;
39
+ }
40
+
41
+ export class ChatRouterError extends Error {
42
+ constructor(
43
+ message: string,
44
+ public readonly statusCode?: number
45
+ ) {
46
+ super(message);
47
+ this.name = 'ChatRouterError';
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Check the body is the shape we expect before anything reads it.
53
+ *
54
+ * A bare `as OceanumResponse` cast used to be enough because the shape had not
55
+ * changed since the extension was written. It has now (OCE-173), and an
56
+ * extension pinned to one contract will meet a backend serving the other during
57
+ * any deploy where the two are not released together: the old body has no
58
+ * `blocks`, so `response.blocks.length` throws deep inside the handoff and the
59
+ * user sees nothing happen. One check turns that into the error the chat window
60
+ * already knows how to show.
61
+ */
62
+ function asOceanumResponse(data: unknown): OceanumResponse {
63
+ const body = data as Partial<OceanumResponse> | null;
64
+ if (
65
+ !body ||
66
+ typeof body.message !== 'string' ||
67
+ !Array.isArray(body.blocks)
68
+ ) {
69
+ throw new ChatRouterError(
70
+ 'Unexpected response from the AI backend. It may be running an ' +
71
+ 'incompatible version of the chat API.'
72
+ );
73
+ }
74
+ return { message: body.message, blocks: body.blocks };
75
+ }
76
+
77
+ export class ChatRouter {
78
+ /**
79
+ * @param _notebook What the conversation's notebook holds -- the one its
80
+ * answers are placed in -- or null if there is nothing to send. Asked on
81
+ * every request, so it follows the conversation rather than whichever tab
82
+ * happens to be active.
83
+ */
84
+ constructor(
85
+ private _settings: ISettingRegistry.ISettings,
86
+ private _notebook: () => Promise<INotebookSnapshot | null>
87
+ ) {}
88
+
89
+ async route(
90
+ prompt: string,
91
+ chatHistory: ChatMessage[] = [],
92
+ signal?: AbortSignal,
93
+ onProgress?: (progress: Progress) => void
94
+ ): Promise<RouteResult> {
95
+ const { payload, isCodeCell, context } = await this._gather(
96
+ prompt,
97
+ chatHistory
98
+ );
99
+ const data = await this._send('/api/chat', payload, signal, onProgress);
100
+ return {
101
+ response: asOceanumResponse(data),
102
+ hasCodeCellSelected: isCodeCell && context.trim().length > 0
103
+ };
104
+ }
105
+
106
+ /**
107
+ * The notebook half of the execute loop: the code ran in the user's own
108
+ * kernel, here is what happened, what next? Same context as `route`, plus
109
+ * every run so far, so the agent sees the whole chain it is continuing.
110
+ */
111
+ async observe(
112
+ prompt: string,
113
+ chatHistory: ChatMessage[],
114
+ runs: ObservedRun[],
115
+ signal?: AbortSignal,
116
+ onProgress?: (progress: Progress) => void
117
+ ): Promise<OceanumResponse> {
118
+ const { payload } = await this._gather(prompt, chatHistory);
119
+ const data = await this._send(
120
+ '/api/chat/observe',
121
+ { ...payload, runs },
122
+ signal,
123
+ onProgress
124
+ );
125
+ return asOceanumResponse(data);
126
+ }
127
+
128
+ private async _gather(
129
+ prompt: string,
130
+ chatHistory: ChatMessage[]
131
+ ): Promise<{ payload: ChatPayload; isCodeCell: boolean; context: string }> {
132
+ let context = '';
133
+ let isCodeCell = false;
134
+ let notebookCells: string[] = [];
135
+
136
+ try {
137
+ const snapshot = await this._notebook();
138
+ if (snapshot) {
139
+ notebookCells = formatNotebookCells(snapshot.cells);
140
+ // Answers are placed in this same notebook, so its selected cell is
141
+ // the one a code answer may replace.
142
+ if (snapshot.selected) {
143
+ context = snapshot.selected.source;
144
+ isCodeCell = snapshot.selected.isCode;
145
+ }
146
+ }
147
+ } catch {
148
+ // context is optional — never throw
149
+ }
150
+
151
+ // Build payload with all context
152
+ const payload: ChatPayload = { prompt };
153
+
154
+ if (context) {
155
+ if (isCodeCell) {
156
+ payload.codeContext = context;
157
+ } else {
158
+ payload.context = context;
159
+ }
160
+ }
161
+
162
+ // Include chat history (excluding the current prompt which is already in 'prompt')
163
+ if (chatHistory.length > 0) {
164
+ payload.chatHistory = chatHistory;
165
+ }
166
+
167
+ // Include the conversation's notebook cells
168
+ if (notebookCells.length > 0) {
169
+ payload.notebookCells = notebookCells;
170
+ }
171
+
172
+ return { payload, isCodeCell, context };
173
+ }
174
+
175
+ private async _send(
176
+ path: string,
177
+ payload: unknown,
178
+ signal?: AbortSignal,
179
+ onProgress?: (progress: Progress) => void
180
+ ): Promise<unknown> {
181
+ const token = this._settings.get('datameshToken').composite as string;
182
+ if (!token) {
183
+ throw new ChatRouterError(
184
+ 'Datamesh token not configured. Set your token in Settings → Oceanum.io.'
185
+ );
186
+ }
187
+
188
+ let response: Response;
189
+ try {
190
+ response = await fetch(`${OCEANUM_AI_BACKEND_URL}${path}`, {
191
+ method: 'POST',
192
+ headers: {
193
+ 'Content-Type': 'application/json',
194
+ 'X-Datamesh-Token': token,
195
+ // Ask for the streamed form only when someone is listening. The
196
+ // server requires the media type to be NAMED -- a wildcard never
197
+ // selects it -- so omitting this is what keeps the plain JSON path
198
+ // byte-identical to what it always was.
199
+ ...(onProgress ? { Accept: 'text/event-stream' } : {})
200
+ },
201
+ body: JSON.stringify(payload),
202
+ signal
203
+ });
204
+ } catch (err) {
205
+ // The user pressed Stop. Not a failure, and not "could not reach"; the
206
+ // caller checks `signal.aborted` and stays quiet.
207
+ if (signal?.aborted) {
208
+ throw err;
209
+ }
210
+ throw new ChatRouterError(
211
+ `Could not reach Oceanum AI backend at ${OCEANUM_AI_BACKEND_URL}. Is it running?`
212
+ );
213
+ }
214
+
215
+ if (response.status === 401) {
216
+ throw new ChatRouterError('Invalid or expired Datamesh token.', 401);
217
+ }
218
+
219
+ if (!response.ok) {
220
+ let detail = '';
221
+ try {
222
+ const body = await response.json();
223
+ detail = body.detail ?? JSON.stringify(body);
224
+ } catch {
225
+ detail = response.statusText;
226
+ }
227
+ throw new ChatRouterError(`Backend error: ${detail}`, response.status);
228
+ }
229
+
230
+ // Stop can also land while the body is still being read; that rejects
231
+ // here, not in the fetch above, and the caller treats it the same way.
232
+ if (onProgress && isEventStream(response)) {
233
+ return this._drain(response, onProgress, signal);
234
+ }
235
+ return response.json();
236
+ }
237
+
238
+ /**
239
+ * Read the streamed form, reporting progress, and return the final answer.
240
+ *
241
+ * The `done` event is authoritative: the events before it are for the user
242
+ * to look at, not for the client to reassemble. Anything else and a client
243
+ * that missed a frame would silently answer with less than the agent said.
244
+ */
245
+ private async _drain(
246
+ response: Response,
247
+ onProgress: (progress: Progress) => void,
248
+ signal?: AbortSignal
249
+ ): Promise<unknown> {
250
+ if (!response.body) {
251
+ // A proxy buffered the whole response, or this runtime has no
252
+ // ReadableStream. Either way there is nothing to stream; the body is
253
+ // still the answer, so fall back rather than fail.
254
+ return response.json();
255
+ }
256
+
257
+ let answer: unknown;
258
+ try {
259
+ for await (const event of readSse(response.body, signal)) {
260
+ if (event.event === 'status') {
261
+ const data = parseJson<Progress>(event.data);
262
+ if (data?.phase) {
263
+ onProgress(data);
264
+ }
265
+ } else if (event.event === 'done') {
266
+ answer = parseJson<unknown>(event.data);
267
+ } else if (event.event === 'error') {
268
+ const data = parseJson<{ detail?: string; status_code?: number }>(
269
+ event.data
270
+ );
271
+ // The status was 200 before this could happen, so the failure travels
272
+ // in the frame and there was nothing for the check above to throw on.
273
+ throw new ChatRouterError(
274
+ `Backend error: ${data?.detail ?? 'the request failed'}`,
275
+ data?.status_code
276
+ );
277
+ }
278
+ }
279
+ } catch (err) {
280
+ // Stop fails the read with an AbortError, which the caller reports as
281
+ // Stop, and an `error` event is already the chat's error. Anything else
282
+ // is the connection going away mid-answer -- a redeploy, the network --
283
+ // which fetch reports as a bare TypeError ("terminated"). Left as it
284
+ // was, it reached the command as unexpected and the chat showed nothing.
285
+ if (signal?.aborted || err instanceof ChatRouterError) {
286
+ throw err;
287
+ }
288
+ throw new ChatRouterError(
289
+ 'The connection to Oceanum AI was lost before the response was complete.'
290
+ );
291
+ }
292
+
293
+ if (answer === undefined) {
294
+ // The stream ended without its terminal event -- a dropped connection,
295
+ // or a proxy closing a response that looked idle. Treated as a failure
296
+ // rather than a partial answer, which is indistinguishable from a
297
+ // complete one once it reaches the chat.
298
+ if (signal?.aborted) {
299
+ throw new DOMException('Aborted', 'AbortError');
300
+ }
301
+ throw new ChatRouterError('The response ended before it was complete.');
302
+ }
303
+ return answer;
304
+ }
305
+ }
306
+
307
+ function isEventStream(response: Response): boolean {
308
+ return (
309
+ response.headers.get('content-type')?.includes('text/event-stream') ?? false
310
+ );
311
+ }
312
+
313
+ /** Parse one event's data, returning null rather than throwing: a malformed
314
+ * frame should cost a progress update, not the answer. */
315
+ function parseJson<T>(data: string): T | null {
316
+ try {
317
+ return JSON.parse(data) as T;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ interface ChatPayload {
324
+ prompt: string;
325
+ context?: string;
326
+ codeContext?: string;
327
+ chatHistory?: ChatMessage[];
328
+ notebookCells?: string[];
329
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Build-time constants for Oceanum AI integration.
3
+ * These values are compiled into the extension.
4
+ */
5
+
6
+ /** URL of the Oceanum AI backend service */
7
+ export const OCEANUM_AI_BACKEND_URL = 'https://ai.oceanum.io';
8
+
9
+ /**
10
+ * Safety net on observe requests per prompt. The server's EXECUTE_MAX_ROUNDS
11
+ * is what actually caps the chain (at its cap it answers without code, which
12
+ * ends the loop); this only stops a client talking to a server that keeps
13
+ * sending code, and is checked after each observe so the server's explanation
14
+ * turn is always requested.
15
+ */
16
+ export const MAX_OBSERVE_ROUNDS = 8;
@@ -0,0 +1,148 @@
1
+ import type { NotebookPanel } from '@jupyterlab/notebook';
2
+
3
+ /**
4
+ * What ConversationPin needs from JupyterLab -- passed in, so the pin can be
5
+ * tested without it. Both `create` and `reopen` must resolve only once the
6
+ * notebook's contents have loaded: loading replaces every cell, so anything
7
+ * placed before then is wiped.
8
+ */
9
+ export interface INotebookHost {
10
+ /** The notebook in the active main-area tab, or null if that is not one. */
11
+ activeTab(): NotebookPanel | null;
12
+ /** A new notebook, made the active tab. Null if none can be created. */
13
+ create(): Promise<NotebookPanel | null>;
14
+ /** The notebook at `path`, opened again as a tab. Null if it cannot be. */
15
+ reopen(path: string): Promise<NotebookPanel | null>;
16
+ /** Bring `panel`'s tab to the front. */
17
+ activate(panel: NotebookPanel): void;
18
+ }
19
+
20
+ /**
21
+ * The notebook the current conversation lives in: what the agent is shown,
22
+ * and where its answers go.
23
+ *
24
+ * Pinned when the conversation starts -- New chat, or the first message after
25
+ * the panel opens -- to the notebook in the ACTIVE TAB or, when that tab is
26
+ * not a notebook, to a new notebook made the active tab. Kept for the whole
27
+ * conversation: switching tabs does not move it, a rename is followed, and a
28
+ * pinned notebook that has been closed is opened again when an answer has
29
+ * cells to place in it. The active tab rather than the tracker's current
30
+ * notebook, which is the LAST-FOCUSED notebook even with a terminal or a text
31
+ * file in front.
32
+ */
33
+ export class ConversationPin {
34
+ private _panel: NotebookPanel | null = null;
35
+ // Kept separately so a closed notebook can be found again.
36
+ private _path: string | null = null;
37
+ private _started = false;
38
+ // Starting, re-opening and creating happen one at a time: a second New chat
39
+ // must find the notebook the first one created as the active tab rather
40
+ // than create another.
41
+ private _queue: Promise<unknown> = Promise.resolve();
42
+
43
+ constructor(private readonly _host: INotebookHost) {}
44
+
45
+ /** Start a new conversation. Resolves to its notebook's name. */
46
+ start(): Promise<string | null> {
47
+ return this._serial(() => this._startNow());
48
+ }
49
+
50
+ /**
51
+ * Start the conversation if nothing has, and report its notebook's name
52
+ * either way.
53
+ */
54
+ ensure(): Promise<string | null> {
55
+ return this._serial(async () =>
56
+ this._started ? this.name() : this._startNow()
57
+ );
58
+ }
59
+
60
+ /**
61
+ * The conversation's notebook if it is open, starting the conversation if
62
+ * nothing has -- but NOT opening a closed one again: that is for placing an
63
+ * answer, not for asking a question. See path() for reading a closed one.
64
+ */
65
+ current(): Promise<NotebookPanel | null> {
66
+ return this._serial(async () => {
67
+ if (!this._started) {
68
+ await this._startNow();
69
+ }
70
+ return this._live();
71
+ });
72
+ }
73
+
74
+ /**
75
+ * The conversation's notebook, opened again if it has been closed. When it
76
+ * cannot be -- deleted, or gone some other way -- a new notebook takes its
77
+ * place, so the conversation always has one. Null only if none can be made.
78
+ */
79
+ notebook(): Promise<NotebookPanel | null> {
80
+ return this._serial(async () => {
81
+ if (!this._started) {
82
+ await this._startNow();
83
+ }
84
+ const live = this._live();
85
+ if (live) {
86
+ return live;
87
+ }
88
+ const reopened = this._path ? await this._host.reopen(this._path) : null;
89
+ this._pin(reopened ?? (await this._host.create()));
90
+ return this._panel;
91
+ });
92
+ }
93
+
94
+ /** The conversation's notebook, brought to the front: see notebook(). */
95
+ async show(): Promise<NotebookPanel | null> {
96
+ const panel = await this.notebook();
97
+ if (panel) {
98
+ this._host.activate(panel);
99
+ }
100
+ return panel;
101
+ }
102
+
103
+ /** Where the notebook's file is -- its last known place while it is closed. */
104
+ path(): string | null {
105
+ return this._path;
106
+ }
107
+
108
+ /** The notebook's name -- its last known one while it is closed. */
109
+ name(): string | null {
110
+ const live = this._live();
111
+ if (live) {
112
+ return live.title.label;
113
+ }
114
+ return this._path ? (this._path.split('/').pop() ?? this._path) : null;
115
+ }
116
+
117
+ private _live(): NotebookPanel | null {
118
+ return this._panel && !this._panel.isDisposed ? this._panel : null;
119
+ }
120
+
121
+ private async _startNow(): Promise<string | null> {
122
+ this._pin(this._host.activeTab() ?? (await this._host.create()));
123
+ this._started = true;
124
+ return this.name();
125
+ }
126
+
127
+ private _pin(panel: NotebookPanel | null): void {
128
+ this._panel?.context.pathChanged.disconnect(this._onPathChanged, this);
129
+ this._panel = panel;
130
+ this._path = panel?.context.path ?? null;
131
+ // Follows a rename or move made while it is open, so that a notebook
132
+ // renamed and then closed is found again under its new name.
133
+ panel?.context.pathChanged.connect(this._onPathChanged, this);
134
+ }
135
+
136
+ private _onPathChanged(_: unknown, path: string): void {
137
+ this._path = path;
138
+ }
139
+
140
+ private _serial<T>(task: () => Promise<T>): Promise<T> {
141
+ const run = this._queue.then(task);
142
+ // A failed step is reported to its caller; it must not block the next.
143
+ this._queue = run.catch(() => {
144
+ /* swallowed here only */
145
+ });
146
+ return run;
147
+ }
148
+ }
@@ -0,0 +1,5 @@
1
+ declare global {
2
+ var datameshToken: string;
3
+ var injectToken: boolean;
4
+ }
5
+ export {};
package/src/handler.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { URLExt } from '@jupyterlab/coreutils';
2
+
3
+ import { ServerConnection } from '@jupyterlab/services';
4
+
5
+ /**
6
+ * Call the API extension
7
+ *
8
+ * @param endPoint API REST end point for the extension
9
+ * @param init Initial values for the request
10
+ * @returns The response body interpreted as JSON
11
+ */
12
+ export async function requestAPI<T>(
13
+ endPoint = '',
14
+ init: RequestInit = {}
15
+ ): Promise<T> {
16
+ // Make request to Jupyter API
17
+ const settings = ServerConnection.makeSettings();
18
+ const requestUrl = URLExt.join(
19
+ settings.baseUrl,
20
+ 'oceanum', // API Namespace
21
+ endPoint
22
+ );
23
+
24
+ let response: Response;
25
+ try {
26
+ response = await ServerConnection.makeRequest(requestUrl, init, settings);
27
+ } catch (error) {
28
+ throw new ServerConnection.NetworkError(error as any);
29
+ }
30
+
31
+ let data: any = await response.text();
32
+
33
+ if (data.length > 0) {
34
+ try {
35
+ data = JSON.parse(data);
36
+ } catch (error) {
37
+ console.log('Not a JSON response body.', response);
38
+ }
39
+ }
40
+
41
+ if (!response.ok) {
42
+ throw new ServerConnection.ResponseError(response, data.message || data);
43
+ }
44
+
45
+ return data;
46
+ }