@fonlan/dsh-model-router 0.3.4 → 0.3.6

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.
@@ -41,6 +41,34 @@ interface ModelSelection {
41
41
  reasoningEffort?: string
42
42
  }
43
43
 
44
+ /**
45
+ * Structural subsets of the host model-catalog wire types (declared locally to
46
+ * avoid pulling a new peer dependency; identical to the host shapes).
47
+ */
48
+ interface ModelReasoningEffort {
49
+ id: string
50
+ name: string
51
+ description?: string
52
+ }
53
+
54
+ interface ModelReasoning {
55
+ efforts: readonly ModelReasoningEffort[]
56
+ defaultEffort?: string
57
+ }
58
+
59
+ interface ModelCatalogModel {
60
+ id: string
61
+ name: string
62
+ description?: string
63
+ reasoning?: ModelReasoning
64
+ }
65
+
66
+ interface ModelProviderGroup {
67
+ id: string
68
+ name: string
69
+ models: readonly ModelCatalogModel[]
70
+ }
71
+
44
72
  /** The injected business face of the composer model seat (same share the host ModelSelect receives). */
45
73
  interface ModelSeatInjected {
46
74
  available: boolean
@@ -192,7 +220,7 @@ export function ModelRouterSeat({
192
220
  }, [load, modelId, routeModel, t])
193
221
 
194
222
  // ---- host ModelSelect logic (faithful copy) ----
195
- const choices = useMemo(() => directoryState.groups.flatMap(group => group.models.map(model => ({
223
+ const choices = useMemo(() => directoryState.groups.flatMap((group: ModelProviderGroup) => group.models.map((model: ModelCatalogModel) => ({
196
224
  group,
197
225
  model,
198
226
  selection: {
@@ -207,14 +235,14 @@ export function ModelRouterSeat({
207
235
  const effectiveEffort = current?.reasoningEffort ?? reasoning?.defaultEffort
208
236
  const effortLabel = reasoning === undefined ? undefined
209
237
  : effectiveEffort === undefined ? t('effortProviderDefault')
210
- : reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
238
+ : reasoning.efforts.find((level: ModelReasoningEffort) => level.id === effectiveEffort)?.name ?? effectiveEffort
211
239
  const effortChoices = useMemo<Array<{ key: string; effort: string | undefined; label: string; description?: string }>>(() => reasoning === undefined ? [] : [
212
240
  ...(reasoning.defaultEffort === undefined ? [{
213
241
  key: 'provider-default',
214
242
  effort: undefined as string | undefined,
215
243
  label: t('effortProviderDefault'),
216
244
  }] : []),
217
- ...reasoning.efforts.map(effort => ({
245
+ ...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
218
246
  key: `effort:${effort.id}`,
219
247
  effort: effort.id,
220
248
  label: effort.name,
@@ -456,7 +484,7 @@ export function ModelRouterSeat({
456
484
  <div className="mr-seat-groupTitle" id={headingId}>
457
485
  {group.name}
458
486
  </div>
459
- {group.models.map(model => {
487
+ {group.models.map((model: ModelCatalogModel) => {
460
488
  const selected = current?.provider === group.id && current.model === model.id
461
489
  return (
462
490
  <button
@@ -596,31 +624,125 @@ export function ModelRouterSeat({
596
624
  )
597
625
  }
598
626
 
599
- /** Register the model seat onto the composer's named model slot (priority -1 shadows the host seat). */
627
+ /** The modelDirectories service face (structural subset of the host service). */
628
+ interface ModelDirectoriesFace {
629
+ directoryFor(sessionId: SessionId): SessionDirectoryFace
630
+ }
631
+
632
+ /** The sessions service face (structural subset of the host service). */
633
+ interface SessionsFace {
634
+ subagentAddress(sessionId: SessionId): unknown
635
+ }
636
+
637
+ /** One session model directory (structural subset of the host ModelDirectory). */
638
+ interface SessionDirectoryFace {
639
+ store: SnapshotStore<ModelDirectoryState>
640
+ load(): Promise<unknown>
641
+ select(selection: ModelSelection): Promise<unknown>
642
+ }
643
+
644
+ /** The slots service face (structural subset; the register call is typed `never` below). */
645
+ interface SlotsFace {
646
+ inject(name: string, callback: () => unknown): unknown
647
+ register(def: unknown, component: unknown): unknown
648
+ }
649
+
650
+ /** The scope resolved by the seat's inject block. */
651
+ interface SeatScopeFace {
652
+ modelDirectories: ModelDirectoriesFace
653
+ sessions: SessionsFace
654
+ slots: SlotsFace
655
+ }
656
+
657
+ const EMPTY_DIRECTORY_STATE: ModelDirectoryState = {
658
+ current: null,
659
+ routable: null,
660
+ groups: [],
661
+ failures: [],
662
+ status: 'idle',
663
+ error: null,
664
+ }
665
+
666
+ /**
667
+ * Inert face for the pathological case where the session directory cannot be
668
+ * resolved even after `remote.session` mounted. The seat renders nothing
669
+ * (available: false), but crucially the slot entry never throws, so it is
670
+ * never abdicated and the host seat can keep the cell alive on the next
671
+ * registration pass.
672
+ */
673
+ const INERT_DIRECTORY_STORE: SnapshotStore<ModelDirectoryState> = {
674
+ subscribe: () => () => {},
675
+ getSnapshot: () => EMPTY_DIRECTORY_STATE,
676
+ update: () => {},
677
+ set: () => {},
678
+ }
679
+
680
+ /**
681
+ * Build the seat face without ever throwing out of the slot `inject` hook.
682
+ *
683
+ * The seat registration below depends on `remote.session` (the namespace is
684
+ * mounted asynchronously at startup), so by the time this runs the host
685
+ * `modelDirectories.directoryFor(sessionId)` resolves — the original race is
686
+ * gone by construction. The try/catch here is belt-and-braces: a throwing
687
+ * inject would permanently abdicate this slot entry in the renderer
688
+ * (`reportEntryError` with `abdicate` for single slots), after which the host
689
+ * seat takes over and the Provider row never appears again.
690
+ */
691
+ function seatFace(
692
+ models: ModelDirectoriesFace,
693
+ sessions: SessionsFace,
694
+ sessionId: SessionId,
695
+ ): ModelSeatInjected {
696
+ let directory: SessionDirectoryFace | null = null
697
+ try {
698
+ directory = models.directoryFor(sessionId)
699
+ } catch {
700
+ // unknown session / transient teardown: degrade to the inert face
701
+ }
702
+ if (directory === null) {
703
+ return {
704
+ available: false,
705
+ directory: INERT_DIRECTORY_STORE,
706
+ load: () => {},
707
+ select: () => Promise.resolve(false),
708
+ }
709
+ }
710
+ let available = true
711
+ try {
712
+ available = sessions.subagentAddress(sessionId) === undefined
713
+ } catch {
714
+ // sessions face not ready; treat as available
715
+ }
716
+ return {
717
+ available,
718
+ directory: directory.store,
719
+ load: () => {
720
+ if (available) directory?.load().catch(() => {})
721
+ },
722
+ select: (selection) =>
723
+ available ? directory!.select(selection).then(() => true, () => false) : Promise.resolve(false),
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Register the model seat onto the composer's named model slot (priority -1
729
+ * shadows the host seat). The inject block additionally depends on
730
+ * `remote.session` so the seat registers only after the remote session
731
+ * namespace has mounted — `directoryFor` can throw while it is still mounting,
732
+ * and a throwing inject permanently abdicates the entry (the host seat then
733
+ * wins and the Provider row never appears).
734
+ */
600
735
  export function registerModelRouterSeat(ctx: ClientContext): void {
601
736
  const t = ctx.locale.bind(LOCALE_NS)
602
- ctx.inject(['slots', 'modelDirectories'], scope => {
603
- const models = scope.modelDirectories
604
- const sessions = scope.sessions
605
- scope.slots.inject('conversation.input.model', () => scope.slots.register({
737
+ ctx.inject(['slots', 'modelDirectories', 'remote.session'], scope => {
738
+ const face = scope as unknown as SeatScopeFace
739
+ face.slots.inject('conversation.input.model', () => face.slots.register({
606
740
  name: 'conversation.input.model',
607
741
  id: 'model-router-model-seat',
608
742
  priority: -1,
609
743
  label: () => t('seatLabel'),
610
744
  locale: LOCALE_NS,
611
- inject: (sessionId: SessionId) => {
612
- const directory = models.directoryFor(sessionId)
613
- const available = sessions.subagentAddress(sessionId) === undefined
614
- return {
615
- available,
616
- directory: directory.store,
617
- load: () => {
618
- if (available) directory.load().catch(() => {})
619
- },
620
- select: (selection: ModelSelection) =>
621
- available ? directory.select(selection).then(() => true, () => false) : Promise.resolve(false),
622
- }
623
- },
745
+ inject: (sessionId: SessionId) => seatFace(face.modelDirectories, face.sessions, sessionId),
624
746
  } as never, ModelRouterSeat as never))
625
747
  })
626
748
  }
@@ -1,81 +1,41 @@
1
- /* Model Router settings card — chrome aligned with the built-in plugin cards
2
- (设置 → 插件 → 插件配置), theme-consistent via DSW alias variables. */
3
- .mr-settings-card {
4
- border: 1px solid var(--dsw-alias-border-l2, #2a3346);
5
- background: var(--dsw-alias-bg-layer-3, #0d1017);
6
- border-radius: 12px;
7
- list-style: none;
8
- transition: border-color .16s, background .16s;
9
- }
1
+ /* Model Router settings page — the page owns one entry in the settings
2
+ sidebar and renders its content in the panel's content column. Styles are
3
+ theme-consistent via DSW alias variables; the page draws its own static
4
+ header (title plus a one-line summary) over the always-visible controls. */
10
5
 
11
- .mr-settings-card:hover {
12
- border-color: var(--dsw-alias-label-dimmed, #4a5468);
13
- }
14
-
15
- .mr-settings-card[data-open] {
16
- background: var(--dsw-alias-bg-layer-2, #141a26);
17
- border-color: var(--dsw-alias-label-dimmed, #4a5468);
18
- }
6
+ /* page layout */
19
7
 
20
- .mr-settings-head {
21
- appearance: none;
22
- width: 100%;
23
- font: inherit;
24
- color: inherit;
25
- text-align: left;
26
- cursor: pointer;
27
- background: transparent;
28
- border: 0;
29
- border-radius: 12px;
30
- align-items: center;
31
- gap: 12px;
32
- padding: 14px 16px;
8
+ .mr-page {
33
9
  display: flex;
10
+ flex-direction: column;
11
+ gap: 16px;
12
+ max-width: 720px;
34
13
  }
35
14
 
36
- .mr-settings-head:focus-visible {
37
- outline: 2px solid var(--dsw-alias-brand-primary, #4f8cff);
38
- outline-offset: -2px;
39
- }
40
-
41
- .mr-settings-head-text {
42
- flex-direction: column;
43
- flex: 1;
44
- gap: 4px;
45
- min-width: 0;
15
+ .mr-page-head {
46
16
  display: flex;
17
+ flex-direction: column;
18
+ gap: 6px;
47
19
  }
48
20
 
49
- .mr-settings-title {
21
+ .mr-page-title {
22
+ margin: 0;
50
23
  color: var(--dsw-alias-label-primary, #e6e6e6);
51
24
  font-size: 15px;
52
25
  font-weight: 600;
53
26
  line-height: 1.4;
54
27
  }
55
28
 
56
- .mr-settings-sub {
29
+ .mr-page-sub {
30
+ margin: 0;
57
31
  color: var(--dsw-alias-label-tertiary, #8b93a7);
58
32
  font-size: 13px;
59
33
  line-height: 1.5;
60
34
  }
61
35
 
62
- .mr-settings-chevron {
63
- color: var(--dsw-alias-label-tertiary, #8b93a7);
64
- flex: none;
65
- transition: transform .16s;
66
- display: flex;
67
- align-items: center;
68
- justify-content: center;
69
- }
70
-
71
- .mr-settings-chevron[data-open] {
72
- transform: rotate(180deg);
73
- }
36
+ /* body layout */
74
37
 
75
- .mr-settings-body {
76
- border-top: 1px solid var(--dsw-alias-border-l2, #2a3346);
77
- margin: 0 16px;
78
- padding: 12px 0 8px;
38
+ .mr-body {
79
39
  display: flex;
80
40
  flex-direction: column;
81
41
  gap: 12px;
@@ -88,7 +48,7 @@
88
48
  line-height: 1.5;
89
49
  }
90
50
 
91
- /* ── card body: the router controls ─────────────────────────────────────── */
51
+ /* ── page body: the router controls ─────────────────────────────────────── */
92
52
 
93
53
  .mr-root {
94
54
  display: flex;