@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/lib/client.js CHANGED
@@ -51,10 +51,19 @@ window.__ModuleLoader__.load({
51
51
  */
52
52
  /** The HTTP path the panel's own views and actions are served from. */
53
53
  const FUSION_API_PATH = "/api/fusion";
54
+ /**
55
+ * The model ids this provider advertises: `auto` plus one generated
56
+ * `pair-xxxxxxxx` per additional combo. The browser bundle inlines this module,
57
+ * so the namespace shape is fixed here rather than read from a settings
58
+ * document at import time.
59
+ */
60
+ const FUSION_MODEL_PATTERN = /^(?:auto|pair-[a-z0-9]{6,12})$/;
61
+ /** Whether a model id names a Fusion combo on this provider. */
62
+ const isFusionModel = (model) => typeof model === "string" && FUSION_MODEL_PATTERN.test(model);
54
63
  /** Whether a provider route belongs to this plugin, under either name. */
55
64
  const isFusionProvider = (provider) => provider === "dsh-fusion" || provider === "dsh-model-fusion";
56
- /** Whether a route names the Fusion entry, under either name. */
57
- const isFusionSelection = (route) => isFusionProvider(route?.provider) && route?.model === "auto";
65
+ /** Whether a route names a Fusion combo, under either provider spelling. */
66
+ const isFusionSelection = (route) => isFusionProvider(route?.provider) && isFusionModel(route?.model);
58
67
  //#endregion
59
68
  //#region src/client/FusionHistory.tsx
60
69
  function formatCompact(tokens) {
@@ -676,6 +685,29 @@ window.__ModuleLoader__.load({
676
685
  });
677
686
  }
678
687
  //#endregion
688
+ //#region src/host/settings.ts
689
+ /**
690
+ * The routes a saved choice still names that the current catalog no longer
691
+ * offers: a provider that was renamed or removed, or a model that was retired.
692
+ *
693
+ * Reading a saved combo tolerates a stale route so the row stays visible and
694
+ * editable, but saving one does not. Without this, the page can only say that
695
+ * *some* model is gone, which is unusable when the stale route is not the one
696
+ * being edited — the user then cannot tell which combo to fix.
697
+ */
698
+ function missingRoutes(value, catalog) {
699
+ if (!value) return [];
700
+ const stale = (role, route) => route !== void 0 && !catalog.groups.find((group) => group.id === route.provider)?.models.find((model) => model.id === route.model) ? [{
701
+ role,
702
+ route
703
+ }] : [];
704
+ return [
705
+ ...stale("lead", value.lead),
706
+ ...stale("worker", value.worker),
707
+ ...stale("compactor", value.compactor?.route)
708
+ ];
709
+ }
710
+ //#endregion
679
711
  //#region src/client/settings-events.ts
680
712
  const listeners = /* @__PURE__ */ new Set();
681
713
  const onSettingsChanged = (listener) => {
@@ -689,7 +721,7 @@ window.__ModuleLoader__.load({
689
721
  };
690
722
  //#endregion
691
723
  //#region src/client/FusionSettings.tsx
692
- const styles$1 = `
724
+ const styles$2 = `
693
725
  .fusion-page{box-sizing:border-box;width:100%;height:100%;min-width:0;min-height:0;flex:1 1 auto;overflow:auto;display:flex;flex-direction:column;align-items:center;scrollbar-gutter:stable;padding:0 clamp(24px,4vw,48px) 48px;--dsh-scrollbar-width:9px;--dsh-scrollbar-thumb-border:2px}
694
726
  .fusion-settings{box-sizing:border-box;width:100%;max-width:680px;margin:0 auto;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:18px}
695
727
  .fusion-settings h2{margin:0;font-size:22px;line-height:30px;font-weight:600}
@@ -710,8 +742,28 @@ window.__ModuleLoader__.load({
710
742
  .fusion-settings details{font-size:13px;line-height:21px}
711
743
  .fusion-settings summary{cursor:pointer;font-weight:500}
712
744
  .fusion-settings .fusion-limits{display:flex;flex-direction:column;gap:12px;padding-top:14px}
713
- .fusion-settings input[type=number]{min-width:0;width:100%;box-sizing:border-box;background-color:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:8px;font:inherit}
745
+ .fusion-settings :is(input[type=number],input[type=text]){min-width:0;width:100%;box-sizing:border-box;background-color:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:7px;padding:8px;font:inherit}
714
746
  .fusion-settings .fusion-consent{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px}
747
+ .fusion-combos{display:flex;flex-direction:column;gap:8px}
748
+ .fusion-settings .fusion-combo{display:flex;align-items:stretch;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background-color:var(--dsw-alias-bg-layer-1);font-size:13px;overflow:hidden;transition:border-color .15s ease,background-color .15s ease}
749
+ .fusion-settings .fusion-combo:hover{background-color:var(--dsw-alias-interactive-bg-hover)}
750
+ .fusion-settings .fusion-combo[data-active=true]{border-color:var(--dsw-alias-brand-primary);background-color:var(--dsw-alias-interactive-bg-hover-accent)}
751
+ .fusion-settings .fusion-combo-main{flex:1 1 auto;display:flex;align-items:center;gap:10px;min-width:0;padding:11px 12px;background:none;border:0;border-radius:0;color:inherit;font:inherit;text-align:left;cursor:pointer}
752
+ .fusion-settings .fusion-combo-main:hover{background:none}
753
+ .fusion-settings .fusion-combo :is(.fusion-combo-main,.fusion-combo-actions):focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
754
+ .fusion-settings .fusion-combo-check{position:relative;flex:none;width:14px;height:14px;border:1.5px solid var(--dsw-alias-border-l2);border-radius:50%;transition:border-color .15s ease}
755
+ .fusion-settings .fusion-combo:hover .fusion-combo-check{border-color:var(--dsw-alias-label-tertiary)}
756
+ .fusion-settings .fusion-combo[data-active=true] .fusion-combo-check{border-color:var(--dsw-alias-brand-primary)}
757
+ .fusion-settings .fusion-combo[data-active=true] .fusion-combo-check::after{content:'';position:absolute;inset:2.5px;border-radius:50%;background:var(--dsw-alias-brand-primary)}
758
+ .fusion-settings .fusion-combo-text{display:flex;flex-direction:column;gap:2px;min-width:0}
759
+ .fusion-settings .fusion-combo-name{font-weight:600;overflow-wrap:anywhere}
760
+ .fusion-settings .fusion-combo-routes{color:var(--dsw-alias-label-secondary);font-size:12px;overflow-wrap:anywhere}
761
+ .fusion-settings .fusion-combo-stale{color:var(--dsw-alias-state-error-primary);font-size:12px;overflow-wrap:anywhere}
762
+ .fusion-settings .fusion-combo-actions{flex:none;align-self:stretch;display:flex;align-items:center;padding:0 14px;border:0;border-left:1px solid var(--dsw-alias-border-l1);border-radius:0;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;transition:color .15s ease,background-color .15s ease}
763
+ .fusion-settings .fusion-combo-actions:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);background-color:var(--dsw-alias-interactive-bg-hover-danger)}
764
+ .fusion-settings .fusion-combo-actions:disabled{opacity:.4;cursor:default}
765
+ .fusion-settings .fusion-combo-add{align-self:flex-start;padding:8px 16px;border:1px solid var(--dsw-alias-border-l2);border-radius:9px;background:none;color:var(--dsw-alias-label-secondary);font-size:13px}
766
+ .fusion-settings .fusion-combo-add:hover:not(:disabled){background-color:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
715
767
  .fusion-cache{box-sizing:border-box;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-module-platform);padding:16px;display:flex;flex-direction:column;gap:12px}
716
768
  .fusion-cache h3{margin:0}
717
769
  .fusion-cache-summary{cursor:pointer;font-weight:600;font-size:14px;display:flex;align-items:center;justify-content:space-between;list-style:none;user-select:none;padding:2px 0}
@@ -773,6 +825,7 @@ window.__ModuleLoader__.load({
773
825
  const lang = useLang();
774
826
  const name = role === "compactor" ? tr(lang, "压缩", "Compaction") : role;
775
827
  const model = catalog.groups.find((group) => group.id === route?.provider)?.models.find((model) => model.id === route?.model);
828
+ const stale = route !== void 0 && model === void 0;
776
829
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", { children: [
777
830
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("legend", { children: name }),
778
831
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: role === "Lead" ? tr(lang, "回答问题、制定方案并审查结果。", "Answers, plans and reviews.") : role === "Sidekick" ? tr(lang, "执行命令和修改,接收反馈后继续。", "Runs commands and makes changes, then continues with feedback.") : tr(lang, "整理较早的上下文,供 Lead 或 Worker 继续工作。", "Summarises older context so the Lead or Sidekick can continue.") }),
@@ -790,21 +843,33 @@ window.__ModuleLoader__.load({
790
843
  model
791
844
  });
792
845
  },
793
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
794
- value: "",
795
- disabled: role !== "compactor",
796
- children: role === "compactor" ? tr(lang, "使用当前角色模型", "Use the role's own model") : tr(lang, "选择已连接的模型", "Choose a connected model")
797
- }), catalog.groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("optgroup", {
798
- label: group.name,
799
- children: group.models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
800
- value: key({
801
- provider: group.id,
802
- model: model.id
803
- }),
804
- children: model.name
805
- }, model.id))
806
- }, group.id))]
846
+ children: [
847
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
848
+ value: "",
849
+ disabled: role !== "compactor",
850
+ children: role === "compactor" ? tr(lang, "使用当前角色模型", "Use the role's own model") : tr(lang, "选择已连接的模型", "Choose a connected model")
851
+ }),
852
+ stale && route && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
853
+ value: key(route),
854
+ disabled: true,
855
+ children: tr(lang, `已不可用:${route.provider}/${route.model}`, `No longer available: ${route.provider}/${route.model}`)
856
+ }),
857
+ catalog.groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("optgroup", {
858
+ label: group.name,
859
+ children: group.models.map((model) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
860
+ value: key({
861
+ provider: group.id,
862
+ model: model.id
863
+ }),
864
+ children: model.name
865
+ }, model.id))
866
+ }, group.id))
867
+ ]
807
868
  })] }),
869
+ stale && route && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
870
+ role: "alert",
871
+ children: tr(lang, `这个角色保存的模型 ${route.provider}/${route.model} 已不可用,请重新选择。`, `This role's saved model ${route.provider}/${route.model} is no longer available; choose another.`)
872
+ }),
808
873
  model?.reasoning && route && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [tr(lang, "推理强度", "Reasoning"), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
809
874
  "aria-label": tr(lang, `${name} 推理强度`, `${name} reasoning`),
810
875
  value: route.reasoningEffort ?? "",
@@ -825,20 +890,35 @@ window.__ModuleLoader__.load({
825
890
  })] })
826
891
  ] });
827
892
  }
893
+ let draftKeys = 0;
894
+ const toDraft = (pairs) => pairs.map(({ id, name, pair }) => ({
895
+ key: id,
896
+ id,
897
+ name,
898
+ pair
899
+ }));
828
900
  function FusionSettings() {
829
901
  const lang = useLang();
830
902
  const [state, setState] = (0, react.useState)();
831
- const [draft, setDraft] = (0, react.useState)({});
903
+ const [draft, setDraft] = (0, react.useState)([]);
904
+ const [active, setActive] = (0, react.useState)(0);
832
905
  const [error, setError] = (0, react.useState)();
833
906
  const [notice, setNotice] = (0, react.useState)();
834
907
  const [saving, setSaving] = (0, react.useState)(false);
835
- const chooseModel = (role, route) => setDraft((value) => ({
836
- ...value,
837
- [role]: route,
838
- ...value.cacheKeepalive ? { cacheKeepalive: {
839
- ...value.cacheKeepalive,
840
- [role]: role === "lead" ? "auto" : false
841
- } } : {}
908
+ const current = draft[Math.min(active, draft.length - 1)];
909
+ const routeName = (route) => route ? state?.catalog.groups.find((group) => group.id === route.provider)?.models.find((model) => model.id === route.model)?.name ?? `${route.provider}/${route.model}` : "—";
910
+ const roleLabel = (role) => role === "lead" ? tr(lang, "Lead", "Lead") : role === "worker" ? tr(lang, "Sidekick", "Sidekick") : tr(lang, "压缩模型", "Compaction model");
911
+ const update = (change) => setDraft((value) => value.map((combo, index) => combo.key === current?.key ? change(combo) : combo));
912
+ const chooseModel = (role, route) => update((combo) => ({
913
+ ...combo,
914
+ pair: {
915
+ ...combo.pair,
916
+ [role]: route,
917
+ ...combo.pair.cacheKeepalive ? { cacheKeepalive: {
918
+ ...combo.pair.cacheKeepalive,
919
+ [role]: role === "lead" ? "auto" : false
920
+ } } : {}
921
+ }
842
922
  }));
843
923
  (0, react.useEffect)(() => {
844
924
  const abort = new AbortController();
@@ -851,24 +931,54 @@ window.__ModuleLoader__.load({
851
931
  return response.json();
852
932
  }).then((value) => {
853
933
  setState(value);
854
- setDraft(value.pair ?? {});
934
+ setDraft(toDraft(value.pairs));
935
+ setActive(0);
855
936
  }).catch((error) => {
856
937
  if (!abort.signal.aborted) setError(String(error));
857
938
  });
858
939
  return () => abort.abort();
859
940
  }, []);
941
+ const addCombo = () => {
942
+ draftKeys += 1;
943
+ setDraft((value) => [...value, {
944
+ key: `new-${draftKeys}`,
945
+ name: tr(lang, `组合 ${value.length + 1}`, `Combo ${value.length + 1}`),
946
+ pair: {}
947
+ }]);
948
+ setActive(draft.length);
949
+ };
950
+ const removeCombo = (index) => {
951
+ setDraft((value) => value.filter((_, position) => position !== index));
952
+ setActive(Math.max(0, Math.min(active > index ? active - 1 : active, draft.length - 2)));
953
+ };
860
954
  const save = async () => {
861
- if (!state || !draft.lead || !draft.worker) return;
955
+ if (!state) return;
956
+ const invalid = draft.findIndex((combo) => !combo.pair.lead || !combo.pair.worker || !combo.name.trim());
957
+ if (invalid >= 0) {
958
+ setError(tr(lang, `第 ${invalid + 1} 个组合还不完整:需要名称、Lead 和 Sidekick。`, `Combo ${invalid + 1} is incomplete: it needs a name, a Lead and a Sidekick.`));
959
+ return;
960
+ }
961
+ const stale = draft.flatMap((combo, index) => missingRoutes(combo.pair, state.catalog).map(({ role, route }) => tr(lang, `第 ${index + 1} 个组合「${combo.name.trim() || tr(lang, "未命名组合", "Untitled combo")}」的 ${roleLabel(role)} 模型已不可用:${route.provider}/${route.model}。请为该角色重新选择模型。`, `Combo ${index + 1} "${combo.name.trim() || "Untitled combo"}" still names a ${roleLabel(role)} model that is no longer available: ${route.provider}/${route.model}. Choose a model for that role again.`)));
962
+ if (stale.length) {
963
+ setError(stale.join(tr(lang, " ", " ")));
964
+ return;
965
+ }
862
966
  setSaving(true);
863
967
  setError(void 0);
864
968
  setNotice(void 0);
865
969
  try {
866
- const { outputTokens, ...rest } = draft;
867
- const limits = Object.fromEntries(Object.entries(outputTokens ?? {}).filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0));
868
- const pair = {
869
- ...rest,
870
- ...Object.keys(limits).length ? { outputTokens: limits } : {}
871
- };
970
+ const pairs = draft.map(({ id, name, pair }) => {
971
+ const { outputTokens, ...rest } = pair;
972
+ const limits = Object.fromEntries(Object.entries(outputTokens ?? {}).filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0));
973
+ return {
974
+ ...id ? { id } : {},
975
+ name: name.trim(),
976
+ pair: {
977
+ ...rest,
978
+ ...Object.keys(limits).length ? { outputTokens: limits } : {}
979
+ }
980
+ };
981
+ });
872
982
  const response = await fetch(FUSION_API_PATH, {
873
983
  method: "POST",
874
984
  credentials: "same-origin",
@@ -876,37 +986,49 @@ window.__ModuleLoader__.load({
876
986
  body: JSON.stringify({
877
987
  action: "configure",
878
988
  revision: state.revision,
879
- pair
989
+ pairs
880
990
  })
881
991
  });
882
992
  const result = await response.json();
883
993
  if (!response.ok) throw new Error(result.error ?? tr(lang, "保存失败,请刷新后重试", "Save failed; refresh and try again"));
884
- const current = await fetch(`${FUSION_API_PATH}?view=settings`, {
994
+ const confirmed = await fetch(`${FUSION_API_PATH}?view=settings`, {
885
995
  credentials: "same-origin",
886
996
  cache: "no-store"
887
997
  });
888
- if (!current.ok) throw new Error(tr(lang, "配置已提交,但暂时无法确认,请刷新", "Saved, but not confirmed yet; refresh"));
889
- const confirmed = await current.json();
890
- setState(confirmed);
891
- setDraft(confirmed.pair ?? {});
998
+ if (!confirmed.ok) throw new Error(tr(lang, "配置已提交,但暂时无法确认,请刷新", "Saved, but not confirmed yet; refresh"));
999
+ const next = await confirmed.json();
1000
+ setState(next);
1001
+ setDraft(toDraft(next.pairs));
1002
+ setActive(0);
892
1003
  settingsChanged();
893
- setNotice(tr(lang, "组合已保存并启用。可以在模型菜单中选择 Fusion · 自动。", "Pair saved and enabled. Choose Fusion · auto in the model menu."));
1004
+ setNotice(tr(lang, "组合已保存。模型菜单中每个组合各占一项,不同会话可以同时使用不同组合。", "Combos saved. Each one is its own model-menu entry, so different conversations can use different combos at the same time."));
894
1005
  } catch (error) {
895
1006
  setError(error instanceof Error ? error.message : String(error));
896
1007
  } finally {
897
1008
  setSaving(false);
898
1009
  }
899
1010
  };
1011
+ const dirty = state ? JSON.stringify(draft.map(({ id, name, pair }) => ({
1012
+ id,
1013
+ name,
1014
+ pair
1015
+ }))) !== JSON.stringify(state.pairs.map(({ id, name, pair }) => ({
1016
+ id,
1017
+ name,
1018
+ pair
1019
+ }))) : false;
1020
+ const blocked = state?.pairs.filter((pair) => !pair.authorized) ?? [];
900
1021
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
901
1022
  className: "fusion-page",
902
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles$1 }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1023
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles$2 }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
903
1024
  className: "fusion-settings",
904
1025
  children: [
905
1026
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
906
1027
  "data-window-drag": true,
907
1028
  children: [
908
1029
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", { children: "Fusion" }),
909
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "选择 Lead 和 Sidekick。Lead 处理日常请求、安排工作并审查结果;Sidekick 执行委派的任务。", "Choose a Lead and a Sidekick. The Lead handles requests, plans and reviews; the Sidekick does the delegated work.") }),
1030
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "每个组合是一对 Lead 和 Sidekick,会作为单独的一项出现在模型菜单里。开启多个会话时,可以为每个会话选择不同的组合。", "Each combo is a Lead and a Sidekick pair, listed as its own entry in the model menu. With several conversations open, each one can use a different combo.") }),
1031
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "Lead 处理日常请求、安排工作并审查结果;Sidekick 执行委派的任务。", "The Lead handles requests, plans and reviews; the Sidekick does the delegated work.") }),
910
1032
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
911
1033
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: tr(lang, "搭配建议", "Pairing tip") }),
912
1034
  ": ",
@@ -923,13 +1045,84 @@ window.__ModuleLoader__.load({
923
1045
  children: tr(lang, "正在读取模型…", "Loading models…")
924
1046
  }),
925
1047
  state && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
926
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
1048
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1049
+ className: "fusion-card",
1050
+ "aria-label": tr(lang, "组合列表", "Combo list"),
1051
+ children: [
1052
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1053
+ className: "fusion-combos",
1054
+ children: [draft.map((combo, index) => {
1055
+ const selected = combo.key === current?.key;
1056
+ const label = combo.name.trim() || tr(lang, "未命名组合", "Untitled combo");
1057
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1058
+ className: "fusion-combo",
1059
+ "data-active": selected,
1060
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1061
+ type: "button",
1062
+ className: "fusion-combo-main",
1063
+ "aria-pressed": selected,
1064
+ onClick: () => setActive(index),
1065
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1066
+ className: "fusion-combo-check",
1067
+ "aria-hidden": "true"
1068
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1069
+ className: "fusion-combo-text",
1070
+ children: [
1071
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1072
+ className: "fusion-combo-name",
1073
+ children: label
1074
+ }),
1075
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1076
+ className: "fusion-combo-routes",
1077
+ children: `${routeName(combo.pair.lead)} → ${routeName(combo.pair.worker)}`
1078
+ }),
1079
+ missingRoutes(combo.pair, state.catalog).length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1080
+ className: "fusion-combo-stale",
1081
+ children: tr(lang, "有模型已不可用,需要重新选择", "A model is no longer available; choose it again")
1082
+ })
1083
+ ]
1084
+ })]
1085
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1086
+ type: "button",
1087
+ className: "fusion-combo-actions",
1088
+ disabled: saving || draft.length <= 1,
1089
+ "aria-label": tr(lang, `删除组合 ${label}`, `Delete combo ${label}`),
1090
+ onClick: (event) => {
1091
+ event.stopPropagation();
1092
+ removeCombo(index);
1093
+ },
1094
+ children: tr(lang, "删除", "Delete")
1095
+ })]
1096
+ }, combo.key);
1097
+ }), !draft.length && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "还没有组合。新增一个,选择 Lead 和 Sidekick 后保存。", "No combos yet. Add one, choose a Lead and a Sidekick, then save.") })]
1098
+ }),
1099
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1100
+ type: "button",
1101
+ className: "fusion-combo-add",
1102
+ disabled: saving || draft.length >= state.maxPairs,
1103
+ onClick: addCombo,
1104
+ children: tr(lang, "+ 新增组合", "+ Add combo")
1105
+ }),
1106
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, `最多保存 ${state.maxPairs} 个组合;模型菜单中的名称是「Fusion · 组合名称」。`, `Up to ${state.maxPairs} combos; the model menu shows them as “Fusion · name”.`) })
1107
+ ]
1108
+ }),
1109
+ current && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
927
1110
  className: "fusion-card",
928
1111
  disabled: saving,
929
1112
  children: [
1113
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [tr(lang, "名称", "Name"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1114
+ type: "text",
1115
+ maxLength: 40,
1116
+ "aria-label": tr(lang, "组合名称", "Combo name"),
1117
+ value: current.name,
1118
+ onChange: (event) => update((combo) => ({
1119
+ ...combo,
1120
+ name: event.target.value
1121
+ }))
1122
+ })] }),
930
1123
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelChoice, {
931
1124
  role: "Lead",
932
- route: draft.lead,
1125
+ route: current.pair.lead,
933
1126
  catalog: state.catalog,
934
1127
  onChange: (route) => {
935
1128
  if (route) chooseModel("lead", route);
@@ -937,7 +1130,7 @@ window.__ModuleLoader__.load({
937
1130
  }),
938
1131
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelChoice, {
939
1132
  role: "Sidekick",
940
- route: draft.worker,
1133
+ route: current.pair.worker,
941
1134
  catalog: state.catalog,
942
1135
  onChange: (route) => {
943
1136
  if (route) chooseModel("worker", route);
@@ -949,37 +1142,42 @@ window.__ModuleLoader__.load({
949
1142
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "默认由当前角色模型压缩。也可以单独选择已连接的模型;请求由所选账号计费。", "By default each role compacts with its own model. You can pick another connected model; its account is billed.") }),
950
1143
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelChoice, {
951
1144
  role: "compactor",
952
- route: draft.compactor?.route,
1145
+ route: current.pair.compactor?.route,
953
1146
  catalog: state.catalog,
954
- onChange: (route) => setDraft((value) => {
955
- const { compactor, ...rest } = value;
956
- return route ? {
957
- ...rest,
958
- compactor: {
959
- route,
960
- maxOutputTokens: compactor?.maxOutputTokens ?? 8192
961
- }
962
- } : rest;
1147
+ onChange: (route) => update((combo) => {
1148
+ const { compactor, ...rest } = combo.pair;
1149
+ return {
1150
+ ...combo,
1151
+ pair: route ? {
1152
+ ...rest,
1153
+ compactor: {
1154
+ route,
1155
+ maxOutputTokens: compactor?.maxOutputTokens ?? 8192
1156
+ }
1157
+ } : rest
1158
+ };
963
1159
  })
964
1160
  }),
965
- draft.compactor && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [tr(lang, "输出 Token 上限", "Output token limit"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1161
+ current.pair.compactor && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [tr(lang, "输出 Token 上限", "Output token limit"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
966
1162
  "aria-label": tr(lang, "压缩输出 Token 上限", "Compaction output token limit"),
967
1163
  type: "number",
968
1164
  min: "1",
969
1165
  max: "128000",
970
- value: draft.compactor.maxOutputTokens,
1166
+ value: current.pair.compactor.maxOutputTokens,
971
1167
  onChange: (event) => {
972
1168
  const maxOutputTokens = Number(event.target.value);
973
- setDraft((value) => value.compactor ? {
974
- ...value,
975
- compactor: {
976
- ...value.compactor,
977
- maxOutputTokens
1169
+ update((combo) => combo.pair.compactor ? {
1170
+ ...combo,
1171
+ pair: {
1172
+ ...combo.pair,
1173
+ compactor: {
1174
+ ...combo.pair.compactor,
1175
+ maxOutputTokens
1176
+ }
978
1177
  }
979
- } : value);
1178
+ } : combo);
980
1179
  }
981
- })] }),
982
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "已选中的会话仍使用原配置。", "Conversations already using Fusion keep their setup.") })
1180
+ })] })
983
1181
  ]
984
1182
  })] })
985
1183
  ]
@@ -987,11 +1185,11 @@ window.__ModuleLoader__.load({
987
1185
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
988
1186
  className: "fusion-actions",
989
1187
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "已选中的会话保留原组合;新选择使用新组合。额度耗尽后要换当前任务的 Sidekick,用该会话的 Fusion 恢复面板,而不是只改这里。", "Conversations already using Fusion keep their pair; new selections use the new one. After a quota stop, switch this conversation's Sidekick from its Fusion recovery panel, not only here.") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
990
- disabled: saving || !draft.lead || !draft.worker || JSON.stringify(draft) === JSON.stringify(state.pair),
1188
+ disabled: saving || !dirty || !draft.length,
991
1189
  onClick: () => {
992
1190
  save();
993
1191
  },
994
- children: saving ? tr(lang, "正在保存…", "Saving…") : tr(lang, "保存组合", "Save pair")
1192
+ children: saving ? tr(lang, "正在保存…", "Saving…") : tr(lang, "保存组合", "Save combos")
995
1193
  })]
996
1194
  }),
997
1195
  notice && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -1000,10 +1198,10 @@ window.__ModuleLoader__.load({
1000
1198
  }),
1001
1199
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "输出参数跟随原生模型设置,Fusion 不额外限制请求次数或任务轮数。", "Output settings follow the native model settings; Fusion adds no request or round limits.") }),
1002
1200
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "保存即表示允许 Fusion 使用所选模型的现有账号;费用由这些账号计入,当前未提供可靠的实际账单金额。", "Saving lets Fusion use the chosen models' existing accounts; they are billed there, and no reliable billed amount is available here.") }),
1003
- state.pair && !state.authorized && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1201
+ blocked.map((pair) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1004
1202
  role: "status",
1005
- children: [tr(lang, "组合需要重新保存后才能使用", "Save the pair again before use"), state.reason ? `: ${state.reason}` : ""]
1006
- }),
1203
+ children: `${pair.name}: ${tr(lang, "尚未就绪,需要重新保存这个组合", "not ready yet; save this combo again")}${pair.reason ? `: ${pair.reason}` : ""}`
1204
+ }, pair.id)),
1007
1205
  state.managedAuthorization && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "运行额度由外部授权配置管理。", "Run limits are managed by an external authorization file.") })
1008
1206
  ] }),
1009
1207
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FusionCache, { refresh: state?.revision ?? 0 }),
@@ -1333,22 +1531,28 @@ window.__ModuleLoader__.load({
1333
1531
  document.removeEventListener("visibilitychange", own.visibility);
1334
1532
  };
1335
1533
  }
1336
- /** One in-flight read per mounted session; collapsed hints still discover new attention states. */
1337
- function useFusionStatus(sessionId, expandedTask, attentionOnly = false) {
1338
- const expandedRef = (0, react.useRef)(expandedTask), attentionRef = (0, react.useRef)(attentionOnly);
1534
+ /**
1535
+ * One in-flight read per mounted session; collapsed hints still discover new attention states.
1536
+ * `live` asks for the fast cadence whenever the task is running, for a view whose whole
1537
+ * purpose is the current stage (the role and permission of the model working now).
1538
+ */
1539
+ function useFusionStatus(sessionId, expandedTask, attentionOnly = false, live = false) {
1540
+ const expandedRef = (0, react.useRef)(expandedTask), attentionRef = (0, react.useRef)(attentionOnly), liveRef = (0, react.useRef)(live);
1339
1541
  expandedRef.current = expandedTask;
1340
1542
  attentionRef.current = attentionOnly;
1543
+ liveRef.current = live;
1341
1544
  (0, react.useEffect)(() => {
1342
1545
  if (expandedTask) entries.get(sessionId)?.visibility();
1343
1546
  }, [sessionId, expandedTask]);
1344
1547
  return (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => subscribe(sessionId, listener, () => {
1548
+ if (liveRef.current) return true;
1345
1549
  const task = entries.get(sessionId)?.snapshot?.status?.task;
1346
1550
  return expandedRef.current !== void 0 && task?.id === expandedRef.current && (!attentionRef.current || task.attention);
1347
1551
  }), [sessionId]), (0, react.useCallback)(() => entries.get(sessionId)?.snapshot, [sessionId]), () => void 0);
1348
1552
  }
1349
1553
  //#endregion
1350
1554
  //#region src/client/FusionStatus.tsx
1351
- const styles = `
1555
+ const styles$1 = `
1352
1556
  .fusion-status{margin:8px 12px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;overflow-wrap:anywhere}
1353
1557
  .fusion-status>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;gap:12px;list-style:none;border-top:1px solid var(--dsw-alias-border-l2);outline-offset:2px}
1354
1558
  .fusion-status>summary::before{content:'›';font-size:18px;flex:none}.fusion-status[open]>summary::before{content:'⌄'}
@@ -1428,7 +1632,7 @@ window.__ModuleLoader__.load({
1428
1632
  className: "fusion-status",
1429
1633
  onToggle: (event) => setExpandedTask(event.currentTarget.open ? task.id : void 0),
1430
1634
  children: [
1431
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles }),
1635
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles$1 }),
1432
1636
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", {
1433
1637
  "aria-label": tr(lang, `Fusion · ${stage},任务详情`, `Fusion · ${stage}, task details`),
1434
1638
  "data-ud-check": "fusion-status-summary",
@@ -1572,7 +1776,12 @@ window.__ModuleLoader__.load({
1572
1776
  if (!response.ok) throw new Error("unavailable");
1573
1777
  return response.json();
1574
1778
  }).then((state) => {
1575
- if (active) setMessage(!state.pair ? tr(lang, "Fusion 尚未配置模型,请前往设置 → Fusion 选择 Lead 和 Sidekick。", "Fusion has no models yet: open Settings → Fusion and choose a Lead and a Sidekick.") : !state.authorized ? tr(lang, "Fusion 当前配置未就绪,请前往设置 → Fusion 查看。", "The Fusion setup is not ready: see Settings → Fusion.") : void 0);
1779
+ if (!active) return;
1780
+ const combo = state.pairs.find((pair) => pair.id === selected?.model);
1781
+ if (!state.pairs.length) setMessage(tr(lang, "Fusion 尚未配置模型,请前往设置 → Fusion 选择 Lead 和 Sidekick。", "Fusion has no models yet: open Settings → Fusion and choose a Lead and a Sidekick."));
1782
+ else if (!combo) setMessage(tr(lang, "当前组合已被删除,请在模型菜单中选择其他组合。", "This combo was removed; pick another one in the model menu."));
1783
+ else if (!combo.authorized) setMessage(tr(lang, "Fusion 当前配置未就绪,请前往设置 → Fusion 查看。", "The Fusion setup is not ready: see Settings → Fusion."));
1784
+ else setMessage(void 0);
1576
1785
  }).catch(() => {
1577
1786
  if (active) setMessage(tr(lang, "暂时无法确认 Fusion 配置,请前往设置 → Fusion 查看。", "Cannot confirm the Fusion setup right now: see Settings → Fusion."));
1578
1787
  });
@@ -1584,7 +1793,11 @@ window.__ModuleLoader__.load({
1584
1793
  abort.abort();
1585
1794
  off();
1586
1795
  };
1587
- }, [fusion, lang]);
1796
+ }, [
1797
+ fusion,
1798
+ lang,
1799
+ selected?.model
1800
+ ]);
1588
1801
  if (!fusion) return null;
1589
1802
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [message && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1590
1803
  role: "status",
@@ -1601,6 +1814,54 @@ window.__ModuleLoader__.load({
1601
1814
  })] });
1602
1815
  }
1603
1816
  //#endregion
1817
+ //#region src/client/FusionPermission.tsx
1818
+ const styles = `
1819
+ .fusion-permission{display:inline-flex;align-items:center;gap:4px;height:28px;min-width:0;max-width:220px;padding:0 6px;border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;font-weight:500}
1820
+ .fusion-permission svg{width:14px;height:14px;flex:none}
1821
+ .fusion-permission .fusion-permission-role{flex:none;color:var(--dsw-alias-label-caption)}
1822
+ .fusion-permission .fusion-permission-mode{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1823
+ `;
1824
+ const modeOf = (lang, mode) => mode === "read-only" ? tr(lang, "仅可查看", "Read Only") : mode === "workspace-write" ? tr(lang, "工作区内修改", "Workspace Write") : tr(lang, "完全权限", "Full access");
1825
+ const iconOf = (mode) => mode === "read-only" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.PermissionIconReadOnlyRegular, {}) : mode === "workspace-write" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.PermissionIconWorkspaceWriteRegular, {}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.PermissionIconFullAccessRegular, {});
1826
+ /**
1827
+ * The permission of the model working now. The composer's native permission control
1828
+ * belongs to the conversation (Lead) session, which Fusion confines to read-only, and
1829
+ * that confinement matches no configured preset — so the control reads Custom while a
1830
+ * task runs. This additive chip names the role and its mode, so the tool row never
1831
+ * shows an unexplained Custom alone.
1832
+ */
1833
+ function FusionPermission({ sessionId, directory, load }) {
1834
+ const selected = (0, react.useSyncExternalStore)(directory.subscribe, directory.getSnapshot).current;
1835
+ const fusion = isFusionSelection(selected ?? void 0);
1836
+ const lang = useLang();
1837
+ const snapshot = useFusionStatus(sessionId, void 0, false, true);
1838
+ (0, react.useEffect)(() => {
1839
+ load();
1840
+ }, [load]);
1841
+ const permissions = fusion ? snapshot?.status?.task?.permissions : void 0;
1842
+ if (!permissions) return null;
1843
+ const role = permissions.active === "worker" ? "Sidekick" : "Lead";
1844
+ const mode = permissions.active === "worker" ? permissions.worker : permissions.lead;
1845
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1846
+ className: "fusion-permission",
1847
+ title: tr(lang, `Fusion 按角色分离权限:Lead ${modeOf(lang, permissions.lead)},Sidekick ${modeOf(lang, permissions.worker)}。输入框的权限控件属于对话会话(Lead),运行期间可能显示 Custom。`, `Fusion separates permissions by role: Lead ${modeOf(lang, permissions.lead)}, Sidekick ${modeOf(lang, permissions.worker)}. The permission control belongs to the conversation (Lead) session, so it may read Custom while a task runs.`),
1848
+ "aria-label": `${role} · ${modeOf(lang, mode)}`,
1849
+ "data-ud-check": "fusion-permission",
1850
+ children: [
1851
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles }),
1852
+ iconOf(mode),
1853
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1854
+ className: "fusion-permission-role",
1855
+ children: role
1856
+ }),
1857
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1858
+ className: "fusion-permission-mode",
1859
+ children: modeOf(lang, mode)
1860
+ })
1861
+ ]
1862
+ });
1863
+ }
1864
+ //#endregion
1604
1865
  //#region src/activity.ts
1605
1866
  /** Namespaced display identity; the unchanged native identity remains in source. */
1606
1867
  function activityCallId(activity) {
@@ -1890,21 +2151,28 @@ window.__ModuleLoader__.load({
1890
2151
  label: () => "Fusion"
1891
2152
  }, FusionPanelIcon));
1892
2153
  ctx.inject(["modelDirectories"], (scope) => {
2154
+ const sessionProps = (sessionId) => {
2155
+ const directory = scope.modelDirectories.directoryFor(sessionId);
2156
+ return {
2157
+ sessionId,
2158
+ directory: directory.store,
2159
+ load: () => {
2160
+ directory.load().catch(() => void 0);
2161
+ }
2162
+ };
2163
+ };
1893
2164
  scope.slots.inject("conversation.input.dock", () => scope.slots.register({
1894
2165
  name: "conversation.input.dock",
1895
2166
  id: "fusion-configuration",
1896
2167
  order: 20,
1897
- inject: (sessionId) => {
1898
- const directory = scope.modelDirectories.directoryFor(sessionId);
1899
- return {
1900
- sessionId,
1901
- directory: directory.store,
1902
- load: () => {
1903
- directory.load().catch(() => void 0);
1904
- }
1905
- };
1906
- }
2168
+ inject: sessionProps
1907
2169
  }, FusionHint));
2170
+ scope.slots.inject("conversation.input.left", () => scope.slots.register({
2171
+ name: "conversation.input.left",
2172
+ id: "fusion-permission",
2173
+ order: 10,
2174
+ inject: sessionProps
2175
+ }, FusionPermission));
1908
2176
  });
1909
2177
  }
1910
2178
  //#endregion