@morscherlab/mint-sdk 1.0.41 → 1.0.43

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,18 +1,20 @@
1
- import { computed, shallowRef, watch, type ComputedRef, type Ref } from 'vue'
1
+ import { computed, onMounted, onUnmounted, ref, shallowRef, watch, type ComputedRef, type Ref } from 'vue'
2
2
  import { useApi } from './useApi'
3
3
  import { usePlatformContext } from './usePlatformContext'
4
4
  import { useRequestSyncState } from './useRequestSyncState'
5
+ import { useAuthStore } from '../stores/auth'
5
6
  import {
6
7
  currentExperimentFromContext,
7
8
  getInjectedPlatformContext,
8
9
  resolveCurrentExperimentId,
9
10
  } from './platformContextHelpers'
10
11
  import {
12
+ buildPluginEndpointUrl,
11
13
  hasKeys,
12
14
  pluginEndpointRequestParts,
13
15
  requestBodyFromPayload,
14
16
  } from './pluginEndpointBuilder'
15
- import type { ExperimentSummary, PageSelectorItem } from '../types'
17
+ import type { ExperimentSummary, PageSelectorItem, SettingsModalSchema, TopBarSettingsConfig } from '../types'
16
18
 
17
19
  export {
18
20
  buildPluginEndpointUrl,
@@ -66,6 +68,10 @@ export interface PluginContract {
66
68
  analysisResultReaders?: string[]
67
69
  capabilities?: Record<string, unknown>
68
70
  }
71
+ settings?: {
72
+ modelName?: string | null
73
+ schema?: SettingsModalSchema | null
74
+ }
69
75
  endpoints: PluginEndpointContract[]
70
76
  hash: string
71
77
  }
@@ -114,6 +120,84 @@ export interface DownloadBlobOptions {
114
120
  revokeObjectUrlDelayMs?: number
115
121
  }
116
122
 
123
+ export type PluginSettingsValues<TSettings> = Partial<TSettings> & Record<string, unknown>
124
+
125
+ export interface UsePluginSettingsOptions {
126
+ /** Plugin name for platform persistence. Defaults to the integrated platform plugin name. */
127
+ pluginName?: string
128
+ /** Plugin API base URL for standalone/local settings routes. Generated clients pass pluginApiPrefix. */
129
+ apiBaseUrl?: string
130
+ /** Platform API base URL for /plugins/{name}/config. Defaults to the injected platform API URL. */
131
+ platformApiBaseUrl?: string
132
+ /** SettingsModal schema generated from the backend settings_model. */
133
+ schema?: SettingsModalSchema | null
134
+ /** SettingsModal title when using settingsConfig. */
135
+ title?: string
136
+ /** Whether SettingsModal should include SDK appearance controls. */
137
+ showAppearance?: boolean
138
+ /** Load persisted values on component mount. */
139
+ loadOnMount?: boolean
140
+ }
141
+
142
+ export interface UsePluginSettingsReturn<TSettings = Record<string, unknown>> {
143
+ /** Current typed settings values. */
144
+ settings: ComputedRef<PluginSettingsValues<TSettings>>
145
+ /** Editable settings values for SettingsModal. */
146
+ values: Ref<PluginSettingsValues<TSettings>>
147
+ /** Alias for values, kept for callers that think in platform config terms. */
148
+ config: Ref<PluginSettingsValues<TSettings>>
149
+ /** Ready-to-pass AppTopBar/PluginWorkspaceView settingsConfig. */
150
+ settingsConfig: ComputedRef<TopBarSettingsConfig>
151
+ isLoading: Ref<boolean>
152
+ isSaving: Ref<boolean>
153
+ error: Ref<string | null>
154
+ lastLoadedAt: Ref<Date | null>
155
+ lastSavedAt: Ref<Date | null>
156
+ isDirty: ComputedRef<boolean>
157
+ setValues: (values: Record<string, unknown>) => void
158
+ load: () => Promise<void>
159
+ save: (values?: Record<string, unknown>) => Promise<boolean>
160
+ reset: () => void
161
+ }
162
+
163
+ export interface PluginEventStreamMessage<TData = string> {
164
+ event: string
165
+ data: TData
166
+ id?: string
167
+ retry?: number
168
+ raw: string
169
+ }
170
+
171
+ export interface PluginEventStreamOptions<TData = string> {
172
+ /** Override the generated or platform-injected API base URL. */
173
+ baseUrl?: string
174
+ /** Start the stream immediately. Defaults to true. */
175
+ immediate?: boolean
176
+ /** Reconnect after errors or server closes. Defaults to true. */
177
+ reconnect?: boolean
178
+ /** Delay before reconnecting after a stream failure or close. Defaults to 2000ms. */
179
+ reconnectDelayMs?: number
180
+ /** Parse `data:` as JSON before calling onMessage. */
181
+ parseJson?: boolean
182
+ /** Fetch credentials mode. Defaults to same-origin. */
183
+ credentials?: RequestCredentials
184
+ /** Extra request headers. Authorization is injected unless already supplied. */
185
+ headers?: Record<string, string>
186
+ onOpen?: () => void
187
+ onMessage?: (message: PluginEventStreamMessage<TData>) => void
188
+ onError?: (error: unknown) => void
189
+ }
190
+
191
+ export interface UsePluginEventStreamReturn<TData = string> {
192
+ isConnected: Ref<boolean>
193
+ isConnecting: Ref<boolean>
194
+ error: Ref<string | null>
195
+ lastMessage: Ref<PluginEventStreamMessage<TData> | null>
196
+ lastEventId: Ref<string | null>
197
+ start: () => void
198
+ stop: () => void
199
+ }
200
+
117
201
  export type PluginFormDataValue =
118
202
  | string
119
203
  | number
@@ -240,7 +324,6 @@ export function getPluginPageSelectorItems(contract: PluginContract): PageSelect
240
324
  label: item.label,
241
325
  to: normalizePluginNavPath(item.path),
242
326
  icon: item.icon || pluginIcon || undefined,
243
- hint: item.description || contract.plugin.name,
244
327
  }))
245
328
  }
246
329
 
@@ -337,11 +420,373 @@ export function usePluginClient<TClient>(client: TClient): TClient {
337
420
  return client
338
421
  }
339
422
 
340
- /** Read plugin settings exposed through the platform context. */
341
- export function usePluginSettings<TSettings = Record<string, unknown>>() {
342
- const { plugin } = usePlatformContext()
343
- const settings = computed(() => plugin.value?.settings as TSettings | undefined)
344
- return { settings }
423
+ function normalizePluginSettingsOptions(options: UsePluginSettingsOptions | string = {}): UsePluginSettingsOptions {
424
+ return typeof options === 'string' ? { pluginName: options } : options
425
+ }
426
+
427
+ function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
428
+ return { ...value }
429
+ }
430
+
431
+ function responseSettingsPayload(response: unknown): Record<string, unknown> {
432
+ if (!response || typeof response !== 'object') return {}
433
+ const data = response as {
434
+ config?: unknown
435
+ settings?: unknown
436
+ }
437
+ if (data.config && typeof data.config === 'object') {
438
+ return cloneRecord(data.config as Record<string, unknown>)
439
+ }
440
+ if (data.settings && typeof data.settings === 'object') {
441
+ return cloneRecord(data.settings as Record<string, unknown>)
442
+ }
443
+ return {}
444
+ }
445
+
446
+ /** Load, edit, and persist plugin settings from platform config or the plugin-local settings router. */
447
+ export function usePluginSettings<TSettings = Record<string, unknown>>(
448
+ options: UsePluginSettingsOptions | string = {},
449
+ ): UsePluginSettingsReturn<TSettings> {
450
+ const resolvedOptions = normalizePluginSettingsOptions(options)
451
+ const injectedContext = getInjectedPlatformContext()
452
+ const { plugin, isIntegrated } = usePlatformContext()
453
+ const platformApi = useApi({ baseUrl: resolvedOptions.platformApiBaseUrl ?? injectedContext?.platformApiUrl })
454
+ const pluginApi = useApi({ baseUrl: resolvedOptions.apiBaseUrl ?? injectedContext?.plugin?.api_prefix })
455
+
456
+ const values = shallowRef<PluginSettingsValues<TSettings>>({} as PluginSettingsValues<TSettings>)
457
+ const savedValues = shallowRef<Record<string, unknown>>({})
458
+ const request = useRequestSyncState('Plugin settings request failed.')
459
+ const isLoading = ref(false)
460
+ const isSaving = ref(false)
461
+
462
+ const resolvedPluginName = computed(() =>
463
+ resolvedOptions.pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '',
464
+ )
465
+ const usesPlatformConfig = computed(() =>
466
+ Boolean((isIntegrated.value || injectedContext?.isIntegrated) && resolvedPluginName.value),
467
+ )
468
+ const settings = computed(() => values.value)
469
+ const isDirty = computed(() => JSON.stringify(values.value) !== JSON.stringify(savedValues.value))
470
+ const settingsConfig = computed<TopBarSettingsConfig>(() => ({
471
+ title: resolvedOptions.title,
472
+ schema: resolvedOptions.schema ?? undefined,
473
+ showAppearance: resolvedOptions.showAppearance,
474
+ values: values.value,
475
+ }))
476
+
477
+ function setValues(nextValues: Record<string, unknown>): void {
478
+ values.value = cloneRecord(nextValues) as PluginSettingsValues<TSettings>
479
+ }
480
+
481
+ function applyLoaded(nextValues: Record<string, unknown>): void {
482
+ const cloned = cloneRecord(nextValues)
483
+ values.value = cloned as PluginSettingsValues<TSettings>
484
+ savedValues.value = cloneRecord(cloned)
485
+ }
486
+
487
+ async function load(): Promise<void> {
488
+ isLoading.value = true
489
+ try {
490
+ const response = usesPlatformConfig.value
491
+ ? await request.run(
492
+ () => platformApi.get<{ plugin_name: string; config: Record<string, unknown> }>(
493
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
494
+ ),
495
+ { success: 'load', errorMessage: 'Failed to load plugin settings' },
496
+ )
497
+ : await request.run(
498
+ () => pluginApi.get<{ settings: Record<string, unknown>; mode?: string }>('/settings'),
499
+ { success: 'load', errorMessage: 'Failed to load plugin settings' },
500
+ )
501
+ applyLoaded(responseSettingsPayload(response))
502
+ } catch {
503
+ // Error state is handled by request.run().
504
+ } finally {
505
+ isLoading.value = false
506
+ }
507
+ }
508
+
509
+ async function save(nextValues?: Record<string, unknown>): Promise<boolean> {
510
+ if (nextValues) {
511
+ setValues(nextValues)
512
+ }
513
+
514
+ isSaving.value = true
515
+ try {
516
+ const payload = cloneRecord(values.value)
517
+ const response = usesPlatformConfig.value
518
+ ? await request.run(
519
+ () => platformApi.patch<{ plugin_name: string; config: Record<string, unknown> }>(
520
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
521
+ { config: payload },
522
+ ),
523
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
524
+ )
525
+ : await request.run(
526
+ () => pluginApi.put<{ settings: Record<string, unknown>; mode?: string }>('/settings', payload),
527
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
528
+ )
529
+ applyLoaded(responseSettingsPayload(response))
530
+ return true
531
+ } catch {
532
+ return false
533
+ } finally {
534
+ isSaving.value = false
535
+ }
536
+ }
537
+
538
+ function reset(): void {
539
+ values.value = cloneRecord(savedValues.value) as PluginSettingsValues<TSettings>
540
+ request.clearError()
541
+ }
542
+
543
+ onMounted(() => {
544
+ if (resolvedOptions.loadOnMount !== false) {
545
+ void load()
546
+ }
547
+ })
548
+
549
+ return {
550
+ settings,
551
+ values,
552
+ config: values,
553
+ settingsConfig,
554
+ isLoading,
555
+ isSaving,
556
+ error: request.error,
557
+ lastLoadedAt: request.lastLoadedAt,
558
+ lastSavedAt: request.lastSavedAt,
559
+ isDirty,
560
+ setValues,
561
+ load,
562
+ save,
563
+ reset,
564
+ }
565
+ }
566
+
567
+ function streamHeaders(options: PluginEventStreamOptions<unknown>): Headers {
568
+ const headers = new Headers(options.headers)
569
+ const authStore = useAuthStore()
570
+ if (!authStore.isInitialized) {
571
+ authStore.initialize()
572
+ }
573
+ if (authStore.token && !headers.has('Authorization')) {
574
+ headers.set('Authorization', `Bearer ${authStore.token}`)
575
+ }
576
+ headers.set('Accept', 'text/event-stream')
577
+ return headers
578
+ }
579
+
580
+ function parseSseEvent<TData>(
581
+ block: string,
582
+ parseJson: boolean,
583
+ ): PluginEventStreamMessage<TData> | null {
584
+ let event = 'message'
585
+ let id: string | undefined
586
+ let retry: number | undefined
587
+ const dataLines: string[] = []
588
+
589
+ for (const line of block.split(/\r?\n/)) {
590
+ if (!line || line.startsWith(':')) continue
591
+ const separator = line.indexOf(':')
592
+ const field = separator === -1 ? line : line.slice(0, separator)
593
+ const value = separator === -1
594
+ ? ''
595
+ : line.slice(separator + 1).replace(/^ /, '')
596
+
597
+ if (field === 'event') event = value || 'message'
598
+ if (field === 'data') dataLines.push(value)
599
+ if (field === 'id') id = value
600
+ if (field === 'retry') {
601
+ const parsed = Number(value)
602
+ if (Number.isFinite(parsed)) retry = parsed
603
+ }
604
+ }
605
+
606
+ if (!dataLines.length && !id && retry === undefined) return null
607
+ const data = dataLines.join('\n')
608
+ return {
609
+ event,
610
+ data: (parseJson && data ? JSON.parse(data) : data) as TData,
611
+ id,
612
+ retry,
613
+ raw: block,
614
+ }
615
+ }
616
+
617
+ function looksLikeEventStreamOptions(value: unknown): value is PluginEventStreamOptions {
618
+ if (!value || typeof value !== 'object') return false
619
+ const candidate = value as Record<string, unknown>
620
+ return [
621
+ 'baseUrl',
622
+ 'immediate',
623
+ 'reconnect',
624
+ 'reconnectDelayMs',
625
+ 'parseJson',
626
+ 'credentials',
627
+ 'headers',
628
+ 'onOpen',
629
+ 'onMessage',
630
+ 'onError',
631
+ ].some(key => key in candidate)
632
+ }
633
+
634
+ /** Consume a generated plugin SSE endpoint with auth headers, reconnect, and teardown. */
635
+ export function usePluginEventStream<TData = string>(
636
+ contract: PluginContract,
637
+ endpoint: PluginEndpointDefinition,
638
+ options?: PluginEventStreamOptions<TData>,
639
+ ): UsePluginEventStreamReturn<TData>
640
+ export function usePluginEventStream<TData = string>(
641
+ contract: PluginContract,
642
+ endpoint: PluginEndpointDefinition,
643
+ payload?: unknown,
644
+ options?: PluginEventStreamOptions<TData>,
645
+ ): UsePluginEventStreamReturn<TData>
646
+ export function usePluginEventStream<TData = string>(
647
+ contract: PluginContract,
648
+ endpoint: PluginEndpointDefinition,
649
+ payloadOrOptions?: unknown,
650
+ maybeOptions?: PluginEventStreamOptions<TData>,
651
+ ): UsePluginEventStreamReturn<TData> {
652
+ const payload = maybeOptions === undefined && looksLikeEventStreamOptions(payloadOrOptions)
653
+ ? undefined
654
+ : payloadOrOptions
655
+ const options = (
656
+ maybeOptions === undefined && looksLikeEventStreamOptions(payloadOrOptions)
657
+ ? payloadOrOptions
658
+ : maybeOptions ?? {}
659
+ ) as PluginEventStreamOptions<TData>
660
+ const isConnected = ref(false)
661
+ const isConnecting = ref(false)
662
+ const error = ref<string | null>(null)
663
+ const lastMessage = shallowRef<PluginEventStreamMessage<TData> | null>(null)
664
+ const lastEventId = ref<string | null>(null)
665
+ let controller: AbortController | null = null
666
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
667
+ let stopped = true
668
+
669
+ function clearReconnectTimer(): void {
670
+ if (reconnectTimer !== null) {
671
+ clearTimeout(reconnectTimer)
672
+ reconnectTimer = null
673
+ }
674
+ }
675
+
676
+ function scheduleReconnect(): void {
677
+ if (stopped || options.reconnect === false) return
678
+ clearReconnectTimer()
679
+ reconnectTimer = setTimeout(() => {
680
+ void connect()
681
+ }, options.reconnectDelayMs ?? 2000)
682
+ }
683
+
684
+ async function connect(): Promise<void> {
685
+ if (endpoint.method !== 'get') {
686
+ throw new Error(`[MINT SDK] Plugin event streams must use GET; got ${endpoint.method}`)
687
+ }
688
+ if (typeof fetch !== 'function') {
689
+ throw new Error('[MINT SDK] fetch is required for plugin event streams.')
690
+ }
691
+
692
+ controller?.abort()
693
+ controller = new AbortController()
694
+ isConnecting.value = true
695
+ error.value = null
696
+
697
+ try {
698
+ const url = buildPluginEndpointUrl(contract, endpoint, payload, {
699
+ baseUrl: options.baseUrl,
700
+ })
701
+ const response = await fetch(url, {
702
+ method: 'GET',
703
+ headers: streamHeaders(options as PluginEventStreamOptions<unknown>),
704
+ credentials: options.credentials ?? 'same-origin',
705
+ signal: controller.signal,
706
+ })
707
+
708
+ if (!response.ok) {
709
+ throw new Error(`Plugin event stream failed with HTTP ${response.status}`)
710
+ }
711
+ if (!response.body) {
712
+ throw new Error('Plugin event stream response did not include a readable body.')
713
+ }
714
+
715
+ options.onOpen?.()
716
+ isConnected.value = true
717
+ const reader = response.body.getReader()
718
+ const decoder = new TextDecoder()
719
+ let buffer = ''
720
+
721
+ for (;;) {
722
+ const { value, done } = await reader.read()
723
+ if (done) break
724
+ buffer += decoder.decode(value, { stream: true })
725
+
726
+ let boundary = buffer.search(/\r?\n\r?\n/)
727
+ while (boundary !== -1) {
728
+ const block = buffer.slice(0, boundary)
729
+ buffer = buffer.slice(buffer[boundary] === '\r' ? boundary + 4 : boundary + 2)
730
+ const message = parseSseEvent<TData>(block, options.parseJson === true)
731
+ if (message) {
732
+ lastMessage.value = message
733
+ if (message.id !== undefined) {
734
+ lastEventId.value = message.id
735
+ }
736
+ if (message.retry !== undefined) {
737
+ options.reconnectDelayMs = message.retry
738
+ }
739
+ options.onMessage?.(message)
740
+ }
741
+ boundary = buffer.search(/\r?\n\r?\n/)
742
+ }
743
+ }
744
+ } catch (err) {
745
+ if (!stopped && !(err instanceof DOMException && err.name === 'AbortError')) {
746
+ const message = err instanceof Error ? err.message : 'Plugin event stream failed'
747
+ error.value = message
748
+ options.onError?.(err)
749
+ }
750
+ } finally {
751
+ isConnecting.value = false
752
+ isConnected.value = false
753
+ scheduleReconnect()
754
+ }
755
+ }
756
+
757
+ function start(): void {
758
+ stopped = false
759
+ clearReconnectTimer()
760
+ void connect()
761
+ }
762
+
763
+ function stop(): void {
764
+ stopped = true
765
+ clearReconnectTimer()
766
+ controller?.abort()
767
+ controller = null
768
+ isConnecting.value = false
769
+ isConnected.value = false
770
+ }
771
+
772
+ if (options.immediate !== false && typeof window === 'undefined') {
773
+ onMounted(start)
774
+ }
775
+ onUnmounted(stop)
776
+
777
+ if (options.immediate !== false && typeof window !== 'undefined') {
778
+ start()
779
+ }
780
+
781
+ return {
782
+ isConnected,
783
+ isConnecting,
784
+ error,
785
+ lastMessage,
786
+ lastEventId,
787
+ start,
788
+ stop,
789
+ }
345
790
  }
346
791
 
347
792
  /** Read and optionally load the current platform experiment for integrated plugin views. */
@@ -205,10 +205,8 @@
205
205
 
206
206
  .mint-page-selector__item-label {
207
207
  flex: 1;
208
- }
209
-
210
- .mint-page-selector__item-hint {
211
- font-size: 0.65625rem;
212
- color: var(--text-muted);
208
+ min-width: 0;
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
213
211
  white-space: nowrap;
214
212
  }
@@ -80,10 +80,9 @@
80
80
  background-color: var(--bg-secondary);
81
81
  color: var(--text-secondary-strong);
82
82
  border: 1px solid var(--border-color);
83
- /* Intentionally tighter + fainter than the colored variants
84
- secondary shouldn't "float" the way filled buttons do. Regular (400)
85
- weight de-emphasizes the label relative to the medium-weight filled variants. */
86
- font-weight: 400;
83
+ /* Secondary is lower-emphasis through surface, border, and shadow, but keeps
84
+ medium label weight so it still reads as an actionable button. */
85
+ font-weight: 500;
87
86
  box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03);
88
87
  }
89
88
 
@@ -128,7 +127,7 @@
128
127
  .mint-button--ghost {
129
128
  background-color: transparent;
130
129
  color: var(--text-secondary-strong);
131
- /* Lowest-emphasis variant: regular (400) weight, matching secondary. */
130
+ /* Lowest-emphasis variant: regular (400) weight, below secondary. */
132
131
  font-weight: 400;
133
132
  }
134
133
 
@@ -146,6 +145,10 @@
146
145
  min-height: var(--form-height-sm);
147
146
  }
148
147
 
148
+ .mint-button--secondary.mint-button--sm {
149
+ font-weight: 500;
150
+ }
151
+
149
152
  .mint-button--md {
150
153
  padding: 0.5rem 1rem;
151
154
  font-size: 0.875rem;