@sqdr2/dsh-fusion 0.3.0 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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) {
@@ -689,7 +698,7 @@ window.__ModuleLoader__.load({
689
698
  };
690
699
  //#endregion
691
700
  //#region src/client/FusionSettings.tsx
692
- const styles$1 = `
701
+ const styles$2 = `
693
702
  .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
703
  .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
704
  .fusion-settings h2{margin:0;font-size:22px;line-height:30px;font-weight:600}
@@ -710,8 +719,27 @@ window.__ModuleLoader__.load({
710
719
  .fusion-settings details{font-size:13px;line-height:21px}
711
720
  .fusion-settings summary{cursor:pointer;font-weight:500}
712
721
  .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}
722
+ .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
723
  .fusion-settings .fusion-consent{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px}
724
+ .fusion-combos{display:flex;flex-direction:column;gap:8px}
725
+ .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}
726
+ .fusion-settings .fusion-combo:hover{background-color:var(--dsw-alias-interactive-bg-hover)}
727
+ .fusion-settings .fusion-combo[data-active=true]{border-color:var(--dsw-alias-brand-primary);background-color:var(--dsw-alias-interactive-bg-hover-accent)}
728
+ .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}
729
+ .fusion-settings .fusion-combo-main:hover{background:none}
730
+ .fusion-settings .fusion-combo :is(.fusion-combo-main,.fusion-combo-actions):focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
731
+ .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}
732
+ .fusion-settings .fusion-combo:hover .fusion-combo-check{border-color:var(--dsw-alias-label-tertiary)}
733
+ .fusion-settings .fusion-combo[data-active=true] .fusion-combo-check{border-color:var(--dsw-alias-brand-primary)}
734
+ .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)}
735
+ .fusion-settings .fusion-combo-text{display:flex;flex-direction:column;gap:2px;min-width:0}
736
+ .fusion-settings .fusion-combo-name{font-weight:600;overflow-wrap:anywhere}
737
+ .fusion-settings .fusion-combo-routes{color:var(--dsw-alias-label-secondary);font-size:12px;overflow-wrap:anywhere}
738
+ .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}
739
+ .fusion-settings .fusion-combo-actions:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);background-color:var(--dsw-alias-interactive-bg-hover-danger)}
740
+ .fusion-settings .fusion-combo-actions:disabled{opacity:.4;cursor:default}
741
+ .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}
742
+ .fusion-settings .fusion-combo-add:hover:not(:disabled){background-color:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
715
743
  .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
744
  .fusion-cache h3{margin:0}
717
745
  .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}
@@ -825,20 +853,34 @@ window.__ModuleLoader__.load({
825
853
  })] })
826
854
  ] });
827
855
  }
856
+ let draftKeys = 0;
857
+ const toDraft = (pairs) => pairs.map(({ id, name, pair }) => ({
858
+ key: id,
859
+ id,
860
+ name,
861
+ pair
862
+ }));
828
863
  function FusionSettings() {
829
864
  const lang = useLang();
830
865
  const [state, setState] = (0, react.useState)();
831
- const [draft, setDraft] = (0, react.useState)({});
866
+ const [draft, setDraft] = (0, react.useState)([]);
867
+ const [active, setActive] = (0, react.useState)(0);
832
868
  const [error, setError] = (0, react.useState)();
833
869
  const [notice, setNotice] = (0, react.useState)();
834
870
  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
- } } : {}
871
+ const current = draft[Math.min(active, draft.length - 1)];
872
+ 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}` : "—";
873
+ const update = (change) => setDraft((value) => value.map((combo, index) => combo.key === current?.key ? change(combo) : combo));
874
+ const chooseModel = (role, route) => update((combo) => ({
875
+ ...combo,
876
+ pair: {
877
+ ...combo.pair,
878
+ [role]: route,
879
+ ...combo.pair.cacheKeepalive ? { cacheKeepalive: {
880
+ ...combo.pair.cacheKeepalive,
881
+ [role]: role === "lead" ? "auto" : false
882
+ } } : {}
883
+ }
842
884
  }));
843
885
  (0, react.useEffect)(() => {
844
886
  const abort = new AbortController();
@@ -851,24 +893,49 @@ window.__ModuleLoader__.load({
851
893
  return response.json();
852
894
  }).then((value) => {
853
895
  setState(value);
854
- setDraft(value.pair ?? {});
896
+ setDraft(toDraft(value.pairs));
897
+ setActive(0);
855
898
  }).catch((error) => {
856
899
  if (!abort.signal.aborted) setError(String(error));
857
900
  });
858
901
  return () => abort.abort();
859
902
  }, []);
903
+ const addCombo = () => {
904
+ draftKeys += 1;
905
+ setDraft((value) => [...value, {
906
+ key: `new-${draftKeys}`,
907
+ name: tr(lang, `组合 ${value.length + 1}`, `Combo ${value.length + 1}`),
908
+ pair: {}
909
+ }]);
910
+ setActive(draft.length);
911
+ };
912
+ const removeCombo = (index) => {
913
+ setDraft((value) => value.filter((_, position) => position !== index));
914
+ setActive(Math.max(0, Math.min(active > index ? active - 1 : active, draft.length - 2)));
915
+ };
860
916
  const save = async () => {
861
- if (!state || !draft.lead || !draft.worker) return;
917
+ if (!state) return;
918
+ const invalid = draft.findIndex((combo) => !combo.pair.lead || !combo.pair.worker || !combo.name.trim());
919
+ if (invalid >= 0) {
920
+ setError(tr(lang, `第 ${invalid + 1} 个组合还不完整:需要名称、Lead 和 Sidekick。`, `Combo ${invalid + 1} is incomplete: it needs a name, a Lead and a Sidekick.`));
921
+ return;
922
+ }
862
923
  setSaving(true);
863
924
  setError(void 0);
864
925
  setNotice(void 0);
865
926
  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
- };
927
+ const pairs = draft.map(({ id, name, pair }) => {
928
+ const { outputTokens, ...rest } = pair;
929
+ const limits = Object.fromEntries(Object.entries(outputTokens ?? {}).filter(([, tokens]) => Number.isSafeInteger(tokens) && Number(tokens) > 0));
930
+ return {
931
+ ...id ? { id } : {},
932
+ name: name.trim(),
933
+ pair: {
934
+ ...rest,
935
+ ...Object.keys(limits).length ? { outputTokens: limits } : {}
936
+ }
937
+ };
938
+ });
872
939
  const response = await fetch(FUSION_API_PATH, {
873
940
  method: "POST",
874
941
  credentials: "same-origin",
@@ -876,37 +943,49 @@ window.__ModuleLoader__.load({
876
943
  body: JSON.stringify({
877
944
  action: "configure",
878
945
  revision: state.revision,
879
- pair
946
+ pairs
880
947
  })
881
948
  });
882
949
  const result = await response.json();
883
950
  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`, {
951
+ const confirmed = await fetch(`${FUSION_API_PATH}?view=settings`, {
885
952
  credentials: "same-origin",
886
953
  cache: "no-store"
887
954
  });
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 ?? {});
955
+ if (!confirmed.ok) throw new Error(tr(lang, "配置已提交,但暂时无法确认,请刷新", "Saved, but not confirmed yet; refresh"));
956
+ const next = await confirmed.json();
957
+ setState(next);
958
+ setDraft(toDraft(next.pairs));
959
+ setActive(0);
892
960
  settingsChanged();
893
- setNotice(tr(lang, "组合已保存并启用。可以在模型菜单中选择 Fusion · 自动。", "Pair saved and enabled. Choose Fusion · auto in the model menu."));
961
+ 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
962
  } catch (error) {
895
963
  setError(error instanceof Error ? error.message : String(error));
896
964
  } finally {
897
965
  setSaving(false);
898
966
  }
899
967
  };
968
+ const dirty = state ? JSON.stringify(draft.map(({ id, name, pair }) => ({
969
+ id,
970
+ name,
971
+ pair
972
+ }))) !== JSON.stringify(state.pairs.map(({ id, name, pair }) => ({
973
+ id,
974
+ name,
975
+ pair
976
+ }))) : false;
977
+ const blocked = state?.pairs.filter((pair) => !pair.authorized) ?? [];
900
978
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
901
979
  className: "fusion-page",
902
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles$1 }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
980
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles$2 }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
903
981
  className: "fusion-settings",
904
982
  children: [
905
983
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
906
984
  "data-window-drag": true,
907
985
  children: [
908
986
  /* @__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.") }),
987
+ /* @__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.") }),
988
+ /* @__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
989
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", { children: [
911
990
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("b", { children: tr(lang, "搭配建议", "Pairing tip") }),
912
991
  ": ",
@@ -923,13 +1002,77 @@ window.__ModuleLoader__.load({
923
1002
  children: tr(lang, "正在读取模型…", "Loading models…")
924
1003
  }),
925
1004
  state && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
926
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
1005
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1006
+ className: "fusion-card",
1007
+ "aria-label": tr(lang, "组合列表", "Combo list"),
1008
+ children: [
1009
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1010
+ className: "fusion-combos",
1011
+ children: [draft.map((combo, index) => {
1012
+ const selected = combo.key === current?.key;
1013
+ const label = combo.name.trim() || tr(lang, "未命名组合", "Untitled combo");
1014
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1015
+ className: "fusion-combo",
1016
+ "data-active": selected,
1017
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1018
+ type: "button",
1019
+ className: "fusion-combo-main",
1020
+ "aria-pressed": selected,
1021
+ onClick: () => setActive(index),
1022
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1023
+ className: "fusion-combo-check",
1024
+ "aria-hidden": "true"
1025
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1026
+ className: "fusion-combo-text",
1027
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1028
+ className: "fusion-combo-name",
1029
+ children: label
1030
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1031
+ className: "fusion-combo-routes",
1032
+ children: `${routeName(combo.pair.lead)} → ${routeName(combo.pair.worker)}`
1033
+ })]
1034
+ })]
1035
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1036
+ type: "button",
1037
+ className: "fusion-combo-actions",
1038
+ disabled: saving || draft.length <= 1,
1039
+ "aria-label": tr(lang, `删除组合 ${label}`, `Delete combo ${label}`),
1040
+ onClick: (event) => {
1041
+ event.stopPropagation();
1042
+ removeCombo(index);
1043
+ },
1044
+ children: tr(lang, "删除", "Delete")
1045
+ })]
1046
+ }, combo.key);
1047
+ }), !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.") })]
1048
+ }),
1049
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1050
+ type: "button",
1051
+ className: "fusion-combo-add",
1052
+ disabled: saving || draft.length >= state.maxPairs,
1053
+ onClick: addCombo,
1054
+ children: tr(lang, "+ 新增组合", "+ Add combo")
1055
+ }),
1056
+ /* @__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”.`) })
1057
+ ]
1058
+ }),
1059
+ current && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("fieldset", {
927
1060
  className: "fusion-card",
928
1061
  disabled: saving,
929
1062
  children: [
1063
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [tr(lang, "名称", "Name"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1064
+ type: "text",
1065
+ maxLength: 40,
1066
+ "aria-label": tr(lang, "组合名称", "Combo name"),
1067
+ value: current.name,
1068
+ onChange: (event) => update((combo) => ({
1069
+ ...combo,
1070
+ name: event.target.value
1071
+ }))
1072
+ })] }),
930
1073
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelChoice, {
931
1074
  role: "Lead",
932
- route: draft.lead,
1075
+ route: current.pair.lead,
933
1076
  catalog: state.catalog,
934
1077
  onChange: (route) => {
935
1078
  if (route) chooseModel("lead", route);
@@ -937,7 +1080,7 @@ window.__ModuleLoader__.load({
937
1080
  }),
938
1081
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelChoice, {
939
1082
  role: "Sidekick",
940
- route: draft.worker,
1083
+ route: current.pair.worker,
941
1084
  catalog: state.catalog,
942
1085
  onChange: (route) => {
943
1086
  if (route) chooseModel("worker", route);
@@ -949,37 +1092,42 @@ window.__ModuleLoader__.load({
949
1092
  /* @__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
1093
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ModelChoice, {
951
1094
  role: "compactor",
952
- route: draft.compactor?.route,
1095
+ route: current.pair.compactor?.route,
953
1096
  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;
1097
+ onChange: (route) => update((combo) => {
1098
+ const { compactor, ...rest } = combo.pair;
1099
+ return {
1100
+ ...combo,
1101
+ pair: route ? {
1102
+ ...rest,
1103
+ compactor: {
1104
+ route,
1105
+ maxOutputTokens: compactor?.maxOutputTokens ?? 8192
1106
+ }
1107
+ } : rest
1108
+ };
963
1109
  })
964
1110
  }),
965
- draft.compactor && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", { children: [tr(lang, "输出 Token 上限", "Output token limit"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1111
+ 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
1112
  "aria-label": tr(lang, "压缩输出 Token 上限", "Compaction output token limit"),
967
1113
  type: "number",
968
1114
  min: "1",
969
1115
  max: "128000",
970
- value: draft.compactor.maxOutputTokens,
1116
+ value: current.pair.compactor.maxOutputTokens,
971
1117
  onChange: (event) => {
972
1118
  const maxOutputTokens = Number(event.target.value);
973
- setDraft((value) => value.compactor ? {
974
- ...value,
975
- compactor: {
976
- ...value.compactor,
977
- maxOutputTokens
1119
+ update((combo) => combo.pair.compactor ? {
1120
+ ...combo,
1121
+ pair: {
1122
+ ...combo.pair,
1123
+ compactor: {
1124
+ ...combo.pair.compactor,
1125
+ maxOutputTokens
1126
+ }
978
1127
  }
979
- } : value);
1128
+ } : combo);
980
1129
  }
981
- })] }),
982
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "已选中的会话仍使用原配置。", "Conversations already using Fusion keep their setup.") })
1130
+ })] })
983
1131
  ]
984
1132
  })] })
985
1133
  ]
@@ -987,11 +1135,11 @@ window.__ModuleLoader__.load({
987
1135
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
988
1136
  className: "fusion-actions",
989
1137
  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),
1138
+ disabled: saving || !dirty || !draft.length,
991
1139
  onClick: () => {
992
1140
  save();
993
1141
  },
994
- children: saving ? tr(lang, "正在保存…", "Saving…") : tr(lang, "保存组合", "Save pair")
1142
+ children: saving ? tr(lang, "正在保存…", "Saving…") : tr(lang, "保存组合", "Save combos")
995
1143
  })]
996
1144
  }),
997
1145
  notice && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
@@ -1000,10 +1148,10 @@ window.__ModuleLoader__.load({
1000
1148
  }),
1001
1149
  /* @__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
1150
  /* @__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", {
1151
+ blocked.map((pair) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1004
1152
  role: "status",
1005
- children: [tr(lang, "组合需要重新保存后才能使用", "Save the pair again before use"), state.reason ? `: ${state.reason}` : ""]
1006
- }),
1153
+ children: `${pair.name}: ${tr(lang, "尚未就绪,需要重新保存这个组合", "not ready yet; save this combo again")}${pair.reason ? `: ${pair.reason}` : ""}`
1154
+ }, pair.id)),
1007
1155
  state.managedAuthorization && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: tr(lang, "运行额度由外部授权配置管理。", "Run limits are managed by an external authorization file.") })
1008
1156
  ] }),
1009
1157
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FusionCache, { refresh: state?.revision ?? 0 }),
@@ -1247,7 +1395,7 @@ window.__ModuleLoader__.load({
1247
1395
  //#region src/client/status-polling.ts
1248
1396
  const entries = /* @__PURE__ */ new Map();
1249
1397
  const visible = () => document.visibilityState !== "hidden";
1250
- const running = new Set([
1398
+ const running = /* @__PURE__ */ new Set([
1251
1399
  "分析",
1252
1400
  "执行",
1253
1401
  "修改",
@@ -1333,22 +1481,28 @@ window.__ModuleLoader__.load({
1333
1481
  document.removeEventListener("visibilitychange", own.visibility);
1334
1482
  };
1335
1483
  }
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);
1484
+ /**
1485
+ * One in-flight read per mounted session; collapsed hints still discover new attention states.
1486
+ * `live` asks for the fast cadence whenever the task is running, for a view whose whole
1487
+ * purpose is the current stage (the role and permission of the model working now).
1488
+ */
1489
+ function useFusionStatus(sessionId, expandedTask, attentionOnly = false, live = false) {
1490
+ const expandedRef = (0, react.useRef)(expandedTask), attentionRef = (0, react.useRef)(attentionOnly), liveRef = (0, react.useRef)(live);
1339
1491
  expandedRef.current = expandedTask;
1340
1492
  attentionRef.current = attentionOnly;
1493
+ liveRef.current = live;
1341
1494
  (0, react.useEffect)(() => {
1342
1495
  if (expandedTask) entries.get(sessionId)?.visibility();
1343
1496
  }, [sessionId, expandedTask]);
1344
1497
  return (0, react.useSyncExternalStore)((0, react.useCallback)((listener) => subscribe(sessionId, listener, () => {
1498
+ if (liveRef.current) return true;
1345
1499
  const task = entries.get(sessionId)?.snapshot?.status?.task;
1346
1500
  return expandedRef.current !== void 0 && task?.id === expandedRef.current && (!attentionRef.current || task.attention);
1347
1501
  }), [sessionId]), (0, react.useCallback)(() => entries.get(sessionId)?.snapshot, [sessionId]), () => void 0);
1348
1502
  }
1349
1503
  //#endregion
1350
1504
  //#region src/client/FusionStatus.tsx
1351
- const styles = `
1505
+ const styles$1 = `
1352
1506
  .fusion-status{margin:8px 12px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5;overflow-wrap:anywhere}
1353
1507
  .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
1508
  .fusion-status>summary::before{content:'›';font-size:18px;flex:none}.fusion-status[open]>summary::before{content:'⌄'}
@@ -1428,7 +1582,7 @@ window.__ModuleLoader__.load({
1428
1582
  className: "fusion-status",
1429
1583
  onToggle: (event) => setExpandedTask(event.currentTarget.open ? task.id : void 0),
1430
1584
  children: [
1431
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles }),
1585
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles$1 }),
1432
1586
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("summary", {
1433
1587
  "aria-label": tr(lang, `Fusion · ${stage},任务详情`, `Fusion · ${stage}, task details`),
1434
1588
  "data-ud-check": "fusion-status-summary",
@@ -1572,7 +1726,12 @@ window.__ModuleLoader__.load({
1572
1726
  if (!response.ok) throw new Error("unavailable");
1573
1727
  return response.json();
1574
1728
  }).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);
1729
+ if (!active) return;
1730
+ const combo = state.pairs.find((pair) => pair.id === selected?.model);
1731
+ 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."));
1732
+ else if (!combo) setMessage(tr(lang, "当前组合已被删除,请在模型菜单中选择其他组合。", "This combo was removed; pick another one in the model menu."));
1733
+ else if (!combo.authorized) setMessage(tr(lang, "Fusion 当前配置未就绪,请前往设置 → Fusion 查看。", "The Fusion setup is not ready: see Settings → Fusion."));
1734
+ else setMessage(void 0);
1576
1735
  }).catch(() => {
1577
1736
  if (active) setMessage(tr(lang, "暂时无法确认 Fusion 配置,请前往设置 → Fusion 查看。", "Cannot confirm the Fusion setup right now: see Settings → Fusion."));
1578
1737
  });
@@ -1584,7 +1743,11 @@ window.__ModuleLoader__.load({
1584
1743
  abort.abort();
1585
1744
  off();
1586
1745
  };
1587
- }, [fusion, lang]);
1746
+ }, [
1747
+ fusion,
1748
+ lang,
1749
+ selected?.model
1750
+ ]);
1588
1751
  if (!fusion) return null;
1589
1752
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [message && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1590
1753
  role: "status",
@@ -1601,6 +1764,54 @@ window.__ModuleLoader__.load({
1601
1764
  })] });
1602
1765
  }
1603
1766
  //#endregion
1767
+ //#region src/client/FusionPermission.tsx
1768
+ const styles = `
1769
+ .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}
1770
+ .fusion-permission svg{width:14px;height:14px;flex:none}
1771
+ .fusion-permission .fusion-permission-role{flex:none;color:var(--dsw-alias-label-caption)}
1772
+ .fusion-permission .fusion-permission-mode{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1773
+ `;
1774
+ const modeOf = (lang, mode) => mode === "read-only" ? tr(lang, "仅可查看", "Read Only") : mode === "workspace-write" ? tr(lang, "工作区内修改", "Workspace Write") : tr(lang, "完全权限", "Full access");
1775
+ 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, {});
1776
+ /**
1777
+ * The permission of the model working now. The composer's native permission control
1778
+ * belongs to the conversation (Lead) session, which Fusion confines to read-only, and
1779
+ * that confinement matches no configured preset — so the control reads Custom while a
1780
+ * task runs. This additive chip names the role and its mode, so the tool row never
1781
+ * shows an unexplained Custom alone.
1782
+ */
1783
+ function FusionPermission({ sessionId, directory, load }) {
1784
+ const selected = (0, react.useSyncExternalStore)(directory.subscribe, directory.getSnapshot).current;
1785
+ const fusion = isFusionSelection(selected ?? void 0);
1786
+ const lang = useLang();
1787
+ const snapshot = useFusionStatus(sessionId, void 0, false, true);
1788
+ (0, react.useEffect)(() => {
1789
+ load();
1790
+ }, [load]);
1791
+ const permissions = fusion ? snapshot?.status?.task?.permissions : void 0;
1792
+ if (!permissions) return null;
1793
+ const role = permissions.active === "worker" ? "Sidekick" : "Lead";
1794
+ const mode = permissions.active === "worker" ? permissions.worker : permissions.lead;
1795
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1796
+ className: "fusion-permission",
1797
+ 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.`),
1798
+ "aria-label": `${role} · ${modeOf(lang, mode)}`,
1799
+ "data-ud-check": "fusion-permission",
1800
+ children: [
1801
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: styles }),
1802
+ iconOf(mode),
1803
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1804
+ className: "fusion-permission-role",
1805
+ children: role
1806
+ }),
1807
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1808
+ className: "fusion-permission-mode",
1809
+ children: modeOf(lang, mode)
1810
+ })
1811
+ ]
1812
+ });
1813
+ }
1814
+ //#endregion
1604
1815
  //#region src/activity.ts
1605
1816
  /** Namespaced display identity; the unchanged native identity remains in source. */
1606
1817
  function activityCallId(activity) {
@@ -1890,21 +2101,28 @@ window.__ModuleLoader__.load({
1890
2101
  label: () => "Fusion"
1891
2102
  }, FusionPanelIcon));
1892
2103
  ctx.inject(["modelDirectories"], (scope) => {
2104
+ const sessionProps = (sessionId) => {
2105
+ const directory = scope.modelDirectories.directoryFor(sessionId);
2106
+ return {
2107
+ sessionId,
2108
+ directory: directory.store,
2109
+ load: () => {
2110
+ directory.load().catch(() => void 0);
2111
+ }
2112
+ };
2113
+ };
1893
2114
  scope.slots.inject("conversation.input.dock", () => scope.slots.register({
1894
2115
  name: "conversation.input.dock",
1895
2116
  id: "fusion-configuration",
1896
2117
  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
- }
2118
+ inject: sessionProps
1907
2119
  }, FusionHint));
2120
+ scope.slots.inject("conversation.input.left", () => scope.slots.register({
2121
+ name: "conversation.input.left",
2122
+ id: "fusion-permission",
2123
+ order: 10,
2124
+ inject: sessionProps
2125
+ }, FusionPermission));
1908
2126
  });
1909
2127
  }
1910
2128
  //#endregion