@mongoosejs/studio 0.5.3 → 0.5.5

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 (29) hide show
  1. package/backend/actions/Model/analyzeSchema.js +202 -0
  2. package/backend/actions/Model/index.js +2 -0
  3. package/backend/actions/Model/validateDocument.js +35 -0
  4. package/backend/authorize.js +2 -0
  5. package/backend/helpers/validateDocumentWithTimeout.js +39 -0
  6. package/frontend/index.js +1 -2
  7. package/frontend/public/app.js +689 -114
  8. package/frontend/public/tw.css +221 -28
  9. package/frontend/src/api.js +12 -0
  10. package/frontend/src/array-utils.js +60 -2
  11. package/frontend/src/chat/chat.js +2 -2
  12. package/frontend/src/detail-array/detail-array.html +98 -14
  13. package/frontend/src/detail-array/detail-array.js +73 -3
  14. package/frontend/src/document/document.html +51 -0
  15. package/frontend/src/document/document.js +13 -0
  16. package/frontend/src/document-details/document-details.html +10 -3
  17. package/frontend/src/document-details/document-details.js +28 -0
  18. package/frontend/src/document-details/document-property/document-property.html +36 -44
  19. package/frontend/src/document-details/document-property/document-property.js +78 -14
  20. package/frontend/src/edit-array/edit-array.html +143 -8
  21. package/frontend/src/edit-array/edit-array.js +104 -2
  22. package/frontend/src/index.js +45 -9
  23. package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.html +169 -0
  24. package/frontend/src/models/analyze-schema-modal/analyze-schema-modal.js +149 -0
  25. package/frontend/src/models/models.html +25 -13
  26. package/frontend/src/models/models.js +5 -13
  27. package/frontend/src/navbar/navbar.html +4 -4
  28. package/frontend/src/navbar/navbar.js +2 -3
  29. package/package.json +2 -1
@@ -11,6 +11,7 @@ const appendCSS = require('../../appendCSS');
11
11
  appendCSS(require('./document-property.css'));
12
12
 
13
13
  const UNSET = Symbol('unset');
14
+ const { isArrayOfObjects } = require('../../array-utils');
14
15
 
15
16
  module.exports = app => app.component('document-property', {
16
17
  template,
@@ -22,6 +23,8 @@ module.exports = app => app.component('document-property', {
22
23
  renderedValue: UNSET,
23
24
  isCollapsed: false, // Start uncollapsed by default
24
25
  isValueExpanded: false, // Track if the value is expanded
26
+ /** 'list' | 'table' for array-of-objects display and editing */
27
+ arrayDetailViewMode: 'list',
25
28
  detailViewMode: 'text',
26
29
  copyButtonLabel: 'Copy',
27
30
  copyResetTimeoutId: null,
@@ -34,7 +37,7 @@ module.exports = app => app.component('document-property', {
34
37
  this.copyResetTimeoutId = null;
35
38
  }
36
39
  },
37
- props: ['path', 'document', 'schemaPaths', 'editting', 'changes', 'invalid', 'highlight'],
40
+ props: ['path', 'document', 'schemaPaths', 'editting', 'changes', 'invalid', 'highlight', 'searchQuery'],
38
41
  computed: {
39
42
  isDatePath() {
40
43
  return this.path?.instance === 'Date';
@@ -97,18 +100,6 @@ module.exports = app => app.component('document-property', {
97
100
  }
98
101
  return this.valueAsString;
99
102
  },
100
- truncatedArrayItems() {
101
- if (this.isArray && this.needsTruncation && !this.isValueExpanded) {
102
- return this.arrayValue.slice(0, 2);
103
- }
104
- return [];
105
- },
106
- remainingArrayCount() {
107
- if (this.isArray && this.needsTruncation && !this.isValueExpanded) {
108
- return this.arrayValue.length - 2;
109
- }
110
- return 0;
111
- },
112
103
  isGeoJsonGeometry() {
113
104
  const value = this.getValueForPath(this.path.path);
114
105
  return value != null
@@ -128,6 +119,34 @@ module.exports = app => app.component('document-property', {
128
119
  isMultiPolygon() {
129
120
  const value = this.getValueForPath(this.path.path);
130
121
  return this.isGeoJsonGeometry && value.type === 'MultiPolygon';
122
+ },
123
+ arrayPathCurrentValue() {
124
+ if (!this.path || this.path.instance !== 'Array') {
125
+ return null;
126
+ }
127
+ if (this.editting) {
128
+ return this.getEditValueForPath(this.path);
129
+ }
130
+ return this.getValueForPath(this.path.path);
131
+ },
132
+ canUseArrayTableView() {
133
+ const v = this.arrayPathCurrentValue;
134
+ return Array.isArray(v) && isArrayOfObjects(v);
135
+ },
136
+ propertyDetailViewMode() {
137
+ if (this.isDatePath) {
138
+ return this.dateViewMode;
139
+ }
140
+ if (this.isObjectIdPath) {
141
+ return this.objectIdViewMode;
142
+ }
143
+ if (this.path?.instance === 'Array') {
144
+ return this.arrayDetailViewMode;
145
+ }
146
+ return this.detailViewMode;
147
+ },
148
+ pathNameParts() {
149
+ return this.getSearchMatchParts(this.path?.path || '', this.searchQuery);
131
150
  }
132
151
  },
133
152
  watch: {
@@ -144,12 +163,56 @@ module.exports = app => app.component('document-property', {
144
163
  if (newValue && this.isGeoJsonGeometry) {
145
164
  this.detailViewMode = 'map';
146
165
  }
166
+ },
167
+ canUseArrayTableView(can) {
168
+ if (!can && this.arrayDetailViewMode === 'table') {
169
+ this.arrayDetailViewMode = 'list';
170
+ }
171
+ },
172
+ 'path.path'() {
173
+ this.arrayDetailViewMode = 'list';
147
174
  }
148
175
  },
149
176
  methods: {
177
+ getSearchMatchParts(text, query) {
178
+ const normalizedQuery = typeof query === 'string' ? query.trim() : '';
179
+ if (!normalizedQuery) {
180
+ return [{ text, matched: false }];
181
+ }
182
+
183
+ const lowerText = String(text).toLowerCase();
184
+ const lowerQuery = normalizedQuery.toLowerCase();
185
+ const parts = [];
186
+ let position = 0;
187
+ let matchIndex = lowerText.indexOf(lowerQuery, position);
188
+
189
+ while (matchIndex !== -1) {
190
+ if (matchIndex > position) {
191
+ parts.push({ text: text.slice(position, matchIndex), matched: false });
192
+ }
193
+ const matchEnd = matchIndex + normalizedQuery.length;
194
+ parts.push({ text: text.slice(matchIndex, matchEnd), matched: true });
195
+ position = matchEnd;
196
+ matchIndex = lowerText.indexOf(lowerQuery, position);
197
+ }
198
+
199
+ if (position < text.length) {
200
+ parts.push({ text: text.slice(position), matched: false });
201
+ }
202
+
203
+ return parts.length > 0 ? parts : [{ text, matched: false }];
204
+ },
205
+ setArrayDetailViewMode(mode) {
206
+ this.arrayDetailViewMode = mode;
207
+ if (mode === 'table') {
208
+ if (this.isCollapsed) {
209
+ this.isCollapsed = false;
210
+ }
211
+ }
212
+ },
150
213
  setDetailViewMode(mode) {
151
214
  this.detailViewMode = mode;
152
-
215
+
153
216
  // When switching to map view, expand the container and value so the map is visible
154
217
  if (mode === 'map' && this.isGeoJsonGeometry) {
155
218
  if (this.isCollapsed) {
@@ -220,6 +283,7 @@ module.exports = app => app.component('document-property', {
220
283
  if (path.instance === 'Array') {
221
284
  props.path = path;
222
285
  props.schemaPaths = this.schemaPaths;
286
+ props.viewMode = this.arrayDetailViewMode === 'table' ? 'table' : 'json';
223
287
  }
224
288
  return props;
225
289
  },
@@ -1,9 +1,144 @@
1
1
  <div class="w-full">
2
- <ace-editor
3
- :value="arrayStr"
4
- @input="onEditorInput"
5
- mode="json"
6
- :line-numbers="true"
7
- class="min-h-[300px]"
8
- />
9
- </div>
2
+ <div v-if="!useTableEditor">
3
+ <ace-editor
4
+ :value="arrayStr"
5
+ @input="onEditorInput"
6
+ mode="json"
7
+ :line-numbers="true"
8
+ class="min-h-[300px]"
9
+ />
10
+ </div>
11
+ <div v-else class="overflow-hidden rounded-lg border border-edge bg-surface">
12
+ <div class="border-b border-edge bg-slate-50/90 px-3 py-2.5">
13
+ <div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:gap-4">
14
+ <div class="flex flex-wrap items-stretch gap-2">
15
+ <button
16
+ type="button"
17
+ class="rounded-md bg-primary px-3 py-1.5 text-xs font-semibold text-primary-text shadow-sm hover:bg-primary-hover"
18
+ @click="addRow">
19
+ + Row
20
+ </button>
21
+ <div class="flex min-w-0 max-w-full sm:max-w-md">
22
+ <input
23
+ id="edit-array-new-column"
24
+ v-model="newColumnKeyInput"
25
+ type="text"
26
+ class="min-w-0 flex-1 rounded-l-md border border-r-0 border-edge bg-surface px-2.5 py-1.5 text-xs font-mono text-content placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
27
+ placeholder="New field name"
28
+ autocomplete="off"
29
+ @keyup.enter="addColumn"
30
+ />
31
+ <button
32
+ type="button"
33
+ class="shrink-0 rounded-r-md border border-edge bg-slate-100 px-3 py-1.5 text-xs font-semibold text-content hover:bg-slate-200"
34
+ @click="addColumn">
35
+ Add field
36
+ </button>
37
+ </div>
38
+ </div>
39
+ <p class="text-[11px] leading-snug text-content-tertiary lg:ml-auto lg:max-w-[18rem] lg:text-right">
40
+ Each cell is JSON. Clear a cell to remove that field from the row.
41
+ </p>
42
+ </div>
43
+ <p v-if="newColumnKeyError" class="mt-2 text-xs font-medium text-red-600">{{ newColumnKeyError }}</p>
44
+ </div>
45
+
46
+ <div v-if="arrayValue.length > 0" class="border-b border-edge bg-slate-50/50 px-3 py-2">
47
+ <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
48
+ <div class="relative min-w-0 flex-1 sm:max-w-md">
49
+ <input
50
+ v-model="arraySearchQuery"
51
+ type="search"
52
+ autocomplete="off"
53
+ aria-label="Search rows in this array"
54
+ placeholder="Search rows…"
55
+ class="w-full rounded-md border border-edge bg-surface py-1.5 pl-2 pr-16 text-xs text-content placeholder:text-content-tertiary focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
56
+ />
57
+ <button
58
+ v-if="arraySearchNormalized"
59
+ type="button"
60
+ class="absolute right-1 top-1/2 -translate-y-1/2 rounded px-1.5 py-0.5 text-[11px] text-content-tertiary hover:bg-slate-200 hover:text-content"
61
+ title="Clear search"
62
+ @click="arraySearchQuery = ''">
63
+ Clear
64
+ </button>
65
+ </div>
66
+ <span v-if="arraySearchNormalized" class="shrink-0 text-xs text-content-tertiary">
67
+ {{ filteredTableRows.length }} of {{ arrayValue.length }} shown
68
+ </span>
69
+ </div>
70
+ </div>
71
+
72
+ <div v-if="arrayValue.length === 0" class="px-4 py-10 text-center text-sm text-content-secondary">
73
+ No rows yet. Use <span class="font-medium text-content">+ Row</span> or enter a field name and choose <span class="font-medium text-content">Add field</span>.
74
+ </div>
75
+
76
+ <div
77
+ v-else-if="filteredTableRows.length === 0"
78
+ class="px-4 py-8 text-center text-sm text-content-secondary">
79
+ No rows match "{{ arraySearchQuery.trim() }}".
80
+ </div>
81
+
82
+ <div v-else class="overflow-x-auto">
83
+ <table class="min-w-full text-left text-xs font-mono">
84
+ <thead class="border-b border-edge bg-slate-100 text-content-secondary">
85
+ <tr>
86
+ <th scope="col" class="w-24 border-r border-edge px-2 py-2 text-left text-[11px] font-semibold uppercase tracking-wide">
87
+ Actions
88
+ </th>
89
+ <th
90
+ v-for="key in tableColumnKeys"
91
+ :key="key"
92
+ scope="col"
93
+ class="border-r border-edge px-3 py-2 text-left text-xs font-semibold last:border-r-0 min-w-[8rem] whitespace-nowrap">
94
+ {{ key }}
95
+ </th>
96
+ </tr>
97
+ </thead>
98
+ <tbody class="divide-y divide-edge bg-surface">
99
+ <tr v-for="entry in filteredTableRows" :key="entry.index" class="hover:bg-slate-50/60">
100
+ <td class="align-middle border-r border-edge p-2">
101
+ <div class="flex flex-col gap-1">
102
+ <span class="text-center text-[10px] font-medium text-content-tertiary">#{{ entry.index }}</span>
103
+ <button
104
+ type="button"
105
+ class="rounded border border-edge bg-surface px-2 py-1 text-[11px] font-medium hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
106
+ :disabled="entry.index === 0"
107
+ title="Move row up"
108
+ @click="moveRow(entry.index, -1)">
109
+ Up
110
+ </button>
111
+ <button
112
+ type="button"
113
+ class="rounded border border-edge bg-surface px-2 py-1 text-[11px] font-medium hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40"
114
+ :disabled="entry.index === arrayValue.length - 1"
115
+ title="Move row down"
116
+ @click="moveRow(entry.index, 1)">
117
+ Down
118
+ </button>
119
+ <button
120
+ type="button"
121
+ class="rounded border border-red-200 bg-surface px-2 py-1 text-[11px] font-medium text-red-700 hover:bg-red-50"
122
+ title="Remove this row"
123
+ @click="removeRow(entry.index)">
124
+ Remove
125
+ </button>
126
+ </div>
127
+ </td>
128
+ <td
129
+ v-for="key in tableColumnKeys"
130
+ :key="key"
131
+ class="max-w-md min-w-[8rem] border-r border-edge p-1.5 align-top last:border-r-0">
132
+ <textarea
133
+ rows="2"
134
+ class="w-full resize-y rounded border border-edge bg-surface px-2 py-1.5 text-xs text-content focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
135
+ :value="serializeCell(entry.item[key])"
136
+ @blur="onCellChange(entry.index, key, $event.target.value)"
137
+ ></textarea>
138
+ </td>
139
+ </tr>
140
+ </tbody>
141
+ </table>
142
+ </div>
143
+ </div>
144
+ </div>
@@ -2,6 +2,7 @@
2
2
 
3
3
  const template = require('./edit-array.html');
4
4
  const { BSON } = require('mongodb/lib/bson');
5
+ const { isArrayOfObjects, unionKeysForArrayOfObjects, arrayItemMatchesSearch } = require('../array-utils');
5
6
 
6
7
  const ObjectId = new Proxy(BSON.ObjectId, {
7
8
  apply(target, thisArg, argumentsList) {
@@ -12,15 +13,44 @@ const ObjectId = new Proxy(BSON.ObjectId, {
12
13
 
13
14
  module.exports = app => app.component('edit-array', {
14
15
  template: template,
15
- props: ['value'],
16
+ props: {
17
+ value: {},
18
+ path: {},
19
+ schemaPaths: {},
20
+ /** 'json' (ACE) or 'table' for arrays of plain objects */
21
+ viewMode: {
22
+ type: String,
23
+ default: 'json'
24
+ }
25
+ },
16
26
  data() {
17
27
  return {
18
- arrayValue: []
28
+ arrayValue: [],
29
+ newColumnKeyInput: '',
30
+ newColumnKeyError: '',
31
+ arraySearchQuery: ''
19
32
  };
20
33
  },
21
34
  computed: {
22
35
  arrayStr() {
23
36
  return JSON.stringify(this.arrayValue, null, 2);
37
+ },
38
+ useTableEditor() {
39
+ return this.viewMode === 'table' && isArrayOfObjects(this.arrayValue);
40
+ },
41
+ tableColumnKeys() {
42
+ return unionKeysForArrayOfObjects(this.arrayValue);
43
+ },
44
+ arraySearchNormalized() {
45
+ return (this.arraySearchQuery || '').trim().toLowerCase();
46
+ },
47
+ filteredTableRows() {
48
+ if (!Array.isArray(this.arrayValue)) {
49
+ return [];
50
+ }
51
+ return this.arrayValue
52
+ .map((item, index) => ({ item, index }))
53
+ .filter(({ item }) => arrayItemMatchesSearch(item, this.arraySearchNormalized));
24
54
  }
25
55
  },
26
56
  methods: {
@@ -51,6 +81,78 @@ module.exports = app => app.component('edit-array', {
51
81
  } catch (err) {
52
82
  this.$emit('error', err);
53
83
  }
84
+ },
85
+ serializeCell(value) {
86
+ if (value === undefined) {
87
+ return '';
88
+ }
89
+ return JSON.stringify(value);
90
+ },
91
+ onCellChange(rowIndex, key, raw) {
92
+ const row = this.arrayValue[rowIndex];
93
+ if (row == null || typeof row !== 'object') {
94
+ return;
95
+ }
96
+ const trimmed = raw.trim();
97
+ if (trimmed === '') {
98
+ delete row[key];
99
+ this.emitUpdate();
100
+ return;
101
+ }
102
+ try {
103
+ row[key] = JSON.parse(trimmed);
104
+ this.emitUpdate();
105
+ } catch (err) {
106
+ this.$emit('error', err);
107
+ }
108
+ },
109
+ addRow() {
110
+ const row = {};
111
+ for (const k of this.tableColumnKeys) {
112
+ row[k] = null;
113
+ }
114
+ this.arrayValue.push(row);
115
+ this.emitUpdate();
116
+ },
117
+ addColumn() {
118
+ const key = (this.newColumnKeyInput || '').trim();
119
+ this.newColumnKeyError = '';
120
+ if (!key) {
121
+ this.newColumnKeyError = 'Enter a column name.';
122
+ return;
123
+ }
124
+ if (this.tableColumnKeys.includes(key)) {
125
+ this.newColumnKeyError = 'That column already exists.';
126
+ return;
127
+ }
128
+ if (!Array.isArray(this.arrayValue)) {
129
+ this.arrayValue = [];
130
+ }
131
+ if (this.arrayValue.length === 0) {
132
+ this.arrayValue.push({ [key]: null });
133
+ } else {
134
+ for (const row of this.arrayValue) {
135
+ if (row != null && typeof row === 'object' && !Array.isArray(row) && row.constructor === Object) {
136
+ row[key] = null;
137
+ }
138
+ }
139
+ }
140
+ this.newColumnKeyInput = '';
141
+ this.emitUpdate();
142
+ },
143
+ removeRow(index) {
144
+ this.arrayValue.splice(index, 1);
145
+ this.emitUpdate();
146
+ },
147
+ moveRow(index, delta) {
148
+ const j = index + delta;
149
+ if (j < 0 || j >= this.arrayValue.length) {
150
+ return;
151
+ }
152
+ const copy = this.arrayValue[index];
153
+ this.arrayValue.splice(index, 1);
154
+ this.arrayValue.splice(j, 0, copy);
155
+ this.emitUpdate();
54
156
  }
55
157
  },
56
158
  mounted() {
@@ -25,6 +25,7 @@ const {
25
25
  appendCSS(require('vue-toastification/dist/index.css'));
26
26
 
27
27
  const TRACKED_RECENT_PAGE_ROUTE_NAMES = new Set(['model', 'document', 'dashboard', 'chat']);
28
+ const CHAT_ROUTE_NAMES = new Set(['chat index', 'chat']);
28
29
 
29
30
  function formatHistoryLabel(route) {
30
31
  if (!route || typeof route.path !== 'string') {
@@ -240,8 +241,12 @@ app.component('app-component', {
240
241
  window.localStorage.setItem('_mongooseStudioAccessToken', accessToken._id);
241
242
 
242
243
  try {
243
- const { nodeEnv } = await api.status();
244
+ const [{ nodeEnv }, capabilities] = await Promise.all([
245
+ api.status(),
246
+ loadStudioCapabilities()
247
+ ]);
244
248
  this.nodeEnv = nodeEnv;
249
+ this.capabilities = capabilities;
245
250
  } catch (err) {
246
251
  this.authError = 'Error connecting to Mongoose Studio API: ' + err.response?.data?.message ?? err.message;
247
252
  this.status = 'loaded';
@@ -261,12 +266,14 @@ app.component('app-component', {
261
266
  const { user, roles } = await mothership.me();
262
267
 
263
268
  try {
264
- const [{ nodeEnv }, { modelSchemaPaths }] = await Promise.all([
269
+ const [{ nodeEnv }, { modelSchemaPaths }, capabilities] = await Promise.all([
265
270
  api.status(),
266
- api.Model.listModels()
271
+ api.Model.listModels(),
272
+ loadStudioCapabilities()
267
273
  ]);
268
274
  this.nodeEnv = nodeEnv;
269
275
  this.modelSchemaPaths = modelSchemaPaths;
276
+ this.capabilities = capabilities;
270
277
  } catch (err) {
271
278
  this.authError = 'Error connecting to Mongoose Studio API: ' + (err.response?.data?.message ?? err.message);
272
279
  this.status = 'loaded';
@@ -279,12 +286,14 @@ app.component('app-component', {
279
286
  }
280
287
  } else {
281
288
  try {
282
- const [{ nodeEnv }, { modelSchemaPaths }] = await Promise.all([
289
+ const [{ nodeEnv }, { modelSchemaPaths }, capabilities] = await Promise.all([
283
290
  api.status(),
284
- api.Model.listModels()
291
+ api.Model.listModels(),
292
+ loadStudioCapabilities()
285
293
  ]);
286
294
  this.nodeEnv = nodeEnv;
287
295
  this.modelSchemaPaths = modelSchemaPaths;
296
+ this.capabilities = capabilities;
288
297
  } catch (err) {
289
298
  this.authError = 'Error connecting to Mongoose Studio API: ' + (err.response?.data?.message ?? err.message);
290
299
  }
@@ -299,6 +308,7 @@ app.component('app-component', {
299
308
  const nodeEnv = Vue.ref(null);
300
309
  const authError = Vue.ref(null);
301
310
  const modelSchemaPaths = Vue.ref(null);
311
+ const capabilities = Vue.ref(null);
302
312
 
303
313
  const state = Vue.reactive({
304
314
  user,
@@ -306,7 +316,8 @@ app.component('app-component', {
306
316
  status,
307
317
  nodeEnv,
308
318
  authError,
309
- modelSchemaPaths
319
+ modelSchemaPaths,
320
+ capabilities
310
321
  });
311
322
  Vue.provide('state', state);
312
323
 
@@ -358,13 +369,13 @@ router.beforeEach((to, from, next) => {
358
369
  next();
359
370
  });
360
371
 
361
- router.beforeEach((to, from, next) => {
362
- if (['chat index', 'chat'].includes(to.name) && !window.MONGOOSE_STUDIO_CONFIG.hasLLMAPIKey) {
372
+ router.beforeEach(async(to, from, next) => {
373
+ if (CHAT_ROUTE_NAMES.has(to.name) && !(await supportsAI())) {
363
374
  next({ name: 'root' });
364
375
  return;
365
376
  }
366
377
 
367
- if (to.name === 'root' && window.state.roles && window.state.roles[0] === 'dashboards') {
378
+ if (to.name === 'root' && window.state?.roles && window.state.roles[0] === 'dashboards') {
368
379
  return next({ name: 'dashboards' });
369
380
  } else {
370
381
  next();
@@ -379,3 +390,28 @@ app.config.globalProperties = { format, arrayUtils, $toast: toast };
379
390
  app.use(router);
380
391
 
381
392
  app.mount('#content');
393
+
394
+ async function getStudioCapabilities() {
395
+ if (window.__mongooseStudioCapabilitiesPromise == null) {
396
+ window.__mongooseStudioCapabilitiesPromise = api.getCapabilities().catch(err => {
397
+ window.__mongooseStudioCapabilitiesPromise = null;
398
+ throw err;
399
+ });
400
+ }
401
+ return window.__mongooseStudioCapabilitiesPromise;
402
+ }
403
+
404
+ async function loadStudioCapabilities() {
405
+ try {
406
+ return await getStudioCapabilities();
407
+ } catch (err) {
408
+ return null;
409
+ }
410
+ }
411
+
412
+ async function supportsAI() {
413
+ if (window.state?.capabilities == null) {
414
+ return true;
415
+ }
416
+ return window.state.capabilities.supportsAI !== false;
417
+ }