@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/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
- if (typeof value !== "object" || value === null) return {
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 = value;
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
- scope;
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
- /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
627
- usageFlushMs = 1500;
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.scope = sctx.settings.register(NS, RouterConfigSchema);
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
- if (Object.keys(this.pendingUsage).length > 0) this.flushUsage();
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
- if (this.scope === void 0) return EMPTY_CONFIG;
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.scope === void 0) return;
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.scope === void 0 || Object.keys(pending).length === 0) return;
718
+ if (this.settings === void 0 || Object.keys(pending).length === 0) return;
698
719
  const current = this.config();
699
- await this.scope.replace({
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.scope === void 0) return;
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.scope.replace({
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.scope.replace({
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.scope.replace({
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.scope.replace({
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.scope.replace({
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.scope.replace({
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 = z.object({});
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();
@@ -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, persists the
7
- * per-model provider order/active config in the `model-router` settings
8
- * namespace, and serves the fenced JSON API the web settings page calls.
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 z from '@deepseek-ai/schemastery';
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: z<Schemastery.ObjectS<{}>, Schemastery.ObjectT<{}>>;
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
- private scope;
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
- /** Debounced recent-use persistence (see noteModelUsed / flushUsage). */
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.5",
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.2",
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
@@ -1,7 +1,11 @@
1
1
  /**
2
- * @fonlan/dsh-model-router client half: the plugin's own Settings Card
3
- * (设置 → 插件 → 插件配置 → 模型路由) plus the model seat that merges the
4
- * provider switcher into DSH's composer model picker menu.
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 { makeSettingsCard, type SettingsScopeFace } from './settings-card'
18
+ import { makeSettingsSection } from './settings-section'
15
19
  import { registerModelRouterSeat } from './model-seat'
16
20
 
17
- /** The settings namespace this card edits (must match the host half). */
18
- const ROUTER_NS = 'model-router'
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: { name: string; key: string; inject?: () => unknown }, component: unknown): unknown
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', 'settingsScope', 'connection', 'remote', 'remote.session']
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 SettingsCard = makeSettingsCard(ctx)
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
- services.slots.inject('settings.plugin.item', () =>
54
- services.slots.register(
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.plugin.item',
57
- key: ROUTER_NS,
58
- inject: () => ({ scope }),
71
+ name: 'settings.section',
72
+ id: SECTION_ID,
73
+ order: 300,
74
+ label: () => t('settingsTitle'),
75
+ locale: LOCALE_NS,
59
76
  },
60
- SettingsCard,
77
+ SettingsSection,
61
78
  ),
62
79
  )
63
80
 
@@ -6,9 +6,7 @@ export const LOCALE_NS = 'model-router'
6
6
 
7
7
  export const zh = {
8
8
  settingsTitle: '模型路由',
9
- settingsCardDescription: '模型聚合、提供商切换与优先级顺序',
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
- settingsCardDescription: 'Model aggregation, provider switching and preference order',
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}',