@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.
- package/dist/components/AppTopBar.navigation.d.ts +0 -1
- package/dist/components/index.js +1 -1
- package/dist/{components-CJ2--4Ex.js → components-BGVwavdd.js} +24 -35
- package/dist/components-BGVwavdd.js.map +1 -0
- package/dist/composables/index.d.ts +1 -1
- package/dist/composables/index.js +2 -2
- package/dist/composables/usePluginClient.d.ts +82 -5
- package/dist/{composables-DrE6OcZZ.js → composables-C_hPF0Gn.js} +255 -8
- package/dist/{composables-DrE6OcZZ.js.map → composables-C_hPF0Gn.js.map} +1 -1
- package/dist/index.js +3 -3
- package/dist/install.js +1 -1
- package/dist/styles.css +17 -14
- package/package.json +1 -1
- package/src/__tests__/components/AppTopBar.navigation.test.ts +3 -5
- package/src/__tests__/components/AppTopBar.test.ts +2 -5
- package/src/__tests__/components/AppTopBarPageSelector.test.ts +22 -0
- package/src/__tests__/components/PluginWorkspaceView.test.ts +18 -0
- package/src/__tests__/composables/usePluginClient.test.ts +129 -3
- package/src/components/AppTopBar.navigation.ts +0 -2
- package/src/components/AppTopBar.story.vue +5 -5
- package/src/components/AppTopBar.vue +0 -1
- package/src/components/PluginWorkspaceView.vue +5 -1
- package/src/components/internal/AppTopBarPageSelectorInternal.vue +0 -1
- package/src/composables/index.ts +6 -0
- package/src/composables/usePluginClient.ts +453 -8
- package/src/styles/components/app-page-selector.css +3 -5
- package/src/styles/components/button.css +8 -5
- package/dist/components-CJ2--4Ex.js.map +0 -1
|
@@ -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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
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;
|