@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.
- package/lib/client.js +122 -87
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.tsx +45 -20
- package/src/client/locales.ts +3 -7
- package/src/client/model-seat.tsx +144 -22
- package/src/client/{settings-card.css → settings-section.css} +19 -59
- package/src/client/{settings-card.tsx → settings-section.tsx} +159 -176
|
@@ -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
|
-
/**
|
|
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
|
|
604
|
-
|
|
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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
/* ──
|
|
51
|
+
/* ── page body: the router controls ─────────────────────────────────────── */
|
|
92
52
|
|
|
93
53
|
.mr-root {
|
|
94
54
|
display: flex;
|