@sqdr2/dsh-fusion 0.3.2 → 0.3.3

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/dsh-fusion.js CHANGED
@@ -519,143 +519,49 @@ const FUSION_API_PATH = "/api/fusion";
519
519
  const LEGACY_FUSION_API_PATH = "/api/model-fusion";
520
520
  /** The plugin id and package name this project used before the rename. */
521
521
  const LEGACY_PLUGIN_NAME = "dsh-model-fusion";
522
- /** Registered provider route for the Fusion model entry. */
522
+ /** Registered provider route for the Fusion model entries. */
523
523
  const FUSION_PROVIDER = "dsh-fusion";
524
- /** The single model id the Fusion entry advertises. */
524
+ /**
525
+ * The model id of the combo every installation starts with.
526
+ *
527
+ * It is a pair id like any other; keeping this exact spelling for the first
528
+ * combo means a durable `model/selection` written before combos existed still
529
+ * names a combo that exists.
530
+ */
525
531
  const FUSION_MODEL = "auto";
526
532
  /**
527
- * The provider route this plugin registered before it was renamed from
528
- * `dsh-model-fusion`. Sessions created then still name it in their durable
529
- * `model/selection` event, so reading it as Fusion is what keeps those
530
- * sessions working; writing it is never correct.
533
+ * The model ids this provider advertises: `auto` plus one generated
534
+ * `pair-xxxxxxxx` per additional combo. The browser bundle inlines this module,
535
+ * so the namespace shape is fixed here rather than read from a settings
536
+ * document at import time.
531
537
  */
532
- const LEGACY_FUSION_PROVIDER = "dsh-model-fusion";
538
+ const FUSION_MODEL_PATTERN = /^(?:auto|pair-[a-z0-9]{6,12})$/;
539
+ /** Whether a model id names a Fusion combo on this provider. */
540
+ const isFusionModel = (model) => typeof model === "string" && FUSION_MODEL_PATTERN.test(model);
533
541
  /** Whether a provider route belongs to this plugin, under either name. */
534
542
  const isFusionProvider = (provider) => provider === "dsh-fusion" || provider === "dsh-model-fusion";
535
- /** Whether a route names the Fusion entry, under either name. */
536
- const isFusionSelection = (route) => isFusionProvider(route?.provider) && route?.model === "auto";
543
+ /** Whether a route names a Fusion combo, under either provider spelling. */
544
+ const isFusionSelection = (route) => isFusionProvider(route?.provider) && isFusionModel(route?.model);
537
545
  /**
538
- * How a native model-switch notice names this entry (`provider/model`).
546
+ * How a native model-switch notice names a combo: `provider/model`, or the bare
547
+ * model when both sides share this provider (switching from one combo to
548
+ * another).
539
549
  *
540
- * Both spellings are listed: a session that switched to Fusion before the
541
- * rename carries the old one in its durable log, and the notice is the same
542
- * non-change on every Fusion step either way.
550
+ * The notice is a pseudo-change: it compares the virtual menu route with the
551
+ * physical Lead route that actually dispatches, so it would otherwise repeat on
552
+ * every Fusion step. Both provider spellings are accepted because a session
553
+ * that switched to Fusion before the rename carries the old one in its durable
554
+ * log; any combo id matches, since combos are not physical models.
543
555
  */
544
- const FUSION_SELECTION_LABELS = [`${FUSION_PROVIDER}/${FUSION_MODEL}`, `${LEGACY_FUSION_PROVIDER}/${FUSION_MODEL}`];
556
+ const FUSION_SELECTION_LABEL_PATTERN = /(?:(?:dsh-fusion|dsh-model-fusion)\/|→ )(?:auto|pair-[a-z0-9]{6,12})$/;
557
+ /** Whether a switch-notice summary describes a Fusion combo as either side. */
558
+ const isFusionSelectionLabel = (summary) => typeof summary === "string" && FUSION_SELECTION_LABEL_PATTERN.test(summary);
545
559
  /**
546
- * Whether a route already names the entry under its current name. A durable
547
- * intent that fails this is rewritten before the next request is assembled;
548
- * the legacy spelling is a read-side alias, not a route that still dispatches.
560
+ * Whether a route already names a combo under its current provider spelling. A
561
+ * durable intent that fails this is rewritten before the next request is
562
+ * assembled; the legacy spelling is a read-side alias, not a dispatching route.
549
563
  */
550
- const isCurrentFusionSelection = (route) => route?.provider === "dsh-fusion" && route.model === "auto";
551
- //#endregion
552
- //#region src/host/native-selection.ts
553
- /** Catalog-only local adapter; the native request is physically routed before dispatch. */
554
- var FusionCatalogAdapter = class extends LlmAdapter {
555
- leadInputModalities;
556
- constructor(leadInputModalities) {
557
- super();
558
- this.leadInputModalities = leadInputModalities;
559
- }
560
- providerInfo(provider) {
561
- return {
562
- id: provider,
563
- name: "Fusion"
564
- };
565
- }
566
- async listModels(provider) {
567
- const lead = await this.leadInputModalities?.() ?? [];
568
- return [{
569
- provider,
570
- id: FUSION_MODEL,
571
- name: "Fusion · 自动",
572
- description: "Lead 与 Worker 协作。在设置 → Fusion 中配置模型。",
573
- inputModalities: [.../* @__PURE__ */ new Set(["text", ...lead])]
574
- }];
575
- }
576
- async resolveModel(provider, model) {
577
- if (provider !== "dsh-fusion" || model !== "auto") throw new Error("Unknown local Fusion selection");
578
- return (await this.listModels(provider))[0];
579
- }
580
- async *stream(_options) {
581
- throw new Error("Fusion 尚未就绪。请前往设置 → Fusion 配置 Lead 和 Worker。");
582
- }
583
- };
584
- /** Public native lifecycle integration. No model-menu replacement or Host patch. */
585
- function installNativeFusionSelection(ctx, input) {
586
- const pending = /* @__PURE__ */ new Map();
587
- const failures = /* @__PURE__ */ new Map();
588
- const desired = /* @__PURE__ */ new Map();
589
- const selection = (agent) => desired.get(agent.id) ?? input.selection(agent);
590
- const selected = (agent) => input.coordinator()?.bindings.read(agent.id)?.binding.selected === true;
591
- const change = (agent) => {
592
- if (agent.session.header.parentSession || pending.has(agent.id)) return;
593
- const coordinator = input.coordinator(), profile = input.profile(), route = selection(agent);
594
- if (!coordinator) return;
595
- if (isFusionSelection(route)) {
596
- if (!selected(agent) && profile) coordinator.selectBeforeAssembly(agent, profile);
597
- if (selected(agent)) {
598
- if (!isCurrentFusionSelection(agent.session.snapshotEvents().findLast((event) => event.type === "model/selection")?.data)) agent.session.append("model/selection", {
599
- provider: FUSION_PROVIDER,
600
- model: FUSION_MODEL
601
- });
602
- failures.delete(agent.id);
603
- }
604
- return;
605
- }
606
- if (!selected(agent)) return;
607
- const transition = (async () => {
608
- if (agent.status !== "idle") await coordinator.pause(agent);
609
- await coordinator.clear(agent);
610
- failures.delete(agent.id);
611
- })().catch((error) => {
612
- failures.set(agent.id, String(error));
613
- }).finally(() => {
614
- pending.delete(agent.id);
615
- });
616
- pending.set(agent.id, transition);
617
- };
618
- const disposers = [
619
- ctx.on("session/event", (session, event) => {
620
- if (event.type !== "model/selection") return;
621
- desired.set(session.id, event.data);
622
- const agent = ctx.agents.get(SessionId$1(session.id));
623
- if (agent && (agent.status === "idle" || !isFusionSelection(event.data))) change(agent);
624
- else if (agent && !selected(agent)) agent.cancel({
625
- kind: "hook",
626
- reason: "Fusion selection takes effect after this turn settles"
627
- }, { keepInbox: true });
628
- }),
629
- ctx.on("agent/status", ({ agent, status }) => {
630
- if (status !== "running" || agent.session.header.parentSession) return;
631
- if (pending.has(agent.id) && selected(agent)) {
632
- agent.cancel({
633
- kind: "hook",
634
- reason: "Model switch is draining the previous Fusion task"
635
- }, { keepInbox: true });
636
- return;
637
- }
638
- try {
639
- change(agent);
640
- } catch (error) {
641
- failures.set(agent.id, String(error));
642
- }
643
- }),
644
- ctx.on("agent/request", async (payload, next) => {
645
- const config = await next(), agent = payload.agent;
646
- if (agent.session.header.parentSession) return config;
647
- const route = selection(agent);
648
- if (pending.has(agent.id) && selected(agent) || failures.has(agent.id)) throw new Error(failures.get(agent.id) ?? "Fusion 正在完成模型切换,请稍后继续");
649
- if (isFusionSelection(route)) {
650
- if (!selected(agent)) throw new Error("请先在设置 → Fusion 配置 Lead 和 Worker,再继续此会话。");
651
- } else if (selected(agent)) throw new Error("先停止当前 Fusion 任务,再切换模型。");
652
- return config;
653
- }, { prepend: true })
654
- ];
655
- return () => {
656
- for (const dispose of disposers.reverse()) dispose();
657
- };
658
- }
564
+ const isCurrentFusionSelection = (route) => route?.provider === "dsh-fusion" && isFusionModel(route.model);
659
565
  //#endregion
660
566
  //#region src/profile/keepalive.ts
661
567
  /** No provider-name heuristic can establish cache support for a DSH route. */
@@ -701,18 +607,6 @@ function profileRoutes(profile) {
701
607
  }
702
608
  //#endregion
703
609
  //#region src/host/settings.ts
704
- /** Saved Lead route from a raw settings or profile document, or undefined when unconfigured or pointed at Fusion itself. */
705
- function savedLeadRoute(value) {
706
- if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
707
- const route = value.lead;
708
- if (!route || typeof route !== "object" || Array.isArray(route)) return void 0;
709
- const provider = route.provider, model = route.model;
710
- if (typeof provider !== "string" || typeof model !== "string" || isFusionProvider(provider)) return void 0;
711
- return {
712
- provider,
713
- model
714
- };
715
- }
716
610
  /** Configuration chooses registered physical models; it cannot create adapters. */
717
611
  function validatePairChoice(value, catalog) {
718
612
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(bi("请选择 Lead 和 Worker", "Choose a Lead and a Sidekick"));
@@ -749,11 +643,11 @@ function validatePairChoice(value, catalog) {
749
643
  } }
750
644
  };
751
645
  }
752
- function profileFromChoice(pair) {
646
+ function profileFromChoice(pair, id = "fusion-auto") {
753
647
  const { outputTokens, ...routes } = pair;
754
648
  return {
755
649
  schemaVersion: 1,
756
- id: "fusion-auto",
650
+ id,
757
651
  version: outputTokens?.worker === 128e3 ? "0.1.0-worker-128k" : "0.1.0-faithful",
758
652
  enabled: true,
759
653
  quality: "experimental",
@@ -764,11 +658,15 @@ function profileFromChoice(pair) {
764
658
  ...outputTokens === void 0 ? {} : { context: Object.fromEntries(Object.entries(outputTokens).map(([role, reserveOutputTokens]) => [role, { reserveOutputTokens }])) }
765
659
  };
766
660
  }
767
- /** Product defaults; explicit legacy/benchmark profiles retain their old contract. */
768
- function modelProfileFromChoice(pair) {
661
+ /**
662
+ * Product defaults; explicit legacy/benchmark profiles retain their old contract.
663
+ * `id` names the combo; the first one keeps the historical `fusion-auto` so a
664
+ * profile frozen before combos existed keeps its identity.
665
+ */
666
+ function modelProfileFromChoice(pair, id = "fusion-auto") {
769
667
  const { outputTokens: _output, ...native } = pair;
770
668
  return {
771
- ...profileFromChoice(native),
669
+ ...profileFromChoice(native, id),
772
670
  version: "0.2.0-enforced-v3",
773
671
  interactionMode: "model-like",
774
672
  workflowPolicy: "enforced-v3"
@@ -786,6 +684,328 @@ function choiceFromProfile(profile) {
786
684
  };
787
685
  }
788
686
  //#endregion
687
+ //#region src/host/pairs.ts
688
+ /** Where the combo list lives; `settings:profile` stays readable for migration. */
689
+ const PAIRS_DOCUMENT = "settings:pairs";
690
+ const LEGACY_PROFILE_DOCUMENT = "settings:profile";
691
+ /** The migrated first combo keeps the label the historical entry had. */
692
+ const DEFAULT_PAIR_NAME = "自动";
693
+ const profileIdOf = (model) => `fusion-${model}`;
694
+ /** The combo a frozen profile belongs to, or undefined for a non-combo profile. */
695
+ function modelOfProfileId(id) {
696
+ const model = id.startsWith("fusion-") ? id.slice(7) : void 0;
697
+ return isFusionModel(model) ? model : void 0;
698
+ }
699
+ const newModel = (taken) => {
700
+ for (let attempt = 0; attempt < 100; attempt += 1) {
701
+ const model = `pair-${randomUUID().replace(/-/g, "").slice(0, 8)}`;
702
+ if (!taken.includes(model)) return model;
703
+ }
704
+ throw new Error(bi("无法生成新的组合标识", "Could not allocate a new combo id"));
705
+ };
706
+ function object$1(value) {
707
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
708
+ }
709
+ function route(value) {
710
+ const item = object$1(value);
711
+ if (!item || typeof item.provider !== "string" || !item.provider.trim() || typeof item.model !== "string" || !item.model.trim()) return void 0;
712
+ return {
713
+ provider: item.provider,
714
+ model: item.model,
715
+ ...typeof item.reasoningEffort === "string" && item.reasoningEffort.trim() ? { reasoningEffort: item.reasoningEffort } : {}
716
+ };
717
+ }
718
+ /**
719
+ * Read one stored pair without the Host catalog: a combo whose model has since
720
+ * been retired must stay visible (and editable) instead of hiding the list.
721
+ */
722
+ function structuralPair(value) {
723
+ const raw = object$1(value);
724
+ const lead = route(raw?.lead), worker = route(raw?.worker);
725
+ if (!raw || !lead || !worker) return void 0;
726
+ const compactorRoute = route(object$1(raw.compactor)?.route);
727
+ const maxOutputTokens = object$1(raw.compactor)?.maxOutputTokens;
728
+ let cacheKeepalive;
729
+ try {
730
+ cacheKeepalive = keepaliveChoice(raw.cacheKeepalive);
731
+ } catch {
732
+ cacheKeepalive = void 0;
733
+ }
734
+ let compactor;
735
+ try {
736
+ compactor = compactorRoute ? compactorChoice({
737
+ route: compactorRoute,
738
+ maxOutputTokens
739
+ }) : void 0;
740
+ } catch {
741
+ compactor = void 0;
742
+ }
743
+ return {
744
+ lead,
745
+ worker,
746
+ ...compactor === void 0 ? {} : { compactor },
747
+ ...cacheKeepalive === void 0 ? {} : { cacheKeepalive }
748
+ };
749
+ }
750
+ /** A pre-combo profile document as the choice it was saved from. */
751
+ function choiceFromStoredProfile(value) {
752
+ const raw = object$1(value);
753
+ const pair = structuralPair(raw);
754
+ if (!raw || !pair) return void 0;
755
+ if (raw.interactionMode === "model-like") return pair;
756
+ const context = object$1(raw.context);
757
+ const outputTokens = Object.fromEntries(["lead", "worker"].map((role) => [role, object$1(context?.[role])?.reserveOutputTokens]).filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0 && Number(tokens) !== 8e3).map(([role, tokens]) => [role, Number(tokens)]));
758
+ return Object.keys(outputTokens).length ? {
759
+ ...pair,
760
+ outputTokens
761
+ } : pair;
762
+ }
763
+ function parsePairs(value) {
764
+ const raw = object$1(value);
765
+ if (!raw || raw.schemaVersion !== 1 || !Array.isArray(raw.pairs)) return void 0;
766
+ const seen = /* @__PURE__ */ new Set();
767
+ return raw.pairs.flatMap((item) => {
768
+ const entry = object$1(item);
769
+ const pair = structuralPair(entry?.pair);
770
+ if (!entry || !pair || typeof entry.id !== "string" || !isFusionModel(entry.id) || seen.has(entry.id)) return [];
771
+ if (typeof entry.name !== "string" || !entry.name.trim() || entry.name.length > 40) return [];
772
+ seen.add(entry.id);
773
+ return [{
774
+ id: entry.id,
775
+ name: entry.name,
776
+ pair
777
+ }];
778
+ });
779
+ }
780
+ /** The saved combos; a pre-combo installation migrates to one `auto` combo on read. */
781
+ function readPairs(store) {
782
+ const document = store.readDocument(PAIRS_DOCUMENT);
783
+ if (document) {
784
+ const pairs = parsePairs(document.value);
785
+ if (!pairs?.length) throw new Error(bi("组合配置已损坏,请在 Fusion 页面重新保存", "The combo list is unreadable; save it again from the Fusion page"));
786
+ return {
787
+ revision: document.revision,
788
+ pairs,
789
+ migrated: false
790
+ };
791
+ }
792
+ const legacy = choiceFromStoredProfile(store.readDocument(LEGACY_PROFILE_DOCUMENT)?.value);
793
+ return legacy ? {
794
+ revision: 0,
795
+ pairs: [{
796
+ id: FUSION_MODEL,
797
+ name: DEFAULT_PAIR_NAME,
798
+ pair: legacy
799
+ }],
800
+ migrated: true
801
+ } : {
802
+ revision: 0,
803
+ pairs: [],
804
+ migrated: false
805
+ };
806
+ }
807
+ function pairName(value, fallback) {
808
+ if (typeof value !== "string") return fallback;
809
+ const name = value.trim();
810
+ if (!name) return fallback;
811
+ if (name.length > 40) throw new Error(bi(`组合名称最多 40 个字符`, `A combo name is at most 40 characters`));
812
+ return name;
813
+ }
814
+ /**
815
+ * Validate the list a settings action proposes. Ids are assigned here, never by
816
+ * the client, so a stale tab cannot name a combo that is already frozen
817
+ * elsewhere; reordering only moves the menu rows.
818
+ */
819
+ function validatePairs(value, catalog, existing) {
820
+ if (!Array.isArray(value) || !value.length) throw new Error(bi("请至少保留一个组合", "Keep at least one combo"));
821
+ if (value.length > 6) throw new Error(bi(`最多保存 6 个组合`, `At most 6 combos can be saved`));
822
+ const known = new Set(existing.map((pair) => pair.id));
823
+ const taken = /* @__PURE__ */ new Set();
824
+ const names = /* @__PURE__ */ new Set();
825
+ return value.map((item, index) => {
826
+ const raw = object$1(item);
827
+ if (!raw) throw new Error(bi("组合配置无效", "Invalid combo entry"));
828
+ const requested = typeof raw.id === "string" ? raw.id : "";
829
+ const id = (known.has(requested) || requested === "auto") && !taken.has(requested) ? requested : newModel([...taken]);
830
+ taken.add(id);
831
+ const name = pairName(raw.name, index === 0 ? DEFAULT_PAIR_NAME : `组合 ${index + 1}`);
832
+ if (names.has(name)) throw new Error(bi(`组合名称 "${name}" 重复`, `The combo name "${name}" is used twice`));
833
+ names.add(name);
834
+ return {
835
+ id,
836
+ name,
837
+ pair: validatePairChoice(raw.pair, catalog)
838
+ };
839
+ });
840
+ }
841
+ function pairsDocument(pairs) {
842
+ return {
843
+ schemaVersion: 1,
844
+ pairs: pairs.map(({ id, name, pair }) => ({
845
+ id,
846
+ name,
847
+ pair
848
+ }))
849
+ };
850
+ }
851
+ /**
852
+ * Persist the list under one compare-and-swap revision. The first combo is
853
+ * mirrored into the pre-combo `settings:profile` document so an older tab (or a
854
+ * downgrade) still finds the pair it knows about.
855
+ */
856
+ function writePairs(store, expectedRevision, pairs) {
857
+ const mirror = pairs.find((pair) => pair.id === "auto") ?? pairs[0];
858
+ const legacy = store.readDocument(LEGACY_PROFILE_DOCUMENT);
859
+ return store.writeDocuments([{
860
+ id: PAIRS_DOCUMENT,
861
+ expectedRevision,
862
+ value: pairsDocument(pairs)
863
+ }, {
864
+ id: LEGACY_PROFILE_DOCUMENT,
865
+ expectedRevision: legacy?.revision ?? 0,
866
+ value: modelProfileFromChoice(mirror.pair, profileIdOf(mirror.id))
867
+ }])[0];
868
+ }
869
+ //#endregion
870
+ //#region src/host/native-selection.ts
871
+ const textOnly = [.../* @__PURE__ */ new Set(["text"])];
872
+ /** Catalog-only local adapter; the native request is physically routed before dispatch. */
873
+ var FusionCatalogAdapter = class extends LlmAdapter {
874
+ models;
875
+ constructor(models) {
876
+ super();
877
+ this.models = models;
878
+ }
879
+ providerInfo(provider) {
880
+ return {
881
+ id: provider,
882
+ name: "Fusion"
883
+ };
884
+ }
885
+ async listModels(provider) {
886
+ const declared = await this.models?.().catch(() => []) ?? [];
887
+ return (declared.length ? declared : [{
888
+ id: FUSION_MODEL,
889
+ name: "Fusion · 自动",
890
+ inputModalities: textOnly
891
+ }]).map((entry) => ({
892
+ provider,
893
+ id: entry.id,
894
+ name: entry.name,
895
+ description: entry.description ?? "Lead 与 Worker 协作。在设置 → Fusion 中配置模型。",
896
+ inputModalities: [.../* @__PURE__ */ new Set(["text", ...entry.inputModalities])]
897
+ }));
898
+ }
899
+ async resolveModel(provider, model) {
900
+ if (provider !== "dsh-fusion" || !isFusionModel(model)) throw new Error("Unknown local Fusion selection");
901
+ const declared = (await this.listModels(provider)).find((entry) => entry.id === model);
902
+ if (!declared) throw new Error("Unknown local Fusion selection");
903
+ return declared;
904
+ }
905
+ async *stream(_options) {
906
+ throw new Error("Fusion 尚未就绪。请前往设置 → Fusion 配置 Lead 和 Worker。");
907
+ }
908
+ };
909
+ /** Public native lifecycle integration. No model-menu replacement or Host patch. */
910
+ function installNativeFusionSelection(ctx, input) {
911
+ const pending = /* @__PURE__ */ new Map();
912
+ const failures = /* @__PURE__ */ new Map();
913
+ const desired = /* @__PURE__ */ new Map();
914
+ const selection = (agent) => desired.get(agent.id) ?? input.selection(agent);
915
+ const binding = (agent) => input.coordinator()?.bindings.read(agent.id)?.binding;
916
+ const selected = (agent) => binding(agent)?.selected === true;
917
+ /** The combo a selected session is frozen to, or undefined when it runs no Fusion task. */
918
+ const runningCombo = (agent) => {
919
+ const current = binding(agent);
920
+ return current?.selected ? modelOfProfileId(current.profile.id) ?? "auto" : void 0;
921
+ };
922
+ const blocked = (model) => input.reason?.(model) ?? "请先在设置 → Fusion 配置 Lead 和 Worker,再继续此会话。";
923
+ const change = (agent) => {
924
+ if (agent.session.header.parentSession || pending.has(agent.id)) return;
925
+ const coordinator = input.coordinator(), route = selection(agent);
926
+ if (!coordinator) return;
927
+ if (isFusionSelection(route)) {
928
+ const model = route?.model ?? "auto";
929
+ const profile = input.profile(model), running = runningCombo(agent);
930
+ if (running !== void 0 && running !== model) {
931
+ const transition = (async () => {
932
+ if (agent.status !== "idle") await coordinator.pause(agent);
933
+ await coordinator.clear(agent);
934
+ if (profile) coordinator.selectBeforeAssembly(agent, profile);
935
+ else failures.set(agent.id, blocked(model));
936
+ })().catch((error) => {
937
+ failures.set(agent.id, String(error));
938
+ }).finally(() => {
939
+ pending.delete(agent.id);
940
+ });
941
+ pending.set(agent.id, transition);
942
+ return;
943
+ }
944
+ if (running === void 0) if (profile) coordinator.selectBeforeAssembly(agent, profile);
945
+ else failures.set(agent.id, blocked(model));
946
+ if (selected(agent)) {
947
+ if (!isCurrentFusionSelection(agent.session.snapshotEvents().findLast((event) => event.type === "model/selection")?.data)) agent.session.append("model/selection", {
948
+ provider: FUSION_PROVIDER,
949
+ model
950
+ });
951
+ failures.delete(agent.id);
952
+ }
953
+ return;
954
+ }
955
+ if (!selected(agent)) return;
956
+ const transition = (async () => {
957
+ if (agent.status !== "idle") await coordinator.pause(agent);
958
+ await coordinator.clear(agent);
959
+ failures.delete(agent.id);
960
+ })().catch((error) => {
961
+ failures.set(agent.id, String(error));
962
+ }).finally(() => {
963
+ pending.delete(agent.id);
964
+ });
965
+ pending.set(agent.id, transition);
966
+ };
967
+ const disposers = [
968
+ ctx.on("session/event", (session, event) => {
969
+ if (event.type !== "model/selection") return;
970
+ desired.set(session.id, event.data);
971
+ const agent = ctx.agents.get(SessionId$1(session.id));
972
+ if (agent && (agent.status === "idle" || !isFusionSelection(event.data))) change(agent);
973
+ else if (agent && !selected(agent)) agent.cancel({
974
+ kind: "hook",
975
+ reason: "Fusion selection takes effect after this turn settles"
976
+ }, { keepInbox: true });
977
+ }),
978
+ ctx.on("agent/status", ({ agent, status }) => {
979
+ if (status !== "running" || agent.session.header.parentSession) return;
980
+ if (pending.has(agent.id) && selected(agent)) {
981
+ agent.cancel({
982
+ kind: "hook",
983
+ reason: "Model switch is draining the previous Fusion task"
984
+ }, { keepInbox: true });
985
+ return;
986
+ }
987
+ try {
988
+ change(agent);
989
+ } catch (error) {
990
+ failures.set(agent.id, String(error));
991
+ }
992
+ }),
993
+ ctx.on("agent/request", async (payload, next) => {
994
+ const config = await next(), agent = payload.agent;
995
+ if (agent.session.header.parentSession) return config;
996
+ const route = selection(agent);
997
+ if (pending.has(agent.id) && selected(agent) || failures.has(agent.id)) throw new Error(failures.get(agent.id) ?? "Fusion 正在完成模型切换,请稍后继续");
998
+ if (isFusionSelection(route)) {
999
+ if (!selected(agent)) throw new Error(blocked(route?.model ?? "auto"));
1000
+ } else if (selected(agent)) throw new Error("先停止当前 Fusion 任务,再切换模型。");
1001
+ return config;
1002
+ }, { prepend: true })
1003
+ ];
1004
+ return () => {
1005
+ for (const dispose of disposers.reverse()) dispose();
1006
+ };
1007
+ }
1008
+ //#endregion
789
1009
  //#region src/host/cache-defaults.ts
790
1010
  /**
791
1011
  * Default prompt-cache behaviour for the models most people pair in Fusion, from each provider's official
@@ -1171,12 +1391,18 @@ var CachePolicy = class {
1171
1391
  }));
1172
1392
  }
1173
1393
  };
1174
- /** One row of the settings page: what applies, why, what the defaults say, and what was observed. */
1394
+ /**
1395
+ * One row of the settings page: what applies, why, what the defaults say, and
1396
+ * what was observed. Every saved combo contributes its routes, so a second
1397
+ * combo's models are listed and labelled even before a session uses them.
1398
+ */
1175
1399
  function cacheView(policy, pair, pairChoice) {
1176
- const same = (a, provider, model) => a?.provider === provider && a.model === model;
1177
- return policy.routes(pair ? [pair.lead, pair.worker] : []).map(({ provider, model }) => {
1178
- const role = same(pair?.lead, provider, model) ? "lead" : same(pair?.worker, provider, model) ? "worker" : void 0;
1179
- const effective = policy.resolve(provider, model, role ?? "lead", role ? pairChoice?.[role] : void 0);
1400
+ const pairs = Array.isArray(pair) ? pair : pair ? [pair] : [];
1401
+ const same = (role, provider, model) => pairs.some((item) => item[role].provider === provider && item[role].model === model);
1402
+ const choiceOf = (role) => pairs.find((item) => item.cacheKeepalive)?.["cacheKeepalive"]?.[role] ?? pairChoice?.[role];
1403
+ return policy.routes(pairs.flatMap((item) => [item.lead, item.worker])).map(({ provider, model }) => {
1404
+ const role = same("lead", provider, model) ? "lead" : same("worker", provider, model) ? "worker" : void 0;
1405
+ const effective = policy.resolve(provider, model, role ?? "lead", role ? choiceOf(role) : void 0);
1180
1406
  const stats = policy.stats(provider, model), { family, route } = effective.defaults;
1181
1407
  return {
1182
1408
  provider,
@@ -4924,7 +5150,7 @@ var NativeFusionScopes = class {
4924
5150
  ...decision,
4925
5151
  messages: [...decision.messages.filter((message) => {
4926
5152
  const source = message.source;
4927
- return !(source?.kind === "model-selection" && source.form === "notice" && FUSION_SELECTION_LABELS.some((label) => source.summary.endsWith(label)));
5153
+ return !(source?.kind === "model-selection" && source.form === "notice" && isFusionSelectionLabel(source.summary));
4928
5154
  }).map((message) => this.callbacks.projectMessage?.(agent, entry.binding, role, message) ?? message), ...taskContext ? [taskContext] : []]
4929
5155
  };
4930
5156
  }, { prepend: true }));
@@ -5174,9 +5400,16 @@ function captureWorkerBrief(store, taskId, agent, turn, step, revision) {
5174
5400
  function workerToolBriefProblem(store, taskId, exec, revision, transport) {
5175
5401
  const events = exec.agent?.session.snapshotEvents();
5176
5402
  const message = events?.findLast((event) => event.type === "assistant/message" && event.data.message.content.some((block) => block.type === "tool-call" && block.id === exec.rootCallId)) ?? (transport === void 0 ? void 0 : events?.findLast((event) => event.type === "assistant/message" && event.data.message.content.some((block) => block.type === "tool-call" && block.name === transport)));
5177
- if (!message || message.type !== "assistant/message" || message.data.interrupted) return "Worker tool requires a complete native model request";
5178
- const row = store.readDocument(`worker-request:${exec.agent.id}:${message.data.turn}:${message.data.step}`)?.value;
5179
- if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent.id || row.turn !== message.data.turn || row.step !== message.data.step) return "Worker tool has no captured brief identity";
5403
+ let row;
5404
+ if (message && message.type === "assistant/message" && !message.data.interrupted) {
5405
+ row = store.readDocument(`worker-request:${exec.agent.id}:${message.data.turn}:${message.data.step}`)?.value;
5406
+ if (row?.schemaVersion !== 1 || row.taskId !== taskId || row.sessionId !== exec.agent.id || row.turn !== message.data.turn || row.step !== message.data.step) return "Worker tool has no captured brief identity";
5407
+ } else if (transport !== void 0) {
5408
+ const rows = store.listDocumentIds(`worker-request:${exec.agent.id}:`).map((id) => store.readDocument(id)?.value).filter((r) => Boolean(r && r.schemaVersion === 1 && r.taskId === taskId && r.sessionId === exec.agent.id));
5409
+ rows.sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0) || (a.step ?? 0) - (b.step ?? 0));
5410
+ row = rows.at(-1);
5411
+ if (!row) return "Worker tool has no captured brief identity";
5412
+ } else return "Worker tool requires a complete native model request";
5180
5413
  if (row.briefRevision !== revision) return "A newer Lead brief superseded this request; process it before further tools or a report";
5181
5414
  const order = store.load(taskId)?.currentWorkOrder;
5182
5415
  if (row.workOrderId !== order?.id && (row.workOrderId || order?.mode)) return "A newer work order superseded this request; process its plan before further tools";
@@ -6311,6 +6544,17 @@ function installCompactionRouting(ctx, input) {
6311
6544
  //#endregion
6312
6545
  //#region src/host/native-role-sandbox.ts
6313
6546
  const roleSeparated = (binding) => binding.profile.workflowPolicy === "enforced-v3";
6547
+ const intentId = (sessionId) => `sandbox-intent:${sessionId}`;
6548
+ /**
6549
+ * The mode the user chose for a confined Fusion conversation, which is the mode
6550
+ * the Sidekick runs under. Undefined until Fusion has confined the Lead, or after
6551
+ * the conversation left Fusion and its own mode was restored.
6552
+ */
6553
+ function readSandboxIntent(store, sessionId) {
6554
+ const intent = store.readDocument(intentId(sessionId))?.value;
6555
+ if (!intent || intent.schemaVersion !== 1 || !intent.active) return void 0;
6556
+ return { mode: intent.mode };
6557
+ }
6314
6558
  /**
6315
6559
  * Role separation enforced below the prompt: the Lead's session runs under the
6316
6560
  * Host's own read-only sandbox (bash and filesystem are refused by the OS
@@ -6331,15 +6575,12 @@ var NativeRoleSandbox = class {
6331
6575
  get #policy() {
6332
6576
  return this.ctx.get("sandboxPolicy");
6333
6577
  }
6334
- #id(sessionId) {
6335
- return `sandbox-intent:${sessionId}`;
6336
- }
6337
6578
  #intent(sessionId) {
6338
- return this.store.readDocument(this.#id(sessionId))?.value;
6579
+ return this.store.readDocument(intentId(sessionId))?.value;
6339
6580
  }
6340
6581
  #save(intent) {
6341
- const prior = this.store.readDocument(this.#id(intent.sessionId));
6342
- this.store.writeDocument(this.#id(intent.sessionId), prior?.revision ?? 0, {
6582
+ const prior = this.store.readDocument(intentId(intent.sessionId));
6583
+ this.store.writeDocument(intentId(intent.sessionId), prior?.revision ?? 0, {
6343
6584
  ...intent,
6344
6585
  ownSeqs: intent.ownSeqs.slice(-32)
6345
6586
  });
@@ -11143,6 +11384,17 @@ const EN = {
11143
11384
  "继续消息的投递结果尚未确认。检查会话后使用 /fusion recover 提交检查记录。": "Delivery of the continuation is unconfirmed. Check the conversation, then use /fusion recover."
11144
11385
  };
11145
11386
  const english = (text) => text == null ? text : EN[text] ?? text;
11387
+ /**
11388
+ * The role working right now: an in-flight request names it exactly (requests read
11389
+ * newest first), and between requests the phase does ('DIRECT' is the Lead writing).
11390
+ */
11391
+ function activeRole(requests, phase) {
11392
+ const running = requests.find((request) => request.purpose === "conversation" && request.outcome === "entered");
11393
+ if (running) return running.role;
11394
+ if (phase === "WORKER_RUNNING" || phase === "REWORK") return "worker";
11395
+ if (phase === "PLANNING" || phase === "REVIEWING" || phase === "DIRECT") return "lead";
11396
+ return null;
11397
+ }
11146
11398
  function taskStage(state, checking, workerStepLimitReached = false, workerQuotaExhausted = false) {
11147
11399
  const control = state.control;
11148
11400
  const value = (stage, detail = null, attention = false) => ({
@@ -11227,6 +11479,13 @@ function readStatus(store, sessionId, index) {
11227
11479
  ...modelStage,
11228
11480
  ...pendingDelivery
11229
11481
  };
11482
+ const intent = roleSeparated(binding) ? readSandboxIntent(store, sessionId) : void 0;
11483
+ const takeover = runtime?.schemaVersion === 1 && runtime.taskId === taskId && runtime.takeover === true;
11484
+ const permissions = intent && {
11485
+ lead: takeover ? intent.mode : "read-only",
11486
+ worker: intent.mode,
11487
+ active: activeRole(facts.requests, state.phase)
11488
+ };
11230
11489
  return {
11231
11490
  ...base,
11232
11491
  task: {
@@ -11243,6 +11502,7 @@ function readStatus(store, sessionId, index) {
11243
11502
  worker: modelControl?.routes.worker ?? binding.profile.worker,
11244
11503
  compactor: binding.profile.compactor?.route ?? null
11245
11504
  },
11505
+ ...permissions ? { permissions } : {},
11246
11506
  ...facts,
11247
11507
  automatedChecks: Array.isArray(runtime?.checks) && runtime?.taskId === taskId ? runtime.checks.length : null,
11248
11508
  pendingApprovals: state.control.pendingApprovalIds.length,
@@ -12253,7 +12513,6 @@ async function apply(ctx, config = {}) {
12253
12513
  const cachePolicy = new CachePolicy(store);
12254
12514
  backfillHistory(store);
12255
12515
  const budget = new NativeRequestBudget(store, config.authorizationPath);
12256
- let defaultProfile;
12257
12516
  const physicalCatalog = async () => {
12258
12517
  const catalog = await ctx.sessionController.modelCatalog();
12259
12518
  return {
@@ -12261,19 +12520,82 @@ async function apply(ctx, config = {}) {
12261
12520
  groups: catalog.groups.filter((group) => !isFusionProvider(group.id))
12262
12521
  };
12263
12522
  };
12264
- const configuredProfile = async () => {
12265
- defaultProfile = void 0;
12266
- const saved = store.readDocument("settings:profile");
12267
- if (!saved && (!config.profilePath || !isAbsolute(config.profilePath))) throw new Error(bi("请先在设置的 Fusion 页面选择 Lead 和 Worker", "Open Settings → Fusion and choose a Lead and a Sidekick first"));
12268
- const raw = saved?.value ?? loadJsonObject(config.profilePath);
12523
+ /**
12524
+ * The combos to resolve: the saved list, or the one profile file a benchmark
12525
+ * deployment names. A profile file keeps its legacy contract, so only saved
12526
+ * combos are rebuilt from a choice.
12527
+ */
12528
+ const comboPlan = () => {
12529
+ const snapshot = readPairs(store);
12530
+ if (snapshot.pairs.length) return {
12531
+ revision: snapshot.revision,
12532
+ items: snapshot.pairs
12533
+ };
12534
+ if (config.profilePath && isAbsolute(config.profilePath)) {
12535
+ const raw = loadJsonObject(config.profilePath);
12536
+ const pair = choiceFromStoredProfile(raw);
12537
+ if (pair) return {
12538
+ revision: snapshot.revision,
12539
+ items: [{
12540
+ id: FUSION_MODEL,
12541
+ name: "自动",
12542
+ pair,
12543
+ raw
12544
+ }]
12545
+ };
12546
+ }
12547
+ return {
12548
+ revision: snapshot.revision,
12549
+ items: []
12550
+ };
12551
+ };
12552
+ let combos;
12553
+ const resolveCombos = async () => {
12554
+ const plan = comboPlan();
12269
12555
  const catalog = await physicalCatalog();
12270
- const pair = validatePairChoice(raw, catalog);
12271
- const next = saved ? modelProfileFromChoice(pair) : raw;
12272
- defaultProfile = resolveProfile(next, next.interactionMode === "model-like" ? loadModelPromptBundle() : loadPromptBundle(), { authorizedRoutes: catalog.groups.flatMap((group) => group.models.map((model) => ({
12556
+ const authorizedRoutes = catalog.groups.flatMap((group) => group.models.map((model) => ({
12273
12557
  provider: group.id,
12274
12558
  model: model.id
12275
- }))) });
12276
- return defaultProfile;
12559
+ })));
12560
+ const profiles = /* @__PURE__ */ new Map(), failures = /* @__PURE__ */ new Map();
12561
+ for (const item of plan.items) try {
12562
+ const pair = validatePairChoice(item.pair, catalog);
12563
+ const next = item.raw ?? modelProfileFromChoice(pair, profileIdOf(item.id));
12564
+ profiles.set(item.id, resolveProfile(next, next.interactionMode === "model-like" ? loadModelPromptBundle() : loadPromptBundle(), { authorizedRoutes }));
12565
+ } catch (error) {
12566
+ failures.set(item.id, error instanceof Error ? error.message : String(error));
12567
+ }
12568
+ combos = {
12569
+ revision: plan.revision,
12570
+ items: plan.items.map(({ id, name, pair }) => ({
12571
+ id,
12572
+ name,
12573
+ pair
12574
+ })),
12575
+ profiles,
12576
+ failures
12577
+ };
12578
+ return combos;
12579
+ };
12580
+ const knownCombos = async () => combos ?? await resolveCombos();
12581
+ let reloading = false;
12582
+ const profileOf = (model) => {
12583
+ const found = combos?.profiles.get(model);
12584
+ if (!found && !reloading) {
12585
+ reloading = true;
12586
+ resolveCombos().catch(() => void 0).finally(() => {
12587
+ reloading = false;
12588
+ });
12589
+ }
12590
+ return found;
12591
+ };
12592
+ /** The profile a plain (unselected) request falls back to: the first usable combo. */
12593
+ const primaryProfile = async () => {
12594
+ const state = await knownCombos();
12595
+ const profile = state.profiles.values().next().value;
12596
+ if (profile) return profile;
12597
+ const [first] = state.failures.values();
12598
+ throw new Error(first ?? bi("请先在设置的 Fusion 页面选择 Lead 和 Worker", "Open Settings → Fusion and choose a Lead and a Sidekick first"));
12277
12599
  };
12278
12600
  const needsBudget = (profile) => Boolean(config.authorizationPath) || profile.interactionMode !== "model-like";
12279
12601
  let runtime;
@@ -12287,7 +12609,7 @@ async function apply(ctx, config = {}) {
12287
12609
  if (runtime) return Promise.resolve(runtime);
12288
12610
  if (creating) return creating;
12289
12611
  creating = (async () => {
12290
- const profile = frozen ?? await configuredProfile();
12612
+ const profile = frozen ?? await primaryProfile();
12291
12613
  runtime = new FusionCoordinator(ctx, store, {
12292
12614
  profile,
12293
12615
  workerTools: config.workerTools ?? defaultWorkerTools,
@@ -12322,26 +12644,47 @@ async function apply(ctx, config = {}) {
12322
12644
  });
12323
12645
  return creating;
12324
12646
  };
12325
- const leadInputModalities = async () => {
12647
+ const fusionModels = async () => {
12648
+ let items;
12326
12649
  try {
12327
- const saved = store.readDocument("settings:profile")?.value;
12328
- const lead = savedLeadRoute(saved) ?? (config.profilePath && isAbsolute(config.profilePath) ? savedLeadRoute(loadJsonObject(config.profilePath)) : void 0);
12329
- if (!lead) return void 0;
12330
- return (await ctx.llm.resolveModelInfo(lead.provider, lead.model)).inputModalities;
12650
+ items = readPairs(store).pairs;
12331
12651
  } catch {
12332
- return;
12652
+ return [];
12333
12653
  }
12654
+ if (!items.length && config.profilePath && isAbsolute(config.profilePath)) {
12655
+ const pair = choiceFromStoredProfile(loadJsonObject(config.profilePath));
12656
+ items = pair ? [{
12657
+ id: FUSION_MODEL,
12658
+ name: "自动",
12659
+ pair
12660
+ }] : [];
12661
+ }
12662
+ return Promise.all(items.map(async (item) => {
12663
+ let modalities = [];
12664
+ try {
12665
+ modalities = (await ctx.llm.resolveModelInfo(item.pair.lead.provider, item.pair.lead.model)).inputModalities ?? [];
12666
+ } catch {
12667
+ modalities = [];
12668
+ }
12669
+ return {
12670
+ id: item.id,
12671
+ name: `Fusion · ${item.name}`,
12672
+ inputModalities: modalities
12673
+ };
12674
+ }));
12334
12675
  };
12335
- ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(leadInputModalities)));
12676
+ ctx.effect(() => ctx.llm.registerAdapter([FUSION_PROVIDER], new FusionCatalogAdapter(fusionModels)));
12336
12677
  ctx.effect(() => installNativeFusionSelection(ctx, {
12337
12678
  coordinator: () => runtime,
12338
- profile: () => defaultProfile,
12679
+ profile: profileOf,
12680
+ reason: (model) => combos?.failures.get(model) ?? (combos?.items.length ? bi("该组合已被删除或无法使用,请在模型菜单中选择其他组合。", "That combo was removed or cannot run; pick another one in the model menu.") : void 0),
12339
12681
  selection: (agent) => {
12340
12682
  const selection = agent.session.snapshotEvents().findLast((event) => event.type === "model/selection");
12341
12683
  if (selection?.type === "model/selection") return selection.data;
12342
- if (bindings.read(agent.id)?.binding.selected) return {
12684
+ const bound = bindings.read(agent.id)?.binding;
12685
+ if (bound?.selected) return {
12343
12686
  provider: FUSION_PROVIDER,
12344
- model: FUSION_MODEL
12687
+ model: modelOfProfileId(bound.profile.id) ?? "auto"
12345
12688
  };
12346
12689
  const header = agent.session.requestHeader();
12347
12690
  return header ? {
@@ -12389,38 +12732,58 @@ async function apply(ctx, config = {}) {
12389
12732
  }
12390
12733
  if (url.searchParams.get("view") === "catalog") return json(res, 200, await ctx.sessionController.modelCatalog());
12391
12734
  if (url.searchParams.get("view") === "cache") {
12392
- let profile;
12735
+ let pairs = [];
12393
12736
  try {
12394
- profile = await configuredProfile();
12737
+ pairs = [...(await knownCombos()).profiles.values()].map((profile) => ({
12738
+ lead: profile.profile.lead,
12739
+ worker: profile.profile.worker,
12740
+ cacheKeepalive: profile.profile.cacheKeepalive
12741
+ }));
12395
12742
  } catch {
12396
- profile = void 0;
12743
+ pairs = [];
12397
12744
  }
12398
12745
  return json(res, 200, {
12399
12746
  checked: CACHE_DEFAULTS_CHECKED,
12400
- models: cacheView(cachePolicy, profile ? {
12401
- lead: profile.profile.lead,
12402
- worker: profile.profile.worker
12403
- } : void 0, profile?.profile.cacheKeepalive)
12747
+ models: cacheView(cachePolicy, pairs)
12404
12748
  });
12405
12749
  }
12406
12750
  if (url.searchParams.get("view") === "settings") {
12407
- const saved = store.readDocument("settings:profile");
12408
- let profile, reason, authorized = false;
12409
- try {
12410
- profile = await configuredProfile();
12411
- if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile));
12412
- authorized = true;
12413
- } catch (error) {
12414
- reason = error instanceof Error ? error.message : String(error);
12415
- }
12751
+ const state = await resolveCombos();
12752
+ const pairs = state.items.map((item) => {
12753
+ const profile = state.profiles.get(item.id);
12754
+ if (!profile) return {
12755
+ id: item.id,
12756
+ name: item.name,
12757
+ pair: item.pair,
12758
+ authorized: false,
12759
+ reason: state.failures.get(item.id)
12760
+ };
12761
+ let authorized = false, reason;
12762
+ try {
12763
+ if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile));
12764
+ authorized = true;
12765
+ } catch (error) {
12766
+ reason = error instanceof Error ? error.message : String(error);
12767
+ }
12768
+ return {
12769
+ id: item.id,
12770
+ name: item.name,
12771
+ pair: choiceFromProfile(profile.profile),
12772
+ authorized,
12773
+ ...reason ? { reason } : {}
12774
+ };
12775
+ });
12776
+ const blocked = pairs.find((pair) => !pair.authorized);
12416
12777
  return json(res, 200, {
12417
- revision: saved?.revision ?? 0,
12778
+ revision: state.revision,
12418
12779
  catalog: await physicalCatalog(),
12419
- pair: profile ? choiceFromProfile(profile.profile) : null,
12420
- authorized,
12421
- reason,
12780
+ pairs,
12781
+ maxPairs: 6,
12782
+ pair: pairs[0]?.pair ?? null,
12783
+ authorized: pairs.length > 0 && !blocked,
12784
+ ...blocked?.reason ? { reason: blocked.reason } : {},
12422
12785
  managedAuthorization: Boolean(config.authorizationPath),
12423
- policy: profile?.profile.interactionMode ?? "legacy",
12786
+ policy: state.profiles.values().next().value?.profile.interactionMode ?? "model-like",
12424
12787
  actualBilledUsd: null,
12425
12788
  apiEquivalentUsd: null
12426
12789
  });
@@ -12432,7 +12795,7 @@ async function apply(ctx, config = {}) {
12432
12795
  const profile = binding && binding.selected ? {
12433
12796
  profile: binding.profile,
12434
12797
  prompts: binding.prompts
12435
- } : await configuredProfile();
12798
+ } : await primaryProfile();
12436
12799
  (await getRuntime(profile)).transport.assertAvailable();
12437
12800
  if (needsBudget(profile.profile)) budget.check(profileRoutes(profile.profile));
12438
12801
  available = true;
@@ -12533,10 +12896,17 @@ async function apply(ctx, config = {}) {
12533
12896
  }
12534
12897
  if (body.action === "configure") {
12535
12898
  if (!Number.isSafeInteger(body.revision) || Number(body.revision) < 0) throw new Error(bi("配置版本无效,请刷新后重试", "Invalid settings revision; refresh and retry"));
12536
- const pair = validatePairChoice(body.pair, await physicalCatalog());
12537
- const revision = store.writeDocument("settings:profile", Number(body.revision), modelProfileFromChoice(pair));
12538
- const profile = await configuredProfile();
12539
- await getRuntime(profile);
12899
+ const catalog = await physicalCatalog(), current = readPairs(store);
12900
+ const pairs = validatePairs(Array.isArray(body.pairs) ? body.pairs : [{
12901
+ ...current.pairs[0] ?? {
12902
+ id: "auto",
12903
+ name: "自动"
12904
+ },
12905
+ pair: body.pair
12906
+ }, ...current.pairs.slice(1)], catalog, current.pairs);
12907
+ const revision = writePairs(store, Number(body.revision), pairs);
12908
+ const profile = (await resolveCombos()).profiles.values().next().value;
12909
+ if (profile) await getRuntime(profile);
12540
12910
  return json(res, 200, {
12541
12911
  ok: true,
12542
12912
  revision
@@ -12544,13 +12914,12 @@ async function apply(ctx, config = {}) {
12544
12914
  }
12545
12915
  if (body.action === "authorize") {
12546
12916
  if (config.authorizationPath) throw new Error(bi("运行额度由外部授权文件管理,不能在此覆盖", "Run limits are managed by an external authorization file and cannot be overridden here"));
12547
- const saved = store.readDocument("settings:profile");
12548
- if (!saved || saved.revision !== body.revision) throw new Error(bi("模型组合已变化,请刷新后再启用额度", "The pair changed; refresh before enabling limits"));
12549
- const profile = await configuredProfile();
12550
- if (store.readDocument("settings:profile")?.revision !== body.revision) throw new Error(bi("模型组合已变化,请刷新后再启用额度", "The pair changed; refresh before enabling limits"));
12917
+ if (readPairs(store).revision !== body.revision) throw new Error(bi("组合列表已变化,请刷新后再启用额度", "The combo list changed; refresh before enabling limits"));
12918
+ const state = await resolveCombos();
12919
+ if (readPairs(store).revision !== body.revision) throw new Error(bi("组合列表已变化,请刷新后再启用额度", "The combo list changed; refresh before enabling limits"));
12551
12920
  return json(res, 200, {
12552
12921
  ok: true,
12553
- expiresAt: authorizeConfiguredPair(store, profileRoutes(profile.profile), body.limits).expiresAt
12922
+ expiresAt: authorizeConfiguredPair(store, [...state.profiles.values()].flatMap((profile) => profileRoutes(profile.profile)), body.limits).expiresAt
12554
12923
  });
12555
12924
  }
12556
12925
  throw new Error(bi("请使用原模型菜单选择 Fusion 或切换普通模型", "Use the model menu to select Fusion or another model"));
@@ -12578,7 +12947,7 @@ async function apply(ctx, config = {}) {
12578
12947
  const binding = bindings.read(invocation.agent.id)?.binding;
12579
12948
  if (!binding?.selected) return {
12580
12949
  kind: "success",
12581
- text: bi("当前会话未启用 Fusion。请在模型选择器中选择 Fusion · 自动。", "Fusion is not selected in this conversation. Choose Fusion · auto in the model menu.")
12950
+ text: bi("当前会话未启用 Fusion。请在模型选择器中选择一个 Fusion 组合。", "Fusion is not selected in this conversation. Choose a Fusion combo in the model menu.")
12582
12951
  };
12583
12952
  if (action === "status" || action === "") {
12584
12953
  const controller = await getRuntime({
@@ -12650,7 +13019,7 @@ async function apply(ctx, config = {}) {
12650
13019
  store.close();
12651
13020
  });
12652
13021
  try {
12653
- await getRuntime(await configuredProfile());
13022
+ await getRuntime(await primaryProfile());
12654
13023
  } catch {
12655
13024
  const selected = bindings.selected()[0];
12656
13025
  if (selected) await getRuntime({