dsh-monkeyai-sessions 0.1.13 → 0.1.15

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/README.md CHANGED
@@ -61,7 +61,7 @@ From a checkout:
61
61
  npm install
62
62
  npm run build
63
63
  npm pack
64
- dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.13.tgz
64
+ dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.15.tgz
65
65
  ```
66
66
 
67
67
  Verified against a real DSH `0.2.2-rc.1` web profile and a live MonkeyAI runtime: authorization, full history loading, prompt delivery, model switching, and turn cancellation all work end to end.
package/README.zh.md CHANGED
@@ -28,7 +28,7 @@ https://artenx.github.io/dsh-monkeyai-sessions/index.json
28
28
  npm install
29
29
  npm run build
30
30
  npm pack
31
- dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.13.tgz
31
+ dsh plugin --profile web add ./dsh-monkeyai-sessions-0.1.15.tgz
32
32
  ```
33
33
 
34
34
  已在真实的 DSH `0.2.2-rc.1` web profile 与在线 MonkeyAI runtime 上端到端验证:授权、完整历史加载、指令下发、模型切换、回合取消均正常工作。
package/client.js CHANGED
@@ -586,7 +586,18 @@ window.__ModuleLoader__.load({
586
586
  composer: { display: "flex", gap: 8, alignItems: "flex-end" },
587
587
  textarea: { width: "100%", minHeight: 56, maxHeight: 200, boxSizing: "border-box", padding: "8px 11px", borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, resize: "vertical", outline: "none", fontSize: 13, lineHeight: 1.5, transition: "border-color .15s, box-shadow .15s" },
588
588
  details: { fontSize: 12, color: t.text2 },
589
- summary: { cursor: "pointer", userSelect: "none", fontSize: 12, color: t.text2 }
589
+ summary: { cursor: "pointer", userSelect: "none", fontSize: 12, color: t.text2 },
590
+ modelPicker: { position: "relative", flex: "0 1 200px", minWidth: 132, maxWidth: "100%" },
591
+ modelPickerSummary: { display: "flex", alignItems: "center", gap: 6, width: "100%", boxSizing: "border-box", padding: "7px 10px", borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, outline: "none", fontSize: 13, cursor: "pointer", listStyle: "none", userSelect: "none" },
592
+ modelPickerCaret: { flexShrink: 0, color: t.text3, fontSize: 9 },
593
+ modelMenu: { position: "absolute", right: 0, top: "calc(100% + 6px)", zIndex: 40, width: "max(100%, 264px)", maxWidth: "94vw", boxSizing: "border-box", display: "grid", gap: 6, padding: 8, borderRadius: 10, border: `1px solid ${t.border}`, background: t.surface, boxShadow: "0 10px 30px rgba(16,24,40,0.16)" },
594
+ modelMenuList: { display: "grid", gap: 2, maxHeight: "52vh", overflowY: "auto", overscrollBehavior: "contain" },
595
+ modelItem: { display: "flex", alignItems: "baseline", gap: 8, width: "100%", boxSizing: "border-box", padding: "7px 9px", borderRadius: 7, border: "1px solid transparent", background: "transparent", color: t.text, cursor: "pointer", fontSize: 12.5, textAlign: "left", transition: "background .12s" },
596
+ modelItemActive: { background: t.accentSoft, borderColor: t.border, fontWeight: 650 },
597
+ modelItemName: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", textAlign: "left" },
598
+ modelItemMeta: { flexShrink: 0, fontSize: 11, color: t.text3 },
599
+ modelSearch: { width: "100%", boxSizing: "border-box", padding: "6px 9px", borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, outline: "none", fontSize: 12.5 },
600
+ modelEmpty: { fontSize: 12, color: t.text3, textAlign: "center", padding: "10px 0" }
590
601
  };
591
602
  var PANEL_CSS = `
592
603
  .mai-panel input:focus, .mai-panel textarea:focus, .mai-panel select:focus { border-color: #3568e8 !important; box-shadow: 0 0 0 2px rgba(53,104,232,0.14) !important; }
@@ -603,6 +614,10 @@ window.__ModuleLoader__.load({
603
614
  .mai-panel .mai-card-hover { transition: border-color .15s, background .15s; }
604
615
  .mai-panel .mai-card-hover:not(:disabled):hover { border-color: #b9cdf3 !important; background: #f7faff !important; }
605
616
  .mai-panel .mai-toolbar { position: relative; }
617
+ .mai-panel .mai-model-picker > summary::-webkit-details-marker { display: none; }
618
+ .mai-panel .mai-model-picker > summary::before { content: none; }
619
+ .mai-panel .mai-model-picker-menu { max-height: 52vh; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
620
+ .mai-panel .mai-model-picker[open] .mai-model-picker-caret { transform: rotate(180deg); }
606
621
  .mai-panel .mai-workspace { display: grid; grid-template-columns: minmax(248px, 292px) minmax(0, 1fr); gap: 12px; min-width: 0; align-items: stretch; }
607
622
  .mai-panel .mai-session-sidebar { min-height: min(70vh, 720px); }
608
623
  .mai-panel .mai-session-list { scrollbar-gutter: stable; }
@@ -624,6 +639,9 @@ window.__ModuleLoader__.load({
624
639
  .mai-panel .mai-page-title { font-size: 16px !important; }
625
640
  .mai-panel .mai-toolbar-controls { align-items: stretch; }
626
641
  .mai-panel .mai-toolbar-controls select { flex-basis: 100% !important; }
642
+ .mai-panel .mai-model-picker { flex: 1 1 100% !important; min-width: 0 !important; }
643
+ .mai-panel .mai-model-menu { position: fixed !important; inset: 50% 12px auto !important; transform: translateY(-50%); width: auto !important; max-width: none !important; max-height: 70vh !important; }
644
+ .mai-panel .mai-model-picker-menu { max-height: calc(70vh - 80px) !important; }
627
645
  .mai-panel .mai-session-list { grid-template-columns: 1fr !important; max-height: 210px; }
628
646
  .mai-panel .mai-thread { height: 68vh; min-height: 360px; padding: 10px; }
629
647
  .mai-panel .mai-thread-header { gap: 6px !important; }
@@ -657,7 +675,7 @@ window.__ModuleLoader__.load({
657
675
  });
658
676
  }
659
677
  function isRemoteStreamCarrierError(error) {
660
- return /api gateway: remote stream websocket (?:failed|closed)/i.test(errorText(error));
678
+ return /api gateway: remote stream websocket (?:failed|closed)/i.test(errorText(error)) || /remote stream socket closed/i.test(errorText(error));
661
679
  }
662
680
  function renderMarkdown(text) {
663
681
  if (typeof text !== "string" || text === "") return null;
@@ -876,6 +894,7 @@ window.__ModuleLoader__.load({
876
894
  const [sessionStatus, setSessionStatus] = useState("");
877
895
  const [signInEmail, setSignInEmail] = useState("");
878
896
  const [signInPassword, setSignInPassword] = useState("");
897
+ const [modelFilter, setModelFilter] = useState("");
879
898
  const streamRef = useRef(null);
880
899
  const historyLoadedRef = useRef(false);
881
900
  const lastDraftRef = useRef("");
@@ -883,6 +902,16 @@ window.__ModuleLoader__.load({
883
902
  const eventsRef = useRef(null);
884
903
  const prevScrollHeightRef = useRef(null);
885
904
  const streamGenerationRef = useRef(0);
905
+ const modelPickerRef = useRef(null);
906
+ useEffect(() => {
907
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return void 0;
908
+ const onDocClick = (event) => {
909
+ const picker = modelPickerRef.current;
910
+ if (picker && picker.open && picker.contains && !picker.contains(event.target)) picker.removeAttribute("open");
911
+ };
912
+ document.addEventListener("click", onDocClick);
913
+ return () => document.removeEventListener("click", onDocClick);
914
+ }, []);
886
915
  const title = useMemo(() => {
887
916
  const runtime = runtimeSessions.find((item) => item.id === sessionId);
888
917
  const summary = summarySessions.find((item) => item.id === sessionId);
@@ -1129,6 +1158,7 @@ window.__ModuleLoader__.load({
1129
1158
  setError("");
1130
1159
  setAnswerText("");
1131
1160
  setCurrentModel("");
1161
+ setModelFilter("");
1132
1162
  setVisibleCount(RECENT_LIMIT);
1133
1163
  setHistoryLoading(true);
1134
1164
  setStatus("\u6B63\u5728\u8FDE\u63A5\u8FDC\u7A0B\u4F1A\u8BDD");
@@ -1152,7 +1182,7 @@ window.__ModuleLoader__.load({
1152
1182
  setStatus("\u5386\u53F2\u5DF2\u52A0\u8F7D");
1153
1183
  setHistoryLoading(false);
1154
1184
  setSessionStatus((current) => current || "idle");
1155
- if (frame.model) setCurrentModel(frame.model);
1185
+ if (frame.data?.model) setCurrentModel(frame.data.model);
1156
1186
  }
1157
1187
  if (frame.type === "history_incomplete") {
1158
1188
  setStatus("\u5386\u53F2\u4E0D\u5B8C\u6574\uFF1A" + (frame.message || "\u56DE\u653E\u88AB\u4E2D\u65AD"));
@@ -1176,6 +1206,9 @@ window.__ModuleLoader__.load({
1176
1206
  if (frame.type === "request") {
1177
1207
  setPendingRequests((current) => [...current.filter((item) => item.requestId !== frame.requestId), frame]);
1178
1208
  }
1209
+ if (frame.type === "notification" && frame.method === "_monkeyai/session/model") {
1210
+ if (typeof frame.data?.model === "string" && frame.data.model !== "") setCurrentModel(frame.data.model);
1211
+ }
1179
1212
  if (frame.type === "notification" && frame.method === "_monkeyai/session/state") {
1180
1213
  const status2 = frame.data?.state?.status;
1181
1214
  if (typeof status2 === "string" && SESSION_STATUS_LABEL[status2]) {
@@ -1808,21 +1841,76 @@ window.__ModuleLoader__.load({
1808
1841
  sessionStatus === "running" ? h("span", { className: "mai-pulse", style: { ...style.dot, background: t.accent } }) : null,
1809
1842
  SESSION_STATUS_LABEL[sessionStatus]
1810
1843
  ) : null,
1811
- h(
1812
- "select",
1813
- {
1814
- style: { ...style.input, flex: "0 1 190px", minWidth: 130 },
1815
- value: currentModel,
1816
- disabled: !hasControl || switchingModel || models.length === 0,
1817
- title: "\u5207\u6362\u8BE5\u4F1A\u8BDD\u7684\u6A21\u578B",
1818
- onChange: (event) => {
1819
- const next = event.target.value;
1820
- if (next && next !== currentModel) switchSessionModel(next);
1821
- }
1822
- },
1823
- currentModel && !models.some((m) => m.id === currentModel) ? h("option", { value: currentModel }, currentModel.slice(0, 24)) : null,
1824
- models.map((m) => h("option", { key: m.id, value: m.id }, `${m.displayName} (${Math.round(m.contextWindowTokens / 1e3)}k)`))
1825
- )
1844
+ (() => {
1845
+ const modelPickerDisabled = !hasControl || switchingModel || models.length === 0;
1846
+ const currentModelInfo = models.find((m) => m.id === currentModel);
1847
+ const currentModelUnknown = Boolean(currentModel && !currentModelInfo);
1848
+ const needle = modelFilter.trim().toLowerCase();
1849
+ const filteredModels = needle === "" ? models : models.filter((m) => `${m.displayName} ${m.model ?? ""} ${m.id}`.toLowerCase().includes(needle));
1850
+ return h(
1851
+ "details",
1852
+ {
1853
+ className: "mai-model-picker",
1854
+ style: { ...style.modelPicker, ...modelPickerDisabled ? { opacity: 0.5, pointerEvents: "none" } : {} },
1855
+ ref: modelPickerRef
1856
+ },
1857
+ h(
1858
+ "summary",
1859
+ { style: style.modelPickerSummary, title: "\u5207\u6362\u8BE5\u4F1A\u8BDD\u7684\u6A21\u578B" },
1860
+ h("span", { className: "mai-model-picker-caret", style: { ...style.modelPickerCaret, flexShrink: 0 } }, "\u25BE"),
1861
+ h(
1862
+ "span",
1863
+ { style: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", textAlign: "left" } },
1864
+ currentModelInfo ? `${currentModelInfo.displayName} (${Math.round(currentModelInfo.contextWindowTokens / 1e3)}k)` : currentModel ? currentModel.slice(0, 24) : "\u4F1A\u8BDD\u6A21\u578B"
1865
+ )
1866
+ ),
1867
+ h(
1868
+ "div",
1869
+ { className: "mai-model-menu", style: style.modelMenu },
1870
+ models.length > 6 ? h("input", {
1871
+ type: "search",
1872
+ placeholder: "\u641C\u7D22\u6A21\u578B\u2026",
1873
+ "aria-label": "\u641C\u7D22\u6A21\u578B",
1874
+ value: modelFilter,
1875
+ style: style.modelSearch,
1876
+ onChange: (event) => setModelFilter(event.target.value)
1877
+ }) : null,
1878
+ h(
1879
+ "div",
1880
+ { className: "mai-model-picker-menu", style: style.modelMenuList },
1881
+ currentModelUnknown && (needle === "" || currentModel.toLowerCase().includes(needle)) ? h(
1882
+ "button",
1883
+ {
1884
+ type: "button",
1885
+ disabled: true,
1886
+ "aria-pressed": true,
1887
+ style: { ...style.modelItem, ...style.modelItemActive }
1888
+ },
1889
+ h("span", { style: style.modelItemName }, currentModel.slice(0, 24)),
1890
+ h("span", { style: style.modelItemMeta }, "\u5F53\u524D\u6A21\u578B")
1891
+ ) : null,
1892
+ filteredModels.length === 0 ? h("div", { style: style.modelEmpty }, "\u6CA1\u6709\u5339\u914D\u7684\u6A21\u578B") : null,
1893
+ filteredModels.map((m) => h(
1894
+ "button",
1895
+ {
1896
+ key: m.id,
1897
+ type: "button",
1898
+ "aria-pressed": m.id === currentModel,
1899
+ style: { ...style.modelItem, ...m.id === currentModel ? style.modelItemActive : {} },
1900
+ disabled: !hasControl || switchingModel,
1901
+ title: m.model && m.model !== m.displayName ? m.model : void 0,
1902
+ onClick: () => {
1903
+ if (m.id !== currentModel) switchSessionModel(m.id);
1904
+ modelPickerRef.current?.removeAttribute("open");
1905
+ }
1906
+ },
1907
+ h("span", { style: style.modelItemName }, m.displayName),
1908
+ h("span", { style: style.modelItemMeta }, `${Math.round(m.contextWindowTokens / 1e3)}k`)
1909
+ ))
1910
+ )
1911
+ )
1912
+ );
1913
+ })()
1826
1914
  ),
1827
1915
  h(
1828
1916
  "div",
@@ -581,7 +581,18 @@ var style = {
581
581
  composer: { display: "flex", gap: 8, alignItems: "flex-end" },
582
582
  textarea: { width: "100%", minHeight: 56, maxHeight: 200, boxSizing: "border-box", padding: "8px 11px", borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, resize: "vertical", outline: "none", fontSize: 13, lineHeight: 1.5, transition: "border-color .15s, box-shadow .15s" },
583
583
  details: { fontSize: 12, color: t.text2 },
584
- summary: { cursor: "pointer", userSelect: "none", fontSize: 12, color: t.text2 }
584
+ summary: { cursor: "pointer", userSelect: "none", fontSize: 12, color: t.text2 },
585
+ modelPicker: { position: "relative", flex: "0 1 200px", minWidth: 132, maxWidth: "100%" },
586
+ modelPickerSummary: { display: "flex", alignItems: "center", gap: 6, width: "100%", boxSizing: "border-box", padding: "7px 10px", borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, outline: "none", fontSize: 13, cursor: "pointer", listStyle: "none", userSelect: "none" },
587
+ modelPickerCaret: { flexShrink: 0, color: t.text3, fontSize: 9 },
588
+ modelMenu: { position: "absolute", right: 0, top: "calc(100% + 6px)", zIndex: 40, width: "max(100%, 264px)", maxWidth: "94vw", boxSizing: "border-box", display: "grid", gap: 6, padding: 8, borderRadius: 10, border: `1px solid ${t.border}`, background: t.surface, boxShadow: "0 10px 30px rgba(16,24,40,0.16)" },
589
+ modelMenuList: { display: "grid", gap: 2, maxHeight: "52vh", overflowY: "auto", overscrollBehavior: "contain" },
590
+ modelItem: { display: "flex", alignItems: "baseline", gap: 8, width: "100%", boxSizing: "border-box", padding: "7px 9px", borderRadius: 7, border: "1px solid transparent", background: "transparent", color: t.text, cursor: "pointer", fontSize: 12.5, textAlign: "left", transition: "background .12s" },
591
+ modelItemActive: { background: t.accentSoft, borderColor: t.border, fontWeight: 650 },
592
+ modelItemName: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", textAlign: "left" },
593
+ modelItemMeta: { flexShrink: 0, fontSize: 11, color: t.text3 },
594
+ modelSearch: { width: "100%", boxSizing: "border-box", padding: "6px 9px", borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, outline: "none", fontSize: 12.5 },
595
+ modelEmpty: { fontSize: 12, color: t.text3, textAlign: "center", padding: "10px 0" }
585
596
  };
586
597
  var PANEL_CSS = `
587
598
  .mai-panel input:focus, .mai-panel textarea:focus, .mai-panel select:focus { border-color: #3568e8 !important; box-shadow: 0 0 0 2px rgba(53,104,232,0.14) !important; }
@@ -598,6 +609,10 @@ var PANEL_CSS = `
598
609
  .mai-panel .mai-card-hover { transition: border-color .15s, background .15s; }
599
610
  .mai-panel .mai-card-hover:not(:disabled):hover { border-color: #b9cdf3 !important; background: #f7faff !important; }
600
611
  .mai-panel .mai-toolbar { position: relative; }
612
+ .mai-panel .mai-model-picker > summary::-webkit-details-marker { display: none; }
613
+ .mai-panel .mai-model-picker > summary::before { content: none; }
614
+ .mai-panel .mai-model-picker-menu { max-height: 52vh; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
615
+ .mai-panel .mai-model-picker[open] .mai-model-picker-caret { transform: rotate(180deg); }
601
616
  .mai-panel .mai-workspace { display: grid; grid-template-columns: minmax(248px, 292px) minmax(0, 1fr); gap: 12px; min-width: 0; align-items: stretch; }
602
617
  .mai-panel .mai-session-sidebar { min-height: min(70vh, 720px); }
603
618
  .mai-panel .mai-session-list { scrollbar-gutter: stable; }
@@ -619,6 +634,9 @@ var PANEL_CSS = `
619
634
  .mai-panel .mai-page-title { font-size: 16px !important; }
620
635
  .mai-panel .mai-toolbar-controls { align-items: stretch; }
621
636
  .mai-panel .mai-toolbar-controls select { flex-basis: 100% !important; }
637
+ .mai-panel .mai-model-picker { flex: 1 1 100% !important; min-width: 0 !important; }
638
+ .mai-panel .mai-model-menu { position: fixed !important; inset: 50% 12px auto !important; transform: translateY(-50%); width: auto !important; max-width: none !important; max-height: 70vh !important; }
639
+ .mai-panel .mai-model-picker-menu { max-height: calc(70vh - 80px) !important; }
622
640
  .mai-panel .mai-session-list { grid-template-columns: 1fr !important; max-height: 210px; }
623
641
  .mai-panel .mai-thread { height: 68vh; min-height: 360px; padding: 10px; }
624
642
  .mai-panel .mai-thread-header { gap: 6px !important; }
@@ -652,7 +670,7 @@ function withTimeout(promise, timeoutMs, label) {
652
670
  });
653
671
  }
654
672
  function isRemoteStreamCarrierError(error) {
655
- return /api gateway: remote stream websocket (?:failed|closed)/i.test(errorText(error));
673
+ return /api gateway: remote stream websocket (?:failed|closed)/i.test(errorText(error)) || /remote stream socket closed/i.test(errorText(error));
656
674
  }
657
675
  function renderMarkdown(text) {
658
676
  if (typeof text !== "string" || text === "") return null;
@@ -871,6 +889,7 @@ function RemoteSessionPage({ api }) {
871
889
  const [sessionStatus, setSessionStatus] = useState("");
872
890
  const [signInEmail, setSignInEmail] = useState("");
873
891
  const [signInPassword, setSignInPassword] = useState("");
892
+ const [modelFilter, setModelFilter] = useState("");
874
893
  const streamRef = useRef(null);
875
894
  const historyLoadedRef = useRef(false);
876
895
  const lastDraftRef = useRef("");
@@ -878,6 +897,16 @@ function RemoteSessionPage({ api }) {
878
897
  const eventsRef = useRef(null);
879
898
  const prevScrollHeightRef = useRef(null);
880
899
  const streamGenerationRef = useRef(0);
900
+ const modelPickerRef = useRef(null);
901
+ useEffect(() => {
902
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return void 0;
903
+ const onDocClick = (event) => {
904
+ const picker = modelPickerRef.current;
905
+ if (picker && picker.open && picker.contains && !picker.contains(event.target)) picker.removeAttribute("open");
906
+ };
907
+ document.addEventListener("click", onDocClick);
908
+ return () => document.removeEventListener("click", onDocClick);
909
+ }, []);
881
910
  const title = useMemo(() => {
882
911
  const runtime = runtimeSessions.find((item) => item.id === sessionId);
883
912
  const summary = summarySessions.find((item) => item.id === sessionId);
@@ -1124,6 +1153,7 @@ function RemoteSessionPage({ api }) {
1124
1153
  setError("");
1125
1154
  setAnswerText("");
1126
1155
  setCurrentModel("");
1156
+ setModelFilter("");
1127
1157
  setVisibleCount(RECENT_LIMIT);
1128
1158
  setHistoryLoading(true);
1129
1159
  setStatus("\u6B63\u5728\u8FDE\u63A5\u8FDC\u7A0B\u4F1A\u8BDD");
@@ -1147,7 +1177,7 @@ function RemoteSessionPage({ api }) {
1147
1177
  setStatus("\u5386\u53F2\u5DF2\u52A0\u8F7D");
1148
1178
  setHistoryLoading(false);
1149
1179
  setSessionStatus((current) => current || "idle");
1150
- if (frame.model) setCurrentModel(frame.model);
1180
+ if (frame.data?.model) setCurrentModel(frame.data.model);
1151
1181
  }
1152
1182
  if (frame.type === "history_incomplete") {
1153
1183
  setStatus("\u5386\u53F2\u4E0D\u5B8C\u6574\uFF1A" + (frame.message || "\u56DE\u653E\u88AB\u4E2D\u65AD"));
@@ -1171,6 +1201,9 @@ function RemoteSessionPage({ api }) {
1171
1201
  if (frame.type === "request") {
1172
1202
  setPendingRequests((current) => [...current.filter((item) => item.requestId !== frame.requestId), frame]);
1173
1203
  }
1204
+ if (frame.type === "notification" && frame.method === "_monkeyai/session/model") {
1205
+ if (typeof frame.data?.model === "string" && frame.data.model !== "") setCurrentModel(frame.data.model);
1206
+ }
1174
1207
  if (frame.type === "notification" && frame.method === "_monkeyai/session/state") {
1175
1208
  const status2 = frame.data?.state?.status;
1176
1209
  if (typeof status2 === "string" && SESSION_STATUS_LABEL[status2]) {
@@ -1803,21 +1836,76 @@ function RemoteSessionPage({ api }) {
1803
1836
  sessionStatus === "running" ? h("span", { className: "mai-pulse", style: { ...style.dot, background: t.accent } }) : null,
1804
1837
  SESSION_STATUS_LABEL[sessionStatus]
1805
1838
  ) : null,
1806
- h(
1807
- "select",
1808
- {
1809
- style: { ...style.input, flex: "0 1 190px", minWidth: 130 },
1810
- value: currentModel,
1811
- disabled: !hasControl || switchingModel || models.length === 0,
1812
- title: "\u5207\u6362\u8BE5\u4F1A\u8BDD\u7684\u6A21\u578B",
1813
- onChange: (event) => {
1814
- const next = event.target.value;
1815
- if (next && next !== currentModel) switchSessionModel(next);
1816
- }
1817
- },
1818
- currentModel && !models.some((m) => m.id === currentModel) ? h("option", { value: currentModel }, currentModel.slice(0, 24)) : null,
1819
- models.map((m) => h("option", { key: m.id, value: m.id }, `${m.displayName} (${Math.round(m.contextWindowTokens / 1e3)}k)`))
1820
- )
1839
+ (() => {
1840
+ const modelPickerDisabled = !hasControl || switchingModel || models.length === 0;
1841
+ const currentModelInfo = models.find((m) => m.id === currentModel);
1842
+ const currentModelUnknown = Boolean(currentModel && !currentModelInfo);
1843
+ const needle = modelFilter.trim().toLowerCase();
1844
+ const filteredModels = needle === "" ? models : models.filter((m) => `${m.displayName} ${m.model ?? ""} ${m.id}`.toLowerCase().includes(needle));
1845
+ return h(
1846
+ "details",
1847
+ {
1848
+ className: "mai-model-picker",
1849
+ style: { ...style.modelPicker, ...modelPickerDisabled ? { opacity: 0.5, pointerEvents: "none" } : {} },
1850
+ ref: modelPickerRef
1851
+ },
1852
+ h(
1853
+ "summary",
1854
+ { style: style.modelPickerSummary, title: "\u5207\u6362\u8BE5\u4F1A\u8BDD\u7684\u6A21\u578B" },
1855
+ h("span", { className: "mai-model-picker-caret", style: { ...style.modelPickerCaret, flexShrink: 0 } }, "\u25BE"),
1856
+ h(
1857
+ "span",
1858
+ { style: { flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", textAlign: "left" } },
1859
+ currentModelInfo ? `${currentModelInfo.displayName} (${Math.round(currentModelInfo.contextWindowTokens / 1e3)}k)` : currentModel ? currentModel.slice(0, 24) : "\u4F1A\u8BDD\u6A21\u578B"
1860
+ )
1861
+ ),
1862
+ h(
1863
+ "div",
1864
+ { className: "mai-model-menu", style: style.modelMenu },
1865
+ models.length > 6 ? h("input", {
1866
+ type: "search",
1867
+ placeholder: "\u641C\u7D22\u6A21\u578B\u2026",
1868
+ "aria-label": "\u641C\u7D22\u6A21\u578B",
1869
+ value: modelFilter,
1870
+ style: style.modelSearch,
1871
+ onChange: (event) => setModelFilter(event.target.value)
1872
+ }) : null,
1873
+ h(
1874
+ "div",
1875
+ { className: "mai-model-picker-menu", style: style.modelMenuList },
1876
+ currentModelUnknown && (needle === "" || currentModel.toLowerCase().includes(needle)) ? h(
1877
+ "button",
1878
+ {
1879
+ type: "button",
1880
+ disabled: true,
1881
+ "aria-pressed": true,
1882
+ style: { ...style.modelItem, ...style.modelItemActive }
1883
+ },
1884
+ h("span", { style: style.modelItemName }, currentModel.slice(0, 24)),
1885
+ h("span", { style: style.modelItemMeta }, "\u5F53\u524D\u6A21\u578B")
1886
+ ) : null,
1887
+ filteredModels.length === 0 ? h("div", { style: style.modelEmpty }, "\u6CA1\u6709\u5339\u914D\u7684\u6A21\u578B") : null,
1888
+ filteredModels.map((m) => h(
1889
+ "button",
1890
+ {
1891
+ key: m.id,
1892
+ type: "button",
1893
+ "aria-pressed": m.id === currentModel,
1894
+ style: { ...style.modelItem, ...m.id === currentModel ? style.modelItemActive : {} },
1895
+ disabled: !hasControl || switchingModel,
1896
+ title: m.model && m.model !== m.displayName ? m.model : void 0,
1897
+ onClick: () => {
1898
+ if (m.id !== currentModel) switchSessionModel(m.id);
1899
+ modelPickerRef.current?.removeAttribute("open");
1900
+ }
1901
+ },
1902
+ h("span", { style: style.modelItemName }, m.displayName),
1903
+ h("span", { style: style.modelItemMeta }, `${Math.round(m.contextWindowTokens / 1e3)}k`)
1904
+ ))
1905
+ )
1906
+ )
1907
+ );
1908
+ })()
1821
1909
  ),
1822
1910
  h(
1823
1911
  "div",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-monkeyai-sessions",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "在 DeepSeek Harness 中读取和继续 MonkeyAI 会话:查看完整对话历史、下发新指令,无缝接续之前在 MonkeyAI 客户端上的工作",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -72,6 +72,17 @@ const style = {
72
72
  textarea: { width: '100%', minHeight: 56, maxHeight: 200, boxSizing: 'border-box', padding: '8px 11px', borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, resize: 'vertical', outline: 'none', fontSize: 13, lineHeight: 1.5, transition: 'border-color .15s, box-shadow .15s' },
73
73
  details: { fontSize: 12, color: t.text2 },
74
74
  summary: { cursor: 'pointer', userSelect: 'none', fontSize: 12, color: t.text2 },
75
+ modelPicker: { position: 'relative', flex: '0 1 200px', minWidth: 132, maxWidth: '100%' },
76
+ modelPickerSummary: { display: 'flex', alignItems: 'center', gap: 6, width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, outline: 'none', fontSize: 13, cursor: 'pointer', listStyle: 'none', userSelect: 'none' },
77
+ modelPickerCaret: { flexShrink: 0, color: t.text3, fontSize: 9 },
78
+ modelMenu: { position: 'absolute', right: 0, top: 'calc(100% + 6px)', zIndex: 40, width: 'max(100%, 264px)', maxWidth: '94vw', boxSizing: 'border-box', display: 'grid', gap: 6, padding: 8, borderRadius: 10, border: `1px solid ${t.border}`, background: t.surface, boxShadow: '0 10px 30px rgba(16,24,40,0.16)' },
79
+ modelMenuList: { display: 'grid', gap: 2, maxHeight: '52vh', overflowY: 'auto', overscrollBehavior: 'contain' },
80
+ modelItem: { display: 'flex', alignItems: 'baseline', gap: 8, width: '100%', boxSizing: 'border-box', padding: '7px 9px', borderRadius: 7, border: '1px solid transparent', background: 'transparent', color: t.text, cursor: 'pointer', fontSize: 12.5, textAlign: 'left', transition: 'background .12s' },
81
+ modelItemActive: { background: t.accentSoft, borderColor: t.border, fontWeight: 650 },
82
+ modelItemName: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' },
83
+ modelItemMeta: { flexShrink: 0, fontSize: 11, color: t.text3 },
84
+ modelSearch: { width: '100%', boxSizing: 'border-box', padding: '6px 9px', borderRadius: t.radius, border: `1px solid ${t.border2}`, background: t.surface, color: t.text, outline: 'none', fontSize: 12.5 },
85
+ modelEmpty: { fontSize: 12, color: t.text3, textAlign: 'center', padding: '10px 0' },
75
86
  }
76
87
 
77
88
  const PANEL_CSS = `
@@ -89,6 +100,10 @@ const PANEL_CSS = `
89
100
  .mai-panel .mai-card-hover { transition: border-color .15s, background .15s; }
90
101
  .mai-panel .mai-card-hover:not(:disabled):hover { border-color: #b9cdf3 !important; background: #f7faff !important; }
91
102
  .mai-panel .mai-toolbar { position: relative; }
103
+ .mai-panel .mai-model-picker > summary::-webkit-details-marker { display: none; }
104
+ .mai-panel .mai-model-picker > summary::before { content: none; }
105
+ .mai-panel .mai-model-picker-menu { max-height: 52vh; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
106
+ .mai-panel .mai-model-picker[open] .mai-model-picker-caret { transform: rotate(180deg); }
92
107
  .mai-panel .mai-workspace { display: grid; grid-template-columns: minmax(248px, 292px) minmax(0, 1fr); gap: 12px; min-width: 0; align-items: stretch; }
93
108
  .mai-panel .mai-session-sidebar { min-height: min(70vh, 720px); }
94
109
  .mai-panel .mai-session-list { scrollbar-gutter: stable; }
@@ -110,6 +125,9 @@ const PANEL_CSS = `
110
125
  .mai-panel .mai-page-title { font-size: 16px !important; }
111
126
  .mai-panel .mai-toolbar-controls { align-items: stretch; }
112
127
  .mai-panel .mai-toolbar-controls select { flex-basis: 100% !important; }
128
+ .mai-panel .mai-model-picker { flex: 1 1 100% !important; min-width: 0 !important; }
129
+ .mai-panel .mai-model-menu { position: fixed !important; inset: 50% 12px auto !important; transform: translateY(-50%); width: auto !important; max-width: none !important; max-height: 70vh !important; }
130
+ .mai-panel .mai-model-picker-menu { max-height: calc(70vh - 80px) !important; }
113
131
  .mai-panel .mai-session-list { grid-template-columns: 1fr !important; max-height: 210px; }
114
132
  .mai-panel .mai-thread { height: 68vh; min-height: 360px; padding: 10px; }
115
133
  .mai-panel .mai-thread-header { gap: 6px !important; }
@@ -141,6 +159,7 @@ function withTimeout(promise, timeoutMs, label) {
141
159
 
142
160
  function isRemoteStreamCarrierError(error) {
143
161
  return /api gateway: remote stream websocket (?:failed|closed)/i.test(errorText(error))
162
+ || /remote stream socket closed/i.test(errorText(error))
144
163
  }
145
164
 
146
165
  // Lightweight inline markdown: fenced code blocks, `code`, **bold**,
@@ -342,6 +361,7 @@ function RemoteSessionPage({ api }) {
342
361
  const [sessionStatus, setSessionStatus] = useState('')
343
362
  const [signInEmail, setSignInEmail] = useState('')
344
363
  const [signInPassword, setSignInPassword] = useState('')
364
+ const [modelFilter, setModelFilter] = useState('')
345
365
  const streamRef = useRef(null)
346
366
  const historyLoadedRef = useRef(false)
347
367
  const lastDraftRef = useRef('')
@@ -349,6 +369,17 @@ function RemoteSessionPage({ api }) {
349
369
  const eventsRef = useRef(null)
350
370
  const prevScrollHeightRef = useRef(null)
351
371
  const streamGenerationRef = useRef(0)
372
+ const modelPickerRef = useRef(null)
373
+
374
+ useEffect(() => {
375
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return undefined
376
+ const onDocClick = event => {
377
+ const picker = modelPickerRef.current
378
+ if (picker && picker.open && picker.contains && !picker.contains(event.target)) picker.removeAttribute('open')
379
+ }
380
+ document.addEventListener('click', onDocClick)
381
+ return () => document.removeEventListener('click', onDocClick)
382
+ }, [])
352
383
  const title = useMemo(() => {
353
384
  const runtime = runtimeSessions.find(item => item.id === sessionId)
354
385
  const summary = summarySessions.find(item => item.id === sessionId)
@@ -605,6 +636,7 @@ function RemoteSessionPage({ api }) {
605
636
  setError('')
606
637
  setAnswerText('')
607
638
  setCurrentModel('')
639
+ setModelFilter('')
608
640
  setVisibleCount(RECENT_LIMIT)
609
641
  setHistoryLoading(true)
610
642
  setStatus('正在连接远程会话')
@@ -628,7 +660,7 @@ function RemoteSessionPage({ api }) {
628
660
  setStatus('历史已加载')
629
661
  setHistoryLoading(false)
630
662
  setSessionStatus(current => current || 'idle')
631
- if (frame.model) setCurrentModel(frame.model)
663
+ if (frame.data?.model) setCurrentModel(frame.data.model)
632
664
  }
633
665
  if (frame.type === 'history_incomplete') {
634
666
  setStatus('历史不完整:' + (frame.message || '回放被中断'))
@@ -652,6 +684,9 @@ function RemoteSessionPage({ api }) {
652
684
  if (frame.type === 'request') {
653
685
  setPendingRequests(current => [...current.filter(item => item.requestId !== frame.requestId), frame])
654
686
  }
687
+ if (frame.type === 'notification' && frame.method === '_monkeyai/session/model') {
688
+ if (typeof frame.data?.model === 'string' && frame.data.model !== '') setCurrentModel(frame.data.model)
689
+ }
655
690
  if (frame.type === 'notification' && frame.method === '_monkeyai/session/state') {
656
691
  const status = frame.data?.state?.status
657
692
  if (typeof status === 'string' && SESSION_STATUS_LABEL[status]) {
@@ -1141,20 +1176,55 @@ function RemoteSessionPage({ api }) {
1141
1176
  },
1142
1177
  sessionStatus === 'running' ? h('span', { className: 'mai-pulse', style: { ...style.dot, background: t.accent } }) : null,
1143
1178
  SESSION_STATUS_LABEL[sessionStatus]) : null,
1144
- h('select', {
1145
- style: { ...style.input, flex: '0 1 190px', minWidth: 130 },
1146
- value: currentModel,
1147
- disabled: !hasControl || switchingModel || models.length === 0,
1148
- title: '切换该会话的模型',
1149
- onChange: event => {
1150
- const next = event.target.value
1151
- if (next && next !== currentModel) switchSessionModel(next)
1179
+ (() => {
1180
+ const modelPickerDisabled = !hasControl || switchingModel || models.length === 0
1181
+ const currentModelInfo = models.find(m => m.id === currentModel)
1182
+ const currentModelUnknown = Boolean(currentModel && !currentModelInfo)
1183
+ const needle = modelFilter.trim().toLowerCase()
1184
+ const filteredModels = needle === ''
1185
+ ? models
1186
+ : models.filter(m => `${m.displayName} ${m.model ?? ''} ${m.id}`.toLowerCase().includes(needle))
1187
+ return h('details', {
1188
+ className: 'mai-model-picker',
1189
+ style: { ...style.modelPicker, ...(modelPickerDisabled ? { opacity: 0.5, pointerEvents: 'none' } : {}) },
1190
+ ref: modelPickerRef,
1152
1191
  },
1153
- },
1154
- currentModel && !models.some(m => m.id === currentModel)
1155
- ? h('option', { value: currentModel }, currentModel.slice(0, 24))
1156
- : null,
1157
- models.map(m => h('option', { key: m.id, value: m.id }, `${m.displayName} (${Math.round(m.contextWindowTokens / 1000)}k)`)))),
1192
+ h('summary', { style: style.modelPickerSummary, title: '切换该会话的模型' },
1193
+ h('span', { className: 'mai-model-picker-caret', style: { ...style.modelPickerCaret, flexShrink: 0 } }, '▾'),
1194
+ h('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'left' } },
1195
+ currentModelInfo
1196
+ ? `${currentModelInfo.displayName} (${Math.round(currentModelInfo.contextWindowTokens / 1000)}k)`
1197
+ : currentModel ? currentModel.slice(0, 24) : '会话模型')),
1198
+ h('div', { className: 'mai-model-menu', style: style.modelMenu },
1199
+ models.length > 6 ? h('input', {
1200
+ type: 'search', placeholder: '搜索模型…', 'aria-label': '搜索模型', value: modelFilter,
1201
+ style: style.modelSearch,
1202
+ onChange: event => setModelFilter(event.target.value),
1203
+ }) : null,
1204
+ h('div', { className: 'mai-model-picker-menu', style: style.modelMenuList },
1205
+ currentModelUnknown && (needle === '' || currentModel.toLowerCase().includes(needle))
1206
+ ? h('button', {
1207
+ type: 'button', disabled: true, 'aria-pressed': true,
1208
+ style: { ...style.modelItem, ...style.modelItemActive },
1209
+ },
1210
+ h('span', { style: style.modelItemName }, currentModel.slice(0, 24)),
1211
+ h('span', { style: style.modelItemMeta }, '当前模型'))
1212
+ : null,
1213
+ filteredModels.length === 0 ? h('div', { style: style.modelEmpty }, '没有匹配的模型') : null,
1214
+ filteredModels.map(m => h('button', {
1215
+ key: m.id, type: 'button',
1216
+ 'aria-pressed': m.id === currentModel,
1217
+ style: { ...style.modelItem, ...(m.id === currentModel ? style.modelItemActive : {}) },
1218
+ disabled: !hasControl || switchingModel,
1219
+ title: m.model && m.model !== m.displayName ? m.model : undefined,
1220
+ onClick: () => {
1221
+ if (m.id !== currentModel) switchSessionModel(m.id)
1222
+ modelPickerRef.current?.removeAttribute('open')
1223
+ },
1224
+ },
1225
+ h('span', { style: style.modelItemName }, m.displayName),
1226
+ h('span', { style: style.modelItemMeta }, `${Math.round(m.contextWindowTokens / 1000)}k`))))))
1227
+ })()),
1158
1228
  h('div', { style: style.events, className: 'mai-thread-events mai-events', 'aria-live': 'polite', 'data-events': '', ref: eventsRef,
1159
1229
  onScroll: event => {
1160
1230
  const el = event.currentTarget
@@ -739,7 +739,9 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
739
739
  const emitHistoryLoaded = model => {
740
740
  if (historyLoadedEmitted) return
741
741
  historyLoadedEmitted = true
742
- emit({ type: 'history_loaded', sessionId: selectedSession, ...(model === undefined ? {} : { model }) })
742
+ // model travels inside data: the RemoteFrame schema only passes through
743
+ // declared top-level fields, so a bare `model` key would be stripped.
744
+ emit({ type: 'history_loaded', sessionId: selectedSession, ...(model === undefined ? {} : { data: { model } }) })
743
745
  }
744
746
  const refreshPersistedHistory = async changedMessageId => {
745
747
  if (!ticketHttpBase || !ticketValue) return
@@ -1055,23 +1057,50 @@ export class MonkeyAIRemoteService extends TypertRemoteService {
1055
1057
  ticketHttpBase = ticket.httpBase
1056
1058
  if (signal.aborted) throw signal.reason ?? new Error('远程会话已取消')
1057
1059
 
1060
+ // Surface the session's current model as early as possible. Large
1061
+ // histories paginate slowly and the stream carrier can drop mid-walk;
1062
+ // a one-message read already carries session.model, so the model picker
1063
+ // syncs before (and independently of) the history walk.
1064
+ try {
1065
+ const probeTimeout = AbortSignal.any([AbortSignal.timeout(20_000), ...(signal ? [signal] : [])])
1066
+ const modelProbe = await readSessionHistoryPage({ httpBase: ticket.httpBase, ticket: ticketValue, sessionId: selectedSession, limit: 1, signal: probeTimeout })
1067
+ if (typeof modelProbe.session?.model === 'string' && modelProbe.session.model !== '') {
1068
+ yield { type: 'notification', sessionId: selectedSession, method: '_monkeyai/session/model', data: { model: modelProbe.session.model } }
1069
+ }
1070
+ } catch {
1071
+ // Best-effort: history_loaded still carries the model when its walk
1072
+ // completes, and the picker shows the placeholder meanwhile.
1073
+ }
1074
+
1058
1075
  // Load the full history over the runtime's HTTP read channel before the
1059
1076
  // ACP connection starts. The ACP session/load replay only streams a
1060
1077
  // small recent window and the socket is closed mid-replay; the HTTP
1061
- // endpoint paginates the complete message log by cursor instead.
1078
+ // endpoint paginates the complete message log by cursor instead. Yield
1079
+ // the completed history before ACP session/load so a slow runtime mount
1080
+ // cannot keep the panel's history spinner active.
1062
1081
  try {
1082
+ const historyFrames = []
1063
1083
  const history = await fetchFullSessionHistory({
1064
1084
  httpBase: ticket.httpBase,
1065
1085
  ticket: ticketValue,
1066
1086
  sessionId: selectedSession,
1067
1087
  signal,
1068
- onFrame: async frame => { await emit(frame) },
1088
+ onFrame: frame => { historyFrames.push(frame) },
1069
1089
  })
1090
+ for (const frame of historyFrames) yield frame
1070
1091
  for (const id of history.messageIds) httpHistoryMessageIds.add(id)
1071
1092
  if (history.count > 0) {
1072
1093
  httpHistoryComplete = true
1073
1094
  httpConfigurationRevision = history.configurationRevision
1074
- emitHistoryLoaded()
1095
+ // The runtime's session/load response omits the current model; the
1096
+ // history channel's session object carries it, so surface it with
1097
+ // history_loaded to preselect the right entry in the model picker.
1098
+ historyLoadedEmitted = true
1099
+ yield {
1100
+ type: 'history_loaded',
1101
+ sessionId: selectedSession,
1102
+ ...(history.sessionModel === undefined ? {} : { data: { model: history.sessionModel } }),
1103
+ }
1075
1104
  }
1076
1105
  } catch (historyError) {
1077
1106
  if (signal?.aborted) throw historyError
@@ -1518,12 +1547,16 @@ async function fetchFullSessionHistory({ httpBase, ticket, sessionId, signal, on
1518
1547
  const toolCallsById = {}
1519
1548
  let cursor
1520
1549
  let configurationRevision
1550
+ let sessionModel
1521
1551
  for (let page = 0; page < maxPages; page++) {
1522
1552
  const timeout = AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])])
1523
1553
  const result = await readSessionHistoryPage({ httpBase, ticket, sessionId, cursor, direction: 'older', signal: timeout })
1524
1554
  if (Number.isSafeInteger(result.session?.configurationRevision)) {
1525
1555
  configurationRevision = result.session.configurationRevision
1526
1556
  }
1557
+ if (typeof result.session?.model === 'string' && result.session.model !== '') {
1558
+ sessionModel = result.session.model
1559
+ }
1527
1560
  const pageMessages = []
1528
1561
  for (const message of Array.isArray(result.messages) ? result.messages : []) {
1529
1562
  if (typeof message?.id !== 'string' || message.id === '' || seenIds.has(message.id)) continue
@@ -1540,7 +1573,7 @@ async function fetchFullSessionHistory({ httpBase, ticket, sessionId, signal, on
1540
1573
  for (const frame of historyMessageToFrames(sessionId, message, toolCallsById)) await onFrame(frame)
1541
1574
  }
1542
1575
  }
1543
- return { messageIds: [...seenIds], count: seenIds.size, configurationRevision }
1576
+ return { messageIds: [...seenIds], count: seenIds.size, configurationRevision, sessionModel }
1544
1577
  }
1545
1578
 
1546
1579
  // Refresh only the newest pages after a _monkeyai/session/history_changed