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