@fonlan/dsh-model-router 0.3.5 → 0.3.7
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 +158 -88
- package/lib/client.js.map +1 -1
- package/lib/index.js +70 -47
- package/lib/types/index.d.ts +7 -6
- package/lib/types/server/service.d.ts +27 -4
- package/lib/types/shared/config.d.ts +2 -0
- package/package.json +8 -8
- package/src/client/api.ts +5 -0
- package/src/client/icons.tsx +97 -0
- package/src/client/index.tsx +45 -28
- package/src/client/locales.ts +3 -7
- package/src/client/model-seat.tsx +10 -15
- package/src/client/{settings-card.css → settings-section.css} +19 -59
- package/src/client/settings-section.tsx +431 -0
- package/src/index.ts +11 -8
- package/src/server/service.ts +67 -57
- package/src/shared/config.ts +32 -2
- package/src/client/settings-card.tsx +0 -469
package/lib/index.js
CHANGED
|
@@ -311,10 +311,36 @@ function setOrder(config, modelId, order, catalogProviderIds) {
|
|
|
311
311
|
changed: true
|
|
312
312
|
};
|
|
313
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Dereference one config field as the loader may hand it over.
|
|
316
|
+
*
|
|
317
|
+
* dsh >= 0.1.7 owns plugin configuration as entry config, and every field the
|
|
318
|
+
* `Config` schema marks `.volatile()` arrives as a stable REFERENCE
|
|
319
|
+
* (`{ get() }`, schemastery's `Volatile<T>`) instead of a plain value — a
|
|
320
|
+
* reference serializes to `{}`. Reading such a field raw silently yields
|
|
321
|
+
* `models: {}`, `modelSort: 'custom'`, an empty `modelOrder` and no
|
|
322
|
+
* `recentlyUsed`: the stored document is dropped, and the next persist
|
|
323
|
+
* overwrites it with catalog defaults. Plain values pass through unchanged, so
|
|
324
|
+
* this is safe for the write paths and for callers that already hold a
|
|
325
|
+
* resolved document.
|
|
326
|
+
*/
|
|
327
|
+
function derefField(value) {
|
|
328
|
+
if (value !== null && typeof value === "object" && typeof value.get === "function") return value.get();
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
/** Dereference every own field of a config object (per-field volatile refs). */
|
|
332
|
+
function derefConfig(value) {
|
|
333
|
+
const root = derefField(value);
|
|
334
|
+
if (root === null || typeof root !== "object" || Array.isArray(root)) return root;
|
|
335
|
+
const out = {};
|
|
336
|
+
for (const [key, field] of Object.entries(root)) out[key] = derefField(field);
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
314
339
|
/** Shape-validate an unknown value as a RouterConfigShape (lenient). */
|
|
315
340
|
function normalizeConfig(value) {
|
|
316
341
|
const models = {};
|
|
317
|
-
|
|
342
|
+
const source = derefConfig(value);
|
|
343
|
+
if (typeof source !== "object" || source === null) return {
|
|
318
344
|
models,
|
|
319
345
|
showQuickSwitch: true,
|
|
320
346
|
ignoreModelIdPrefix: true,
|
|
@@ -322,7 +348,7 @@ function normalizeConfig(value) {
|
|
|
322
348
|
modelOrder: [],
|
|
323
349
|
recentlyUsed: {}
|
|
324
350
|
};
|
|
325
|
-
const root =
|
|
351
|
+
const root = source;
|
|
326
352
|
const showQuickSwitch = root.showQuickSwitch !== false;
|
|
327
353
|
const ignoreModelIdPrefix = root.ignoreModelIdPrefix !== false;
|
|
328
354
|
const modelSort = root.modelSort === "name" || root.modelSort === "recent" ? root.modelSort : "custom";
|
|
@@ -589,25 +615,17 @@ const RouterConfigSchema = z.object({
|
|
|
589
615
|
models: z.dict(z.object({
|
|
590
616
|
order: z.array(z.string()),
|
|
591
617
|
active: z.string()
|
|
592
|
-
})),
|
|
593
|
-
showQuickSwitch: z.boolean(),
|
|
594
|
-
ignoreModelIdPrefix: z.boolean(),
|
|
618
|
+
})).volatile(),
|
|
619
|
+
showQuickSwitch: z.boolean().volatile(),
|
|
620
|
+
ignoreModelIdPrefix: z.boolean().volatile(),
|
|
595
621
|
modelSort: z.union([
|
|
596
622
|
z.const("custom"),
|
|
597
623
|
z.const("name"),
|
|
598
624
|
z.const("recent")
|
|
599
|
-
]),
|
|
600
|
-
modelOrder: z.array(z.string()),
|
|
601
|
-
recentlyUsed: z.dict(z.number())
|
|
625
|
+
]).volatile(),
|
|
626
|
+
modelOrder: z.array(z.string()).volatile(),
|
|
627
|
+
recentlyUsed: z.dict(z.number()).volatile()
|
|
602
628
|
});
|
|
603
|
-
const EMPTY_CONFIG = {
|
|
604
|
-
models: {},
|
|
605
|
-
showQuickSwitch: true,
|
|
606
|
-
ignoreModelIdPrefix: true,
|
|
607
|
-
modelSort: "custom",
|
|
608
|
-
modelOrder: [],
|
|
609
|
-
recentlyUsed: {}
|
|
610
|
-
};
|
|
611
629
|
var ModelRouterService = class {
|
|
612
630
|
ctx;
|
|
613
631
|
cached = {
|
|
@@ -619,22 +637,30 @@ var ModelRouterService = class {
|
|
|
619
637
|
};
|
|
620
638
|
inflight = null;
|
|
621
639
|
cachedAt = 0;
|
|
622
|
-
|
|
640
|
+
/** Optional host settings service; absent in profiles without one (no persistence). */
|
|
641
|
+
settings;
|
|
642
|
+
/** Live config document (seeded from the entry config, updated on write). */
|
|
643
|
+
configDoc;
|
|
623
644
|
registration;
|
|
624
645
|
/** How long a built catalog is considered fresh before being rebuilt. */
|
|
625
646
|
catalogTtlMs = 3e4;
|
|
626
|
-
/**
|
|
627
|
-
|
|
647
|
+
/**
|
|
648
|
+
* Debounced recent-use persistence (see noteModelUsed / flushUsage). Kept
|
|
649
|
+
* long on purpose: every persist writes the entry config, which restarts
|
|
650
|
+
* this plugin's entry, so usage bookkeeping must not stream live traffic.
|
|
651
|
+
*/
|
|
652
|
+
usageFlushMs = 6e4;
|
|
628
653
|
usageDebounce = null;
|
|
629
654
|
pendingUsage = {};
|
|
630
655
|
usageFlushRunning = false;
|
|
631
|
-
constructor(ctx) {
|
|
656
|
+
constructor(ctx, config) {
|
|
632
657
|
this.ctx = ctx;
|
|
658
|
+
this.configDoc = normalizeConfig(config);
|
|
633
659
|
}
|
|
634
|
-
/** Register settings + adapter and wire topology listeners. */
|
|
660
|
+
/** Register the settings seam + adapter and wire topology listeners. */
|
|
635
661
|
start() {
|
|
636
662
|
this.ctx.inject(["settings"], (sctx) => {
|
|
637
|
-
this.
|
|
663
|
+
this.settings = sctx.settings;
|
|
638
664
|
});
|
|
639
665
|
const adapter = new ModelRouterAdapter(this);
|
|
640
666
|
this.registration = this.ctx.llm.registerAdapter([ROUTER_PROVIDER_ID], adapter);
|
|
@@ -648,7 +674,7 @@ var ModelRouterService = class {
|
|
|
648
674
|
clearTimeout(this.usageDebounce);
|
|
649
675
|
this.usageDebounce = null;
|
|
650
676
|
}
|
|
651
|
-
|
|
677
|
+
this.pendingUsage = {};
|
|
652
678
|
this.registration?.();
|
|
653
679
|
this.registration = void 0;
|
|
654
680
|
}
|
|
@@ -659,12 +685,7 @@ var ModelRouterService = class {
|
|
|
659
685
|
};
|
|
660
686
|
}
|
|
661
687
|
config() {
|
|
662
|
-
|
|
663
|
-
try {
|
|
664
|
-
return normalizeConfig(this.scope.get());
|
|
665
|
-
} catch {
|
|
666
|
-
return EMPTY_CONFIG;
|
|
667
|
-
}
|
|
688
|
+
return this.configDoc;
|
|
668
689
|
}
|
|
669
690
|
providerNames() {
|
|
670
691
|
return this.cached.providerNames;
|
|
@@ -679,7 +700,7 @@ var ModelRouterService = class {
|
|
|
679
700
|
* write.
|
|
680
701
|
*/
|
|
681
702
|
noteModelUsed(modelId) {
|
|
682
|
-
if (this.
|
|
703
|
+
if (this.settings === void 0) return;
|
|
683
704
|
this.pendingUsage[modelId] = Date.now();
|
|
684
705
|
if (this.usageDebounce !== null) return;
|
|
685
706
|
this.usageDebounce = setTimeout(() => {
|
|
@@ -694,9 +715,9 @@ var ModelRouterService = class {
|
|
|
694
715
|
const pending = this.pendingUsage;
|
|
695
716
|
this.pendingUsage = {};
|
|
696
717
|
try {
|
|
697
|
-
if (this.
|
|
718
|
+
if (this.settings === void 0 || Object.keys(pending).length === 0) return;
|
|
698
719
|
const current = this.config();
|
|
699
|
-
await this.
|
|
720
|
+
await this.writeConfig({
|
|
700
721
|
...current,
|
|
701
722
|
recentlyUsed: {
|
|
702
723
|
...current.recentlyUsed,
|
|
@@ -743,12 +764,12 @@ var ModelRouterService = class {
|
|
|
743
764
|
/** Refresh (forced), then persist any config drift (migration, new/vanished models). */
|
|
744
765
|
async refreshAndReconcile() {
|
|
745
766
|
const catalog = await this.refreshCatalog(true);
|
|
746
|
-
if (this.
|
|
767
|
+
if (this.settings === void 0) return;
|
|
747
768
|
const current = this.config();
|
|
748
769
|
const { models, modelOrder, changed } = reconcileConfig(current, catalog.models);
|
|
749
770
|
if (!changed) return;
|
|
750
771
|
try {
|
|
751
|
-
await this.
|
|
772
|
+
await this.writeConfig({
|
|
752
773
|
...current,
|
|
753
774
|
models,
|
|
754
775
|
...modelOrder === void 0 ? {} : { modelOrder }
|
|
@@ -770,6 +791,7 @@ var ModelRouterService = class {
|
|
|
770
791
|
const names = catalog.providerNames;
|
|
771
792
|
const creds = catalog.credentialConfigured;
|
|
772
793
|
return {
|
|
794
|
+
writable: this.settings !== void 0,
|
|
773
795
|
showQuickSwitch: config.showQuickSwitch,
|
|
774
796
|
ignoreModelIdPrefix: config.ignoreModelIdPrefix,
|
|
775
797
|
modelSort: config.modelSort,
|
|
@@ -818,10 +840,9 @@ var ModelRouterService = class {
|
|
|
818
840
|
}
|
|
819
841
|
/** Toggle the composer quick route-switcher button (global, persisted). */
|
|
820
842
|
async setShowQuickSwitch(value) {
|
|
821
|
-
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
822
843
|
const current = this.config();
|
|
823
844
|
if (current.showQuickSwitch === value) return;
|
|
824
|
-
await this.
|
|
845
|
+
await this.writeConfig({
|
|
825
846
|
...current,
|
|
826
847
|
showQuickSwitch: value
|
|
827
848
|
});
|
|
@@ -833,10 +854,9 @@ var ModelRouterService = class {
|
|
|
833
854
|
* turning on; split models re-initialize when turning off).
|
|
834
855
|
*/
|
|
835
856
|
async setIgnoreModelIdPrefix(value) {
|
|
836
|
-
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
837
857
|
const current = this.config();
|
|
838
858
|
if (current.ignoreModelIdPrefix === value) return;
|
|
839
|
-
await this.
|
|
859
|
+
await this.writeConfig({
|
|
840
860
|
...current,
|
|
841
861
|
ignoreModelIdPrefix: value
|
|
842
862
|
});
|
|
@@ -844,10 +864,9 @@ var ModelRouterService = class {
|
|
|
844
864
|
}
|
|
845
865
|
/** Switch the model-list display order mode (global, persisted). */
|
|
846
866
|
async setModelSort(mode) {
|
|
847
|
-
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
848
867
|
const current = this.config();
|
|
849
868
|
if (current.modelSort === mode) return;
|
|
850
|
-
await this.
|
|
869
|
+
await this.writeConfig({
|
|
851
870
|
...current,
|
|
852
871
|
modelSort: mode
|
|
853
872
|
});
|
|
@@ -858,22 +877,26 @@ var ModelRouterService = class {
|
|
|
858
877
|
* always covers the live catalog.
|
|
859
878
|
*/
|
|
860
879
|
async setModelOrder(order) {
|
|
861
|
-
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
862
880
|
await this.refreshCatalog();
|
|
863
881
|
const ids = new Set(this.cached.models.map((model) => model.id));
|
|
864
882
|
const clean = order.filter((id) => ids.has(id));
|
|
865
883
|
for (const model of this.cached.models) if (!clean.includes(model.id)) clean.push(model.id);
|
|
866
884
|
const current = this.config();
|
|
867
885
|
if (clean.length === current.modelOrder.length && clean.every((id, at) => id === current.modelOrder[at])) return;
|
|
868
|
-
await this.
|
|
886
|
+
await this.writeConfig({
|
|
869
887
|
...current,
|
|
870
888
|
modelOrder: clean
|
|
871
889
|
});
|
|
872
890
|
}
|
|
891
|
+
/** Write the document through the host settings service (entry config). */
|
|
892
|
+
async writeConfig(next) {
|
|
893
|
+
if (this.settings === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
894
|
+
await this.settings.replace(NS, next, void 0);
|
|
895
|
+
this.configDoc = normalizeConfig(next);
|
|
896
|
+
}
|
|
873
897
|
async persist(models) {
|
|
874
|
-
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
875
898
|
const current = this.config();
|
|
876
|
-
await this.
|
|
899
|
+
await this.writeConfig({
|
|
877
900
|
...current,
|
|
878
901
|
models
|
|
879
902
|
});
|
|
@@ -1023,9 +1046,9 @@ function requireString(value, name) {
|
|
|
1023
1046
|
//#region src/index.ts
|
|
1024
1047
|
const name = "@fonlan/dsh-model-router";
|
|
1025
1048
|
const inject = ["llm"];
|
|
1026
|
-
const Config =
|
|
1027
|
-
function apply(ctx) {
|
|
1028
|
-
const service = new ModelRouterService(ctx);
|
|
1049
|
+
const Config = RouterConfigSchema;
|
|
1050
|
+
function apply(ctx, config) {
|
|
1051
|
+
const service = new ModelRouterService(ctx, config);
|
|
1029
1052
|
ctx.effect(() => {
|
|
1030
1053
|
service.start();
|
|
1031
1054
|
return () => service.stop();
|
package/lib/types/index.d.ts
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Registers one `model-router` LLM adapter that aggregates every configured
|
|
5
5
|
* provider and model (strict model-id merge), routes each request to the
|
|
6
|
-
* model's currently active provider by in-process delegation,
|
|
7
|
-
* per-model provider order/active config in the
|
|
8
|
-
*
|
|
6
|
+
* model's currently active provider by in-process delegation, keeps the
|
|
7
|
+
* per-model provider order/active config in the entry config of this bundle
|
|
8
|
+
* (dsh >= 0.1.7: the `Config` schema below is the settings document), and
|
|
9
|
+
* serves the fenced JSON API the web settings page calls.
|
|
9
10
|
*/
|
|
10
11
|
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
-
import
|
|
12
|
+
import type { RouterConfigShape } from './shared/config.js';
|
|
12
13
|
export declare const name = "@fonlan/dsh-model-router";
|
|
13
14
|
export declare const inject: string[];
|
|
14
|
-
export declare const Config:
|
|
15
|
-
export declare function apply(ctx: Context): void;
|
|
15
|
+
export declare const Config: import("@deepseek-ai/schemastery").default<RouterConfigShape>;
|
|
16
|
+
export declare function apply(ctx: Context, config: RouterConfigShape): void;
|
|
@@ -9,10 +9,24 @@ import { type ModelSortMode, type RouterConfigShape } from '../shared/config.js'
|
|
|
9
9
|
import { type RouterCatalog } from './catalog.js';
|
|
10
10
|
import { type RouterFacts } from './adapter.js';
|
|
11
11
|
export declare const NS = "model-router";
|
|
12
|
+
/**
|
|
13
|
+
* The slice of the host settings service this plugin writes through. dsh >=
|
|
14
|
+
* 0.1.7 owns plugin configuration as entry config (the plugin's `Config`
|
|
15
|
+
* schema), so the router reads the document it was applied with and persists
|
|
16
|
+
* updates via `settings.replace`, which writes the profile patch.
|
|
17
|
+
*/
|
|
18
|
+
export interface SettingsReplaceFace {
|
|
19
|
+
replace(ns: string, section: object, expectedRevision?: number): Promise<void>;
|
|
20
|
+
}
|
|
12
21
|
/** Settings document schema: one entry per model id. */
|
|
13
22
|
export declare const RouterConfigSchema: z<RouterConfigShape>;
|
|
14
23
|
/** The settings-page state view served by the API. */
|
|
15
24
|
export interface ModelRouterState {
|
|
25
|
+
/**
|
|
26
|
+
* Whether this deployment can persist configuration. False (a profile
|
|
27
|
+
* without the host settings service) makes the page render read-only.
|
|
28
|
+
*/
|
|
29
|
+
writable: boolean;
|
|
16
30
|
/** Whether provider switching is shown inside the composer model picker menu. */
|
|
17
31
|
showQuickSwitch: boolean;
|
|
18
32
|
/** Whether model ids are matched with their leading vendor/ prefix ignored. */
|
|
@@ -46,17 +60,24 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
46
60
|
private cached;
|
|
47
61
|
private inflight;
|
|
48
62
|
private cachedAt;
|
|
49
|
-
|
|
63
|
+
/** Optional host settings service; absent in profiles without one (no persistence). */
|
|
64
|
+
private settings;
|
|
65
|
+
/** Live config document (seeded from the entry config, updated on write). */
|
|
66
|
+
private configDoc;
|
|
50
67
|
private registration;
|
|
51
68
|
/** How long a built catalog is considered fresh before being rebuilt. */
|
|
52
69
|
private readonly catalogTtlMs;
|
|
53
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* Debounced recent-use persistence (see noteModelUsed / flushUsage). Kept
|
|
72
|
+
* long on purpose: every persist writes the entry config, which restarts
|
|
73
|
+
* this plugin's entry, so usage bookkeeping must not stream live traffic.
|
|
74
|
+
*/
|
|
54
75
|
private readonly usageFlushMs;
|
|
55
76
|
private usageDebounce;
|
|
56
77
|
private pendingUsage;
|
|
57
78
|
private usageFlushRunning;
|
|
58
|
-
constructor(ctx: Context);
|
|
59
|
-
/** Register settings + adapter and wire topology listeners. */
|
|
79
|
+
constructor(ctx: Context, config: RouterConfigShape);
|
|
80
|
+
/** Register the settings seam + adapter and wire topology listeners. */
|
|
60
81
|
start(): void;
|
|
61
82
|
stop(): void;
|
|
62
83
|
catalog(): {
|
|
@@ -115,5 +136,7 @@ export declare class ModelRouterService implements RouterFacts {
|
|
|
115
136
|
* always covers the live catalog.
|
|
116
137
|
*/
|
|
117
138
|
setModelOrder(order: string[]): Promise<void>;
|
|
139
|
+
/** Write the document through the host settings service (entry config). */
|
|
140
|
+
private writeConfig;
|
|
118
141
|
private persist;
|
|
119
142
|
}
|
|
@@ -198,5 +198,7 @@ export declare function setOrder(config: RouterConfigShape, modelId: string, ord
|
|
|
198
198
|
models: Record<string, RouterModelConfig>;
|
|
199
199
|
changed: boolean;
|
|
200
200
|
};
|
|
201
|
+
/** Dereference every own field of a config object (per-field volatile refs). */
|
|
202
|
+
export declare function derefConfig(value: unknown): unknown;
|
|
201
203
|
/** Shape-validate an unknown value as a RouterConfigShape (lenient). */
|
|
202
204
|
export declare function normalizeConfig(value: unknown): RouterConfigShape;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-model-router",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "DSH plugin: a virtual model-router provider that aggregates every configured provider and model, routes each request to the model's currently active provider, and ships a settings page for per-model provider ordering and switching.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -63,15 +63,15 @@
|
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
66
|
-
"@deepseek-ai/schemastery": "^3.18.2",
|
|
67
|
-
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
|
|
68
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
69
|
-
"@deepseek-ai/dsh-credentials": "^0.1.2-alpha.2",
|
|
70
66
|
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
|
|
71
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
72
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
|
|
73
67
|
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
|
|
74
68
|
"@deepseek-ai/dsh-client-ui-model-selection": "^0.1.2-alpha.2",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
71
|
+
"@deepseek-ai/dsh-credentials": "^0.1.2-alpha.2",
|
|
72
|
+
"@deepseek-ai/dsh-llm": "^0.1.2-alpha.2",
|
|
73
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
74
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
75
75
|
"react": "^18.2.0",
|
|
76
76
|
"react-dom": "^18.2.0"
|
|
77
77
|
},
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@deepseek-ai/dsh-session": "^0.1.2-alpha.2",
|
|
93
93
|
"@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
|
|
94
94
|
"@deepseek-ai/dsh-timeout": "0.1.2-alpha.2",
|
|
95
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
95
|
+
"@deepseek-ai/schemastery": "^3.18.3",
|
|
96
96
|
"@types/node": "^22.0.0",
|
|
97
97
|
"@types/react": "~18.3.1",
|
|
98
98
|
"@types/react-dom": "~18.3.0",
|
package/src/client/api.ts
CHANGED
|
@@ -16,6 +16,11 @@ export interface RouterModelView {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export interface ModelRouterState {
|
|
19
|
+
/**
|
|
20
|
+
* Whether this deployment can persist router configuration (the host half
|
|
21
|
+
* found the settings service). False renders the page read-only.
|
|
22
|
+
*/
|
|
23
|
+
writable: boolean
|
|
19
24
|
/** Whether provider switching is shown inside the composer model picker menu. */
|
|
20
25
|
showQuickSwitch: boolean
|
|
21
26
|
/** Whether model ids are matched with their leading vendor/ prefix ignored. */
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
|
2
|
+
|
|
3
|
+
/** Props every shell icon accepts: square edge in px, color rides currentColor. */
|
|
4
|
+
export interface IconProps {
|
|
5
|
+
size?: number
|
|
6
|
+
className?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** One shell icon component. */
|
|
10
|
+
export type IconComponent = (props: IconProps) => JSX.Element
|
|
11
|
+
|
|
12
|
+
/** The runtime export table, read by capability instead of by one hard-coded name. */
|
|
13
|
+
const table = primitives as unknown as Record<string, IconComponent | undefined>
|
|
14
|
+
|
|
15
|
+
/** First name the runtime actually provides (a missing export is `undefined`). */
|
|
16
|
+
function firstDefined(...names: string[]): IconComponent | undefined {
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
let candidate: IconComponent | undefined
|
|
19
|
+
try {
|
|
20
|
+
candidate = table[name]
|
|
21
|
+
} catch {
|
|
22
|
+
// Some module namespaces reject unknown names instead of yielding
|
|
23
|
+
// `undefined`; a rename must never throw on its way to the fallback.
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
if (typeof candidate === 'function') return candidate
|
|
27
|
+
}
|
|
28
|
+
return undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One-path stroked glyph fallback (16×16 viewBox — the 0.1.7 artwork). */
|
|
32
|
+
function glyph(path: string, defaultSize: number): IconComponent {
|
|
33
|
+
return function Glyph({ size = defaultSize, className }: IconProps): JSX.Element {
|
|
34
|
+
return (
|
|
35
|
+
<svg
|
|
36
|
+
width={size}
|
|
37
|
+
height={size}
|
|
38
|
+
className={className}
|
|
39
|
+
viewBox="0 0 16 16"
|
|
40
|
+
fill="none"
|
|
41
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
42
|
+
aria-hidden="true"
|
|
43
|
+
strokeWidth={1}
|
|
44
|
+
>
|
|
45
|
+
<path d={path} stroke="currentColor" />
|
|
46
|
+
</svg>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Warning circle fallback (the 0.1.7 artwork: ring, stem, dot). */
|
|
52
|
+
function WarningFallback({ size = 16, className }: IconProps): JSX.Element {
|
|
53
|
+
return (
|
|
54
|
+
<svg
|
|
55
|
+
width={size}
|
|
56
|
+
height={size}
|
|
57
|
+
className={className}
|
|
58
|
+
viewBox="0 0 16 16"
|
|
59
|
+
fill="none"
|
|
60
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
strokeWidth={1}
|
|
63
|
+
>
|
|
64
|
+
<path d="M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z" stroke="currentColor" />
|
|
65
|
+
<path d="M8 4.29199V9.79199" stroke="currentColor" />
|
|
66
|
+
<path d="M8 10.708V11.708" stroke="currentColor" />
|
|
67
|
+
</svg>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Shell icons used by the model seat.
|
|
73
|
+
*
|
|
74
|
+
* DSH 0.1.7 renamed every size-suffixed icon export (`IconCheckOutline16`,
|
|
75
|
+
* `IconChevronDownOutline14`, …) into stroke-weight variants (`…OutlineRegular`
|
|
76
|
+
* 1px / `…OutlineMedium` 1.3px). A stale named import resolves to `undefined`
|
|
77
|
+
* and React then throws "Element type is invalid" while rendering the slot
|
|
78
|
+
* entry — the whole seat is dropped from the conversation input instead of
|
|
79
|
+
* degrading (this is exactly what the 0.1.7 upgrade did to this plugin).
|
|
80
|
+
* Resolve the names at runtime, newest first, so the next rename lands on
|
|
81
|
+
* another shipped variant or on the inlined artwork below.
|
|
82
|
+
*/
|
|
83
|
+
export const CheckIcon: IconComponent =
|
|
84
|
+
firstDefined('IconCheckOutlineRegular', 'IconCheckOutlineMedium', 'IconCheckOutline16')
|
|
85
|
+
?? glyph('M2.25 8.5L5.49732 11.7473C5.90519 12.1552 6.57263 12.1344 6.95426 11.7018L13.75 4', 16)
|
|
86
|
+
|
|
87
|
+
export const ChevronDownIcon: IconComponent =
|
|
88
|
+
firstDefined('IconChevronDownOutlineRegular', 'IconChevronDownOutlineMedium', 'IconChevronDownOutline14')
|
|
89
|
+
?? glyph('M4 6L7.29289 9.29289C7.68342 9.68342 8.31658 9.68342 8.70711 9.29289L12 6', 14)
|
|
90
|
+
|
|
91
|
+
export const ChevronRightIcon: IconComponent =
|
|
92
|
+
firstDefined('IconChevronRightOutlineRegular', 'IconChevronRightOutlineMedium', 'IconChevronRightOutline14')
|
|
93
|
+
?? glyph('M6 12L9.29289 8.70711C9.68342 8.31658 9.68342 7.68342 9.29289 7.29289L6 4', 14)
|
|
94
|
+
|
|
95
|
+
export const WarningIcon: IconComponent =
|
|
96
|
+
firstDefined('IconWarningOutlineRegular', 'IconWarningOutlineMedium', 'IconWarningOutline16')
|
|
97
|
+
?? WarningFallback
|
package/src/client/index.tsx
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fonlan/dsh-model-router client half: the plugin's own Settings
|
|
3
|
-
* (设置 →
|
|
4
|
-
*
|
|
2
|
+
* @fonlan/dsh-model-router client half: the plugin's own Settings page
|
|
3
|
+
* (设置 → 侧栏「模型路由」) plus the model seat that merges the provider
|
|
4
|
+
* switcher into DSH's composer model picker menu.
|
|
5
|
+
*
|
|
6
|
+
* The page registers into the settings.section slot, so it owns one entry in
|
|
7
|
+
* the settings sidebar and renders in the panel content column; the page
|
|
8
|
+
* component itself lives in ./settings-section.
|
|
5
9
|
*/
|
|
6
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
7
11
|
type ClientContext = Context
|
|
@@ -11,25 +15,34 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
|
11
15
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
12
16
|
import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
13
17
|
import { LOCALE_NS, zh, en } from './locales'
|
|
14
|
-
import {
|
|
18
|
+
import { makeSettingsSection } from './settings-section'
|
|
15
19
|
import { registerModelRouterSeat } from './model-seat'
|
|
16
20
|
|
|
17
|
-
/** The settings
|
|
18
|
-
const
|
|
21
|
+
/** The settings sidebar entry this page owns (must stay stable). */
|
|
22
|
+
const SECTION_ID = 'model-router'
|
|
23
|
+
|
|
24
|
+
/** Slots face: the settings.section list slot is declared at runtime by the
|
|
25
|
+
* settings shell (ui-settings-general), which external plugins do not depend
|
|
26
|
+
* on; the register call below is typed through this local face (erased at
|
|
27
|
+
* build time). */
|
|
28
|
+
interface SectionEntry {
|
|
29
|
+
name: string
|
|
30
|
+
id?: string
|
|
31
|
+
order?: number
|
|
32
|
+
label?: () => string
|
|
33
|
+
locale?: string
|
|
34
|
+
}
|
|
19
35
|
|
|
20
|
-
/** Slots face: the keyed `settings.plugin.item` declaration ships with the web
|
|
21
|
-
* app's ui-settings-plugins package, which external plugins do not depend on;
|
|
22
|
-
* the register call below is typed through this local face (erased at build). */
|
|
23
36
|
interface Slots {
|
|
24
37
|
inject(name: string, callback: () => unknown): unknown
|
|
25
|
-
register(def:
|
|
38
|
+
register(def: SectionEntry, component: unknown): unknown
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
/** Services required before mounting (provided by the client runtime).
|
|
29
42
|
* `remote.session` is included like the host ui-model-selection declares it:
|
|
30
43
|
* the seat's `directoryFor` depends on the namespace, which mounts
|
|
31
44
|
* asynchronously at startup. */
|
|
32
|
-
export const inject = ['slots', 'locale', 'modelDirectories', 'sessions', '
|
|
45
|
+
export const inject = ['slots', 'locale', 'modelDirectories', 'sessions', 'connection', 'remote', 'remote.session']
|
|
33
46
|
|
|
34
47
|
/** Client plugin body. */
|
|
35
48
|
export function apply(ctx: ClientContext): void {
|
|
@@ -38,26 +51,30 @@ export function apply(ctx: ClientContext): void {
|
|
|
38
51
|
return () => off()
|
|
39
52
|
}, 'model-router: dictionaries')
|
|
40
53
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
// The card registers on the `settings.plugin.item` slot keyed by the
|
|
44
|
-
// `model-router` settings namespace (the same string the host half
|
|
45
|
-
// registers), so it appears inside 设置 → 插件 → 插件配置, paired with the
|
|
46
|
-
// namespace by the tab.
|
|
47
|
-
const services = ctx as unknown as {
|
|
48
|
-
slots: Slots
|
|
49
|
-
settingsScope: { bind(spec: { namespace: string }): SettingsScopeFace }
|
|
50
|
-
}
|
|
51
|
-
const scope = services.settingsScope.bind({ namespace: ROUTER_NS })
|
|
54
|
+
const t = ctx.locale.bind(LOCALE_NS) as unknown as (key: string) => string
|
|
55
|
+
const SettingsSection = makeSettingsSection(ctx)
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
// The page registers on the settings.section list slot under a stable id, so
|
|
58
|
+
// it appears in the settings sidebar's left navigation.
|
|
59
|
+
//
|
|
60
|
+
// dsh >= 0.1.7 dropped the client-side `settingsScope` service (plugin
|
|
61
|
+
// configuration became loader-entry config, projected by the host), and the
|
|
62
|
+
// old conditional registration therefore never fired: the section silently
|
|
63
|
+
// disappeared from Settings. Register unconditionally — the page reads and
|
|
64
|
+
// writes through this plugin's own fenced API (see ./settings-section), so
|
|
65
|
+
// it needs no settings service at all. Never gate a slot registration on a
|
|
66
|
+
// service that may not exist: the failure mode is an invisible page.
|
|
67
|
+
const slots = (ctx as unknown as { slots: Slots }).slots
|
|
68
|
+
slots.inject('settings.section', () =>
|
|
69
|
+
slots.register(
|
|
55
70
|
{
|
|
56
|
-
name: 'settings.
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
name: 'settings.section',
|
|
72
|
+
id: SECTION_ID,
|
|
73
|
+
order: 300,
|
|
74
|
+
label: () => t('settingsTitle'),
|
|
75
|
+
locale: LOCALE_NS,
|
|
59
76
|
},
|
|
60
|
-
|
|
77
|
+
SettingsSection,
|
|
61
78
|
),
|
|
62
79
|
)
|
|
63
80
|
|
package/src/client/locales.ts
CHANGED
|
@@ -6,9 +6,7 @@ export const LOCALE_NS = 'model-router'
|
|
|
6
6
|
|
|
7
7
|
export const zh = {
|
|
8
8
|
settingsTitle: '模型路由',
|
|
9
|
-
|
|
10
|
-
expand: '展开',
|
|
11
|
-
collapse: '收起',
|
|
9
|
+
sectionSub: '模型聚合、提供商切换与优先级顺序',
|
|
12
10
|
readOnly: '本部署的设置为只读。',
|
|
13
11
|
loading: '加载中…',
|
|
14
12
|
loadFailed: '加载失败:{message}',
|
|
@@ -58,11 +56,9 @@ export const zh = {
|
|
|
58
56
|
sortFailed: '排序设置失败:{message}',
|
|
59
57
|
}
|
|
60
58
|
|
|
61
|
-
export const en = {
|
|
59
|
+
export const en: Record<keyof typeof zh, string> = {
|
|
62
60
|
settingsTitle: 'Model Router',
|
|
63
|
-
|
|
64
|
-
expand: 'Expand',
|
|
65
|
-
collapse: 'Collapse',
|
|
61
|
+
sectionSub: 'Model aggregation, provider switching and preference order',
|
|
66
62
|
readOnly: 'This deployment stores settings read-only.',
|
|
67
63
|
loading: 'Loading…',
|
|
68
64
|
loadFailed: 'Load failed: {message}',
|