Files changed (3) hide show
  1. data/models.json +18 -9
  2. public/index.html +624 -40
  3. server.js +290 -65
data/models.json CHANGED
@@ -1,29 +1,38 @@
1
  [
 
2
  { "id": "JuggernautXL", "name": "JuggernautXL", "free": true },
3
  { "id": "qwen-image", "name": "qwen-image", "free": false },
4
- { "id": "hidream", "name": "hidream", "free": false },
5
- { "id": "FLUX.1-dev", "name": "FLUX.1-dev", "free": false },
6
  { "id": "FLUX.1-schnell", "name": "FLUX.1-schnell", "free": false },
7
  { "id": "chroma", "name": "chroma", "free": true },
8
- { "id": "neta-lumina", "name": "neta-lumina", "free": true },
9
- { "id": "nova-anime3d-xl", "name": "nova-anime3d-xl", "free": false },
10
  { "id": "Illustrij", "name": "Illustrij", "free": false },
11
  { "id": "stabilityai/stable-diffusion-xl-base-1.0", "name": "stable-diffusion-xl-base-1.0", "free": false },
12
  { "id": "iLustMix", "name": "iLustMix", "free": false },
13
- { "id": "Animij", "name": "Animij", "free": false },
14
  { "id": "NovaFurryXL", "name": "NovaFurryXL", "free": false },
15
  { "id": "Lykon/dreamshaper-xl-1-0", "name": "dreamshaper-xl-1-0", "free": false },
16
- { "id": "Shitao/OmniGen-v1", "name": "OmniGen-v1", "free": false },
17
  { "id": "diagonalge/Booba", "name": "Booba", "free": false },
18
- { "id": "HassakuXL", "name": "HassakuXL", "free": false },
19
- { "id": "nova-cartoon-xl", "name": "nova-cartoon-xl", "free": false },
20
- { "id": "orphic-lora", "name": "orphic-lora", "free": false },
21
  { "id": "diagonalge/ConstShaper", "name": "ConstShaper", "free": false },
 
 
22
  {
23
  "id": "hunyuan-image-3",
24
  "name": "hunyuan-image-3",
25
  "free": false,
26
  "minimal": true,
27
  "upstream_url": "https://chutes-hunyuan-image-3.chutes.ai/generate"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  }
29
  ]
 
1
  [
2
+ { "id": "JuggernautXL-Ragnarok", "name": "JuggernautXL-Ragnarok", "free": true },
3
  { "id": "JuggernautXL", "name": "JuggernautXL", "free": true },
4
  { "id": "qwen-image", "name": "qwen-image", "free": false },
 
 
5
  { "id": "FLUX.1-schnell", "name": "FLUX.1-schnell", "free": false },
6
  { "id": "chroma", "name": "chroma", "free": true },
 
 
7
  { "id": "Illustrij", "name": "Illustrij", "free": false },
8
  { "id": "stabilityai/stable-diffusion-xl-base-1.0", "name": "stable-diffusion-xl-base-1.0", "free": false },
9
  { "id": "iLustMix", "name": "iLustMix", "free": false },
 
10
  { "id": "NovaFurryXL", "name": "NovaFurryXL", "free": false },
11
  { "id": "Lykon/dreamshaper-xl-1-0", "name": "dreamshaper-xl-1-0", "free": false },
 
12
  { "id": "diagonalge/Booba", "name": "Booba", "free": false },
 
 
 
13
  { "id": "diagonalge/ConstShaper", "name": "ConstShaper", "free": false },
14
+ { "id": "qwen-image", "name": "qwen-image", "free": false },
15
+ { "id": "qwen-image-edit", "name": "qwen-image-edit", "free": false, "upstream_id": "qwen-image", "upstream_url": "https://chutes-qwen-image-edit-2509.chutes.ai/generate" },
16
  {
17
  "id": "hunyuan-image-3",
18
  "name": "hunyuan-image-3",
19
  "free": false,
20
  "minimal": true,
21
  "upstream_url": "https://chutes-hunyuan-image-3.chutes.ai/generate"
22
+ },
23
+ { "id": "novafurry/NovaFurryXL", "name": "NovaFurryXL", "free": false },
24
+ { "id": "hidream", "name": "hidream", "free": false, "upstream_url": "https://chutes-hidream.chutes.ai/generate" },
25
+ {
26
+ "id": "z-image-turbo",
27
+ "name": "Z-Image-Turbo",
28
+ "free": false,
29
+ "minimal": true,
30
+ "upstream_url": "https://chutes-z-image-turbo.chutes.ai/generate",
31
+ "default_params": {
32
+ "guidance_scale": 0.0,
33
+ "num_inference_steps": 9,
34
+ "shift": 3.0,
35
+ "max_sequence_length": 512
36
+ }
37
  }
38
  ]
public/index.html CHANGED
@@ -339,9 +339,16 @@
339
 
340
  .card img {
341
  width: 100%;
 
342
  display: block;
343
  aspect-ratio: 1;
344
  object-fit: cover;
 
 
 
 
 
 
345
  }
346
 
347
  .card .meta {
@@ -627,20 +634,119 @@
627
  width: 100%;
628
  }
629
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
630
  </style>
631
  </head>
632
  <body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  <header>
634
  <button class="mobile-menu-toggle" id="mobileMenuToggle">☰</button>
635
  <h1>Chutes</h1>
636
  <div class="controls">
637
  <div class="toggle-group">
638
- <button id="soundToggle" class="toggle-btn">提示音</button>
 
 
 
 
639
  <button id="themeToggle" class="toggle-btn">主题</button>
640
  </div>
641
  <div class="api-input">
642
- <span>Key</span>
643
- <input type="password" id="apiKeyInput" placeholder="API Token">
644
  </div>
645
  </div>
646
  </header>
@@ -655,13 +761,18 @@
655
  <div class="sidebar-content">
656
  <div class="group">
657
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
658
- <button id="soundToggleMobile" class="toggle-btn">提示音</button>
 
 
 
 
 
659
  <button id="themeToggleMobile" class="toggle-btn">主题</button>
660
  </div>
661
  </div>
662
  <div class="group">
663
- <label>API Key</label>
664
- <input type="password" id="apiKeyInputMobile" placeholder="粘贴 Token" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); font-size: 14px;">
665
  </div>
666
  <div class="group">
667
  <label>模型</label>
@@ -671,16 +782,47 @@
671
  <label>提示词</label>
672
  <textarea id="promptMobile" rows="3" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
673
  </div>
 
 
 
 
 
 
 
 
 
674
  <div class="group">
675
  <label>反向提示词</label>
676
  <textarea id="negative_promptMobile" rows="2" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
677
  </div>
678
- <div class="group form-grid">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  <div class="group">
 
 
 
 
 
 
680
  <label>宽度</label>
681
  <input type="number" id="widthMobile" value="1024">
682
  </div>
683
- <div class="group">
684
  <label>高度</label>
685
  <input type="number" id="heightMobile" value="1024">
686
  </div>
@@ -712,7 +854,11 @@
712
  </div>
713
 
714
  <!-- Mobile Generate Button -->
715
- <button class="mobile-generate-btn" id="mobileGenerateBtn">✨</button>
 
 
 
 
716
 
717
  <main>
718
  <section class="panel" id="desktopPanel">
@@ -727,16 +873,47 @@
727
  <label>提示词</label>
728
  <textarea id="prompt" rows="2" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
729
  </div>
 
 
 
 
 
 
 
 
 
730
  <div class="group">
731
  <label>反向提示词</label>
732
  <textarea id="negative_prompt" rows="1" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
733
  </div>
734
- <div class="group form-grid">
 
 
 
 
 
 
 
 
 
 
 
 
735
  <div class="group">
 
 
 
 
 
 
 
 
 
 
736
  <label>宽度</label>
737
  <input type="number" id="width" value="1024">
738
  </div>
739
- <div class="group">
740
  <label>高度</label>
741
  <input type="number" id="height" value="1024">
742
  </div>
@@ -772,6 +949,16 @@
772
  <div id="gallery" class="gallery"></div>
773
  </section>
774
  </main>
 
 
 
 
 
 
 
 
 
 
775
  <audio id="notify" src="/studio/new-notification-3-398649.mp3" preload="auto"></audio>
776
  <script>
777
  const qs = s => document.querySelector(s);
@@ -783,13 +970,15 @@
783
 
784
  const state = {
785
  models: [],
 
786
  sound: true,
787
  theme: 'light',
788
  apiKey: '',
789
  folderHandle: null,
790
  seedRandom: true,
791
  isMobile: window.innerWidth <= 768,
792
- sidebarOpen: false
 
793
  };
794
 
795
  function setTheme(t) {
@@ -821,14 +1010,24 @@
821
  const btn = qs('#soundToggle');
822
  if (btn) {
823
  btn.classList.toggle('active', state.sound);
824
- btn.textContent = state.sound ? '🔔' : '🔕';
 
 
 
825
  }
826
 
827
  // 更新移动端按钮
828
  const btnMobile = qs('#soundToggleMobile');
829
  if (btnMobile) {
830
  btnMobile.classList.toggle('active', state.sound);
831
- btnMobile.textContent = state.sound ? '🔔 开启' : '🔕 关闭';
 
 
 
 
 
 
 
832
  }
833
  }
834
 
@@ -837,6 +1036,8 @@
837
  ls.set('apiKey', state.apiKey);
838
  const el = qs('#apiKeyInput');
839
  if (el && el.value !== state.apiKey) el.value = state.apiKey;
 
 
840
  }
841
 
842
  function updateSeedToggle() {
@@ -851,7 +1052,7 @@
851
  }
852
 
853
  function genRandomSeed() {
854
- return Math.floor(Math.random() * 4294967295);
855
  }
856
 
857
  async function fetchModels() {
@@ -860,9 +1061,12 @@
860
  headers: state.apiKey ? { 'x-api-key': state.apiKey } : {}
861
  });
862
  const j = await r.json();
 
 
863
  state.models = (j.models || []).map(m => ({
864
  id: String(m.id || m.name || ''),
865
- name: String(m.name || m.id || '')
 
866
  }));
867
  renderModels();
868
  } catch(e) {
@@ -886,21 +1090,117 @@
886
  if (last && last.model) sel.value = last.model;
887
  }
888
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
  }
890
 
891
  function currentParams() {
892
  const isMobile = window.innerWidth <= 768;
893
  const prefix = isMobile ? 'Mobile' : '';
894
- return {
895
- model: qs(`#modelSelect${prefix}`).value || '',
 
 
 
896
  prompt: (qs(`#prompt${prefix}`).value || '').trim(),
897
  negative_prompt: (qs(`#negative_prompt${prefix}`).value || '').trim(),
898
  width: Number(qs(`#width${prefix}`).value) || 1024,
899
  height: Number(qs(`#height${prefix}`).value) || 1024,
900
- guidance_scale: Number(qs(`#guidance_scale${prefix}`).value) || 6,
901
  num_inference_steps: Number(qs(`#num_inference_steps${prefix}`).value) || 20,
902
- seed: state.seedRandom ? null : 0
 
 
 
903
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
  }
905
 
906
  function createPlaceholderCard(i, params) {
@@ -912,7 +1212,7 @@
912
  wrap.appendChild(ph);
913
  const meta = document.createElement('div');
914
  meta.className = 'meta';
915
- meta.innerHTML = `${params.model} | ${params.width}x${params.height}`;
916
  wrap.appendChild(meta);
917
  qs('#gallery').prepend(wrap);
918
  return wrap;
@@ -924,6 +1224,11 @@
924
  img.src = dataUrl;
925
  img.alt = params.prompt || 'image';
926
 
 
 
 
 
 
927
  const meta = document.createElement('div');
928
  meta.className = 'meta';
929
 
@@ -952,13 +1257,22 @@
952
  const metaContent = document.createElement('div');
953
  metaContent.className = 'meta-content';
954
  metaContent.innerHTML = `
955
- <div style="margin-bottom: 4px;">尺寸: ${params.width}x${params.height}</div>
956
  <div style="margin-bottom: 4px;">步数: ${params.num_inference_steps} | 引导: ${params.guidance_scale}</div>
957
  <div style="margin-bottom: 8px; font-size: 12px; line-height: 1.3; word-break: break-all;">${params.prompt}</div>
958
- <div class="row">
959
- <button class="secondary" onclick="downloadImageMobile('${filename}', '${dataUrl}')">下载</button>
960
- </div>
961
  `;
 
 
 
 
 
 
 
 
 
 
 
 
962
 
963
  meta.appendChild(metaHeader);
964
  meta.appendChild(metaContent);
@@ -980,6 +1294,115 @@
980
  }
981
  }
982
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  // 拖拽功能
984
  let dragState = {
985
  isDragging: false,
@@ -1026,6 +1449,8 @@
1026
  const rect = btn.getBoundingClientRect();
1027
  dragState.initialX = rect.left;
1028
  dragState.initialY = rect.top;
 
 
1029
 
1030
  e.preventDefault();
1031
  }
@@ -1063,14 +1488,17 @@
1063
  }
1064
 
1065
  function endDrag(e) {
1066
- if (!dragState.isDragging) return;
1067
-
1068
  const btn = qs('#mobileGenerateBtn');
1069
  if (!btn) return;
1070
 
 
 
 
1071
  dragState.isDragging = false;
1072
  btn.classList.remove('dragging');
1073
 
 
 
1074
  // 如果移动距离很小,视为点击
1075
  const moveDistance = Math.sqrt(
1076
  Math.pow(dragState.currentX - dragState.initialX, 2) +
@@ -1079,13 +1507,13 @@
1079
 
1080
  if (moveDistance < 10) {
1081
  // 触发生成
1082
- generate();
1083
  }
1084
 
1085
  // 保存位置
1086
  ls.set('generateBtnPosition', {
1087
- x: dragState.currentX,
1088
- y: dragState.currentY
1089
  });
1090
  }
1091
 
@@ -1169,9 +1597,49 @@
1169
  return new Blob([u8], { type: mime });
1170
  }
1171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1172
  async function saveToChosenFolder(filename, dataUrl) {
1173
  if (!state.folderHandle) return false;
1174
  try {
 
 
 
1175
  const fileHandle = await state.folderHandle.getFileHandle(filename, { create: true });
1176
  const writable = await fileHandle.createWritable();
1177
  await writable.write(dataURLtoBlob(dataUrl));
@@ -1200,9 +1668,18 @@
1200
  return;
1201
  }
1202
  const handle = await window.showDirectoryPicker();
 
 
 
 
 
 
 
 
1203
  state.folderHandle = handle;
1204
- qs('#folderStatus').textContent = '已选择';
1205
- qs('#folderStatusMobile').textContent = '已选择';
 
1206
  } catch(e) {
1207
  qs('#folderStatus').textContent = '未选择';
1208
  qs('#folderStatusMobile').textContent = '未选择';
@@ -1300,6 +1777,11 @@
1300
  alert('模型与提示词必填');
1301
  return;
1302
  }
 
 
 
 
 
1303
 
1304
  const isMobile = window.innerWidth <= 768;
1305
  const batchElement = qs(isMobile ? '#batchCountMobile' : '#batchCount');
@@ -1314,15 +1796,35 @@
1314
  const tasks = [];
1315
  for (let i = 1; i <= count; i++) {
1316
  const placeholder = createPlaceholderCard(i, p);
1317
- const args = {
 
1318
  prompt: p.prompt,
1319
  negative_prompt: p.negative_prompt || '',
1320
- width: p.width,
1321
- height: p.height,
1322
  guidance_scale: p.guidance_scale,
1323
  num_inference_steps: p.num_inference_steps,
1324
  seed: state.seedRandom ? genRandomSeed() : 0
1325
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1326
  const body = { model: p.model, input_args: args };
1327
 
1328
  const task = (async () => {
@@ -1347,8 +1849,16 @@
1347
  const fname = `image_${Date.now()}_${Math.random().toString(16).slice(2)}.jpg`;
1348
  updateCardWithImage(placeholder, dataUrl, p, fname);
1349
 
 
1350
  if (state.folderHandle) {
1351
- await saveToChosenFolder(fname, dataUrl);
 
 
 
 
 
 
 
1352
  }
1353
 
1354
  if (state.sound) {
@@ -1422,6 +1932,12 @@
1422
 
1423
  qs('#chooseFolder').addEventListener('click', chooseFolder);
1424
  qs('#generateBtn').addEventListener('click', generate);
 
 
 
 
 
 
1425
 
1426
  // 移动端事件绑定
1427
  qs('#mobileMenuToggle').addEventListener('click', toggleSidebar);
@@ -1440,6 +1956,12 @@
1440
  generate();
1441
  closeSidebar();
1442
  });
 
 
 
 
 
 
1443
 
1444
  // 初始化拖拽功能
1445
  initDragButton();
@@ -1458,10 +1980,14 @@
1458
  // 键盘事件
1459
  document.addEventListener('keydown', (e) => {
1460
  if (e.key === 'Enter' && !state.sidebarOpen) generate();
1461
- if (e.key === 'Escape' && state.sidebarOpen) closeSidebar();
1462
- });
1463
-
1464
- // 响应式检测
 
 
 
 
1465
  window.addEventListener('resize', checkMobile);
1466
  checkMobile();
1467
 
@@ -1508,7 +2034,65 @@
1508
  fetchModels();
1509
  }
1510
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1511
  init();
 
1512
  </script>
1513
  </body>
1514
  </html>
 
339
 
340
  .card img {
341
  width: 100%;
342
+ height: auto;
343
  display: block;
344
  aspect-ratio: 1;
345
  object-fit: cover;
346
+ cursor: pointer;
347
+ transition: opacity 0.2s ease;
348
+ }
349
+
350
+ .card img:hover {
351
+ opacity: 0.9;
352
  }
353
 
354
  .card .meta {
 
634
  width: 100%;
635
  }
636
  }
637
+
638
+ /* SVG图标样式 */
639
+ .icon-sparkles {
640
+ width: 24px;
641
+ height: 24px;
642
+ fill: currentColor;
643
+ }
644
+
645
+ /* 大图预览模态框样式 */
646
+ .image-modal {
647
+ position: fixed;
648
+ top: 0;
649
+ left: 0;
650
+ width: 100%;
651
+ height: 100%;
652
+ background: rgba(0, 0, 0, 0.9);
653
+ z-index: 9999;
654
+ display: none;
655
+ align-items: center;
656
+ justify-content: center;
657
+ flex-direction: column;
658
+ padding: 20px;
659
+ }
660
+
661
+ .image-modal.show {
662
+ display: flex;
663
+ }
664
+
665
+ .image-modal-close {
666
+ position: absolute;
667
+ top: 20px;
668
+ right: 20px;
669
+ background: rgba(255, 255, 255, 0.2);
670
+ border: none;
671
+ color: white;
672
+ font-size: 32px;
673
+ cursor: pointer;
674
+ width: 48px;
675
+ height: 48px;
676
+ border-radius: 50%;
677
+ display: flex;
678
+ align-items: center;
679
+ justify-content: center;
680
+ transition: all 0.2s ease;
681
+ z-index: 10001;
682
+ min-height: auto;
683
+ padding: 0;
684
+ }
685
+
686
+ .image-modal-close:hover {
687
+ background: rgba(255, 255, 255, 0.3);
688
+ transform: scale(1.1);
689
+ }
690
+
691
+ .image-modal-content {
692
+ max-width: 90%;
693
+ max-height: 85%;
694
+ display: flex;
695
+ align-items: center;
696
+ justify-content: center;
697
+ }
698
+
699
+ .image-modal-content img {
700
+ max-width: 100%;
701
+ max-height: 100%;
702
+ object-fit: contain;
703
+ border-radius: 8px;
704
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
705
+ }
706
+
707
+ .image-modal-info {
708
+ color: white;
709
+ margin-top: 16px;
710
+ font-size: 14px;
711
+ text-align: center;
712
+ background: rgba(0, 0, 0, 0.5);
713
+ padding: 8px 16px;
714
+ border-radius: 4px;
715
+ }
716
  </style>
717
  </head>
718
  <body>
719
+ <!-- SVG图标定义 -->
720
+ <svg style="display: none;">
721
+ <defs>
722
+ <symbol id="icon-sparkles" viewBox="0 0 24 24">
723
+ <path d="M12 0L14.59 8.41L23 11L14.59 13.59L12 22L9.41 13.59L1 11L9.41 8.41L12 0Z"/>
724
+ <path d="M19 8L20.5 12.5L25 14L20.5 15.5L19 20L17.5 15.5L13 14L17.5 12.5L19 8Z" opacity="0.6"/>
725
+ </symbol>
726
+ <symbol id="icon-bell" viewBox="0 0 24 24">
727
+ <path d="M12 2C11.172 2 10.5 2.672 10.5 3.5V4.191C8.211 4.886 6.5 7.039 6.5 9.6V14L4.5 16V17H19.5V16L17.5 14V9.6C17.5 7.039 15.789 4.886 13.5 4.191V3.5C13.5 2.672 12.828 2 12 2ZM10 18C10 19.1 10.9 20 12 20C13.1 20 14 19.1 14 18H10Z"/>
728
+ </symbol>
729
+ <symbol id="icon-bell-off" viewBox="0 0 24 24">
730
+ <path d="M12 2C11.172 2 10.5 2.672 10.5 3.5V4.191C8.211 4.886 6.5 7.039 6.5 9.6V14L4.5 16V17H19.5V16L17.5 14V9.6C17.5 7.039 15.789 4.886 13.5 4.191V3.5C13.5 2.672 12.828 2 12 2ZM10 18C10 19.1 10.9 20 12 20C13.1 20 14 19.1 14 18H10Z" opacity="0.3"/>
731
+ <path d="M2 2L22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
732
+ </symbol>
733
+ </defs>
734
+ </svg>
735
  <header>
736
  <button class="mobile-menu-toggle" id="mobileMenuToggle">☰</button>
737
  <h1>Chutes</h1>
738
  <div class="controls">
739
  <div class="toggle-group">
740
+ <button id="soundToggle" class="toggle-btn">
741
+ <svg width="16" height="16">
742
+ <use href="#icon-bell"></use>
743
+ </svg>
744
+ </button>
745
  <button id="themeToggle" class="toggle-btn">主题</button>
746
  </div>
747
  <div class="api-input">
748
+ <span>密钥</span>
749
+ <input type="password" id="apiKeyInput" placeholder="API 密钥">
750
  </div>
751
  </div>
752
  </header>
 
761
  <div class="sidebar-content">
762
  <div class="group">
763
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
764
+ <button id="soundToggleMobile" class="toggle-btn">
765
+ <svg width="16" height="16" style="margin-right: 4px;">
766
+ <use href="#icon-bell"></use>
767
+ </svg>
768
+ <span>提示音</span>
769
+ </button>
770
  <button id="themeToggleMobile" class="toggle-btn">主题</button>
771
  </div>
772
  </div>
773
  <div class="group">
774
+ <label>API 密钥</label>
775
+ <input type="password" id="apiKeyInputMobile" placeholder="粘贴密钥" style="width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); font-size: 14px;">
776
  </div>
777
  <div class="group">
778
  <label>模型</label>
 
782
  <label>提示词</label>
783
  <textarea id="promptMobile" rows="3" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
784
  </div>
785
+ <div class="group" id="groupImagesMobile">
786
+ <label>参考图(最多3张,仅用于 qwen-image-edit)</label>
787
+ <input type="file" id="imagesMobile" accept="image/*" multiple>
788
+ <div class="status" id="imagesStatusMobile">未选择</div>
789
+ </div>
790
+ <div class="group" id="groupTrueCfgMobile">
791
+ <label>True CFG 系数(qwen-image-edit)</label>
792
+ <input type="number" id="true_cfg_scaleMobile" step="0.1" min="0" max="10" value="4">
793
+ </div>
794
  <div class="group">
795
  <label>反向提示词</label>
796
  <textarea id="negative_promptMobile" rows="2" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
797
  </div>
798
+ <div class="group" id="groupResolutionMobile" style="display: none;">
799
+ <label>分辨率(仅 hidream)</label>
800
+ <select id="resolutionMobile">
801
+ <option value="1024x1024">1024x1024</option>
802
+ <option value="768x1360">768x1360</option>
803
+ <option value="1360x768">1360x768</option>
804
+ <option value="880x1168">880x1168</option>
805
+ <option value="1168x880">1168x880</option>
806
+ <option value="1248x832">1248x832</option>
807
+ <option value="832x1248">832x1248</option>
808
+ </select>
809
+ </div>
810
+ <div class="group form-grid" id="groupZImageParamsMobile" style="display: none;">
811
+ <div class="group">
812
+ <label>Shift(Z-Image-Turbo)</label>
813
+ <input type="number" id="shiftMobile" step="0.1" min="1" max="10" value="3.0">
814
+ </div>
815
  <div class="group">
816
+ <label>Max Sequence Length</label>
817
+ <input type="number" id="max_sequence_lengthMobile" step="1" min="256" max="2048" value="512">
818
+ </div>
819
+ </div>
820
+ <div class="group form-grid">
821
+ <div class="group" id="groupWidthMobile">
822
  <label>宽度</label>
823
  <input type="number" id="widthMobile" value="1024">
824
  </div>
825
+ <div class="group" id="groupHeightMobile">
826
  <label>高度</label>
827
  <input type="number" id="heightMobile" value="1024">
828
  </div>
 
854
  </div>
855
 
856
  <!-- Mobile Generate Button -->
857
+ <button class="mobile-generate-btn" id="mobileGenerateBtn">
858
+ <svg class="icon-sparkles">
859
+ <use href="#icon-sparkles"></use>
860
+ </svg>
861
+ </button>
862
 
863
  <main>
864
  <section class="panel" id="desktopPanel">
 
873
  <label>提示词</label>
874
  <textarea id="prompt" rows="2" placeholder="例如:原神角色 插画风,清晰细节,高质量"></textarea>
875
  </div>
876
+ <div class="group" id="groupImages">
877
+ <label>参考图(最多3张,仅用于 qwen-image-edit)</label>
878
+ <input type="file" id="images" accept="image/*" multiple>
879
+ <div class="status" id="imagesStatus">未选择</div>
880
+ </div>
881
+ <div class="group" id="groupTrueCfg">
882
+ <label>True CFG 系数(qwen-image-edit)</label>
883
+ <input type="number" id="true_cfg_scale" step="0.1" min="0" max="10" value="4">
884
+ </div>
885
  <div class="group">
886
  <label>反向提示词</label>
887
  <textarea id="negative_prompt" rows="1" placeholder="blurry, lowres, bad anatomy, artifacts"></textarea>
888
  </div>
889
+ <div class="group" id="groupResolution" style="display: none;">
890
+ <label>分辨率(仅 hidream)</label>
891
+ <select id="resolution">
892
+ <option value="1024x1024">1024x1024</option>
893
+ <option value="768x1360">768x1360</option>
894
+ <option value="1360x768">1360x768</option>
895
+ <option value="880x1168">880x1168</option>
896
+ <option value="1168x880">1168x880</option>
897
+ <option value="1248x832">1248x832</option>
898
+ <option value="832x1248">832x1248</option>
899
+ </select>
900
+ </div>
901
+ <div class="group form-grid" id="groupZImageParams" style="display: none;">
902
  <div class="group">
903
+ <label>Shift(Z-Image-Turbo)</label>
904
+ <input type="number" id="shift" step="0.1" min="1" max="10" value="3.0">
905
+ </div>
906
+ <div class="group">
907
+ <label>Max Sequence Length</label>
908
+ <input type="number" id="max_sequence_length" step="1" min="256" max="2048" value="512">
909
+ </div>
910
+ </div>
911
+ <div class="group form-grid">
912
+ <div class="group" id="groupWidth">
913
  <label>宽度</label>
914
  <input type="number" id="width" value="1024">
915
  </div>
916
+ <div class="group" id="groupHeight">
917
  <label>高度</label>
918
  <input type="number" id="height" value="1024">
919
  </div>
 
949
  <div id="gallery" class="gallery"></div>
950
  </section>
951
  </main>
952
+
953
+ <!-- 大图预览模态框 -->
954
+ <div class="image-modal" id="imageModal">
955
+ <button class="image-modal-close" id="imageModalClose">×</button>
956
+ <div class="image-modal-content">
957
+ <img id="imageModalImg" src="" alt="预览">
958
+ </div>
959
+ <div class="image-modal-info" id="imageModalInfo"></div>
960
+ </div>
961
+
962
  <audio id="notify" src="/studio/new-notification-3-398649.mp3" preload="auto"></audio>
963
  <script>
964
  const qs = s => document.querySelector(s);
 
970
 
971
  const state = {
972
  models: [],
973
+ modelsConfig: [], // 存储完整的模型配置(包括 default_params)
974
  sound: true,
975
  theme: 'light',
976
  apiKey: '',
977
  folderHandle: null,
978
  seedRandom: true,
979
  isMobile: window.innerWidth <= 768,
980
+ sidebarOpen: false,
981
+ imageB64s: []
982
  };
983
 
984
  function setTheme(t) {
 
1010
  const btn = qs('#soundToggle');
1011
  if (btn) {
1012
  btn.classList.toggle('active', state.sound);
1013
+ const svg = btn.querySelector('svg use');
1014
+ if (svg) {
1015
+ svg.setAttribute('href', state.sound ? '#icon-bell' : '#icon-bell-off');
1016
+ }
1017
  }
1018
 
1019
  // 更新移动端按钮
1020
  const btnMobile = qs('#soundToggleMobile');
1021
  if (btnMobile) {
1022
  btnMobile.classList.toggle('active', state.sound);
1023
+ const svg = btnMobile.querySelector('svg use');
1024
+ const span = btnMobile.querySelector('span');
1025
+ if (svg) {
1026
+ svg.setAttribute('href', state.sound ? '#icon-bell' : '#icon-bell-off');
1027
+ }
1028
+ if (span) {
1029
+ span.textContent = state.sound ? '开启' : '关闭';
1030
+ }
1031
  }
1032
  }
1033
 
 
1036
  ls.set('apiKey', state.apiKey);
1037
  const el = qs('#apiKeyInput');
1038
  if (el && el.value !== state.apiKey) el.value = state.apiKey;
1039
+ const elMobile = qs('#apiKeyInputMobile');
1040
+ if (elMobile && elMobile.value !== state.apiKey) elMobile.value = state.apiKey;
1041
  }
1042
 
1043
  function updateSeedToggle() {
 
1052
  }
1053
 
1054
  function genRandomSeed() {
1055
+ return Math.floor(Math.random() * 100000000);
1056
  }
1057
 
1058
  async function fetchModels() {
 
1061
  headers: state.apiKey ? { 'x-api-key': state.apiKey } : {}
1062
  });
1063
  const j = await r.json();
1064
+ // 保存完整的模型配置
1065
+ state.modelsConfig = j.models || [];
1066
  state.models = (j.models || []).map(m => ({
1067
  id: String(m.id || m.name || ''),
1068
+ name: String(m.name || m.id || ''),
1069
+ default_params: m.default_params || null
1070
  }));
1071
  renderModels();
1072
  } catch(e) {
 
1090
  if (last && last.model) sel.value = last.model;
1091
  }
1092
  });
1093
+ // 根据当前模型更新参数可见性(桌面与移动端)
1094
+ updateVisibleFieldsFor('');
1095
+ updateVisibleFieldsFor('Mobile');
1096
+ }
1097
+
1098
+ function toggleGroup(selector, show) {
1099
+ const el = qs(selector);
1100
+ if (!el) return;
1101
+ el.style.display = show ? '' : 'none';
1102
+ }
1103
+
1104
+ function updateVisibleFieldsFor(suffix) {
1105
+ const sel = qs(`#modelSelect${suffix}`);
1106
+ if (!sel) return;
1107
+ const model = String(sel.value || '').toLowerCase();
1108
+ const isQwenEdit = model === 'qwen-image-edit';
1109
+ const isHidream = model === 'hidream';
1110
+ const isZImageTurbo = model === 'z-image-turbo';
1111
+
1112
+ toggleGroup(`#groupImages${suffix}`, isQwenEdit);
1113
+ toggleGroup(`#groupTrueCfg${suffix}`, isQwenEdit);
1114
+
1115
+ // hidream 使用分辨率;隐藏宽高,显示分辨率下拉
1116
+ toggleGroup(`#groupWidth${suffix}`, !isHidream);
1117
+ toggleGroup(`#groupHeight${suffix}`, !isHidream);
1118
+ toggleGroup(`#groupResolution${suffix}`, isHidream);
1119
+
1120
+ // Z-Image-Turbo 显示专用参数
1121
+ toggleGroup(`#groupZImageParams${suffix}`, isZImageTurbo);
1122
+
1123
+ // 应用模型的默认参数
1124
+ applyModelDefaultParams(model, suffix);
1125
+ }
1126
+
1127
+ // 应用模型的默认参数
1128
+ function applyModelDefaultParams(modelId, suffix) {
1129
+ const modelConfig = state.models.find(m => m.id.toLowerCase() === modelId.toLowerCase());
1130
+ if (!modelConfig || !modelConfig.default_params) return;
1131
+
1132
+ const params = modelConfig.default_params;
1133
+
1134
+ // 应用 guidance_scale
1135
+ if (params.guidance_scale !== undefined) {
1136
+ const el = qs(`#guidance_scale${suffix}`);
1137
+ if (el) el.value = params.guidance_scale;
1138
+ }
1139
+
1140
+ // 应用 num_inference_steps
1141
+ if (params.num_inference_steps !== undefined) {
1142
+ const el = qs(`#num_inference_steps${suffix}`);
1143
+ if (el) el.value = params.num_inference_steps;
1144
+ }
1145
+
1146
+ // 应用 shift
1147
+ if (params.shift !== undefined) {
1148
+ const el = qs(`#shift${suffix}`);
1149
+ if (el) el.value = params.shift;
1150
+ }
1151
+
1152
+ // 应用 max_sequence_length
1153
+ if (params.max_sequence_length !== undefined) {
1154
+ const el = qs(`#max_sequence_length${suffix}`);
1155
+ if (el) el.value = params.max_sequence_length;
1156
+ }
1157
  }
1158
 
1159
  function currentParams() {
1160
  const isMobile = window.innerWidth <= 768;
1161
  const prefix = isMobile ? 'Mobile' : '';
1162
+ const modelId = qs(`#modelSelect${prefix}`).value || '';
1163
+ const modelConfig = state.models.find(m => m.id === modelId);
1164
+
1165
+ const params = {
1166
+ model: modelId,
1167
  prompt: (qs(`#prompt${prefix}`).value || '').trim(),
1168
  negative_prompt: (qs(`#negative_prompt${prefix}`).value || '').trim(),
1169
  width: Number(qs(`#width${prefix}`).value) || 1024,
1170
  height: Number(qs(`#height${prefix}`).value) || 1024,
1171
+ guidance_scale: Number(qs(`#guidance_scale${prefix}`).value),
1172
  num_inference_steps: Number(qs(`#num_inference_steps${prefix}`).value) || 20,
1173
+ true_cfg_scale: Number(qs(`#true_cfg_scale${prefix}`)?.value) || 4,
1174
+ image_b64s: state.imageB64s ? state.imageB64s.slice(0, 3) : [],
1175
+ seed: state.seedRandom ? null : 0,
1176
+ resolution: (qs(`#resolution${prefix}`) && qs(`#resolution${prefix}`).value) || ''
1177
  };
1178
+
1179
+ // Handle guidance_scale default value properly - only use default if value is NaN (empty input)
1180
+ if (Number.isNaN(params.guidance_scale)) {
1181
+ params.guidance_scale = 6;
1182
+ }
1183
+
1184
+ // 添加 Z-Image-Turbo 特定参数(从 UI 控件读取)
1185
+ const shiftEl = qs(`#shift${prefix}`);
1186
+ const maxSeqEl = qs(`#max_sequence_length${prefix}`);
1187
+ if (shiftEl && shiftEl.offsetParent !== null) {
1188
+ params.shift = Number(shiftEl.value) || 3.0;
1189
+ }
1190
+ if (maxSeqEl && maxSeqEl.offsetParent !== null) {
1191
+ params.max_sequence_length = Number(maxSeqEl.value) || 512;
1192
+ }
1193
+
1194
+ return params;
1195
+ }
1196
+
1197
+ // Helper to get display size string (resolution for hidream, width x height for others)
1198
+ function getDisplaySize(params) {
1199
+ const isHidream = (params.model || '').toLowerCase() === 'hidream';
1200
+ if (isHidream && params.resolution) {
1201
+ return params.resolution;
1202
+ }
1203
+ return `${params.width}x${params.height}`;
1204
  }
1205
 
1206
  function createPlaceholderCard(i, params) {
 
1212
  wrap.appendChild(ph);
1213
  const meta = document.createElement('div');
1214
  meta.className = 'meta';
1215
+ meta.innerHTML = `${params.model} | ${getDisplaySize(params)}`;
1216
  wrap.appendChild(meta);
1217
  qs('#gallery').prepend(wrap);
1218
  return wrap;
 
1224
  img.src = dataUrl;
1225
  img.alt = params.prompt || 'image';
1226
 
1227
+ // 添加点击放大功能
1228
+ img.addEventListener('click', () => {
1229
+ showImageModal(dataUrl, params);
1230
+ });
1231
+
1232
  const meta = document.createElement('div');
1233
  meta.className = 'meta';
1234
 
 
1257
  const metaContent = document.createElement('div');
1258
  metaContent.className = 'meta-content';
1259
  metaContent.innerHTML = `
1260
+ <div style="margin-bottom: 4px;">尺寸: ${getDisplaySize(params)}</div>
1261
  <div style="margin-bottom: 4px;">步数: ${params.num_inference_steps} | 引导: ${params.guidance_scale}</div>
1262
  <div style="margin-bottom: 8px; font-size: 12px; line-height: 1.3; word-break: break-all;">${params.prompt}</div>
 
 
 
1263
  `;
1264
+ const row = document.createElement('div');
1265
+ row.className = 'row';
1266
+ const dlBtn = document.createElement('button');
1267
+ dlBtn.className = 'secondary';
1268
+ dlBtn.textContent = '下载';
1269
+ dlBtn.addEventListener('click', function(e) {
1270
+ e.preventDefault();
1271
+ e.stopPropagation();
1272
+ downloadImageMobile(filename, dataUrl);
1273
+ });
1274
+ row.appendChild(dlBtn);
1275
+ metaContent.appendChild(row);
1276
 
1277
  meta.appendChild(metaHeader);
1278
  meta.appendChild(metaContent);
 
1294
  }
1295
  }
1296
 
1297
+ // Image Viewer (Lightbox)
1298
+ function ensureImageViewer() {
1299
+ let overlay = document.getElementById('imageViewer');
1300
+ if (!overlay) {
1301
+ overlay = document.createElement('div');
1302
+ overlay.id = 'imageViewer';
1303
+ overlay.className = 'image-viewer-overlay';
1304
+ const content = document.createElement('div');
1305
+ content.className = 'image-viewer-content';
1306
+ const img = document.createElement('img');
1307
+ img.alt = 'preview';
1308
+ const closeBtn = document.createElement('button');
1309
+ closeBtn.className = 'image-viewer-close';
1310
+ closeBtn.textContent = '关闭';
1311
+ closeBtn.addEventListener('click', function(e) {
1312
+ e.preventDefault();
1313
+ e.stopPropagation();
1314
+ closeImageViewer();
1315
+ });
1316
+ // click outside content area closes viewer
1317
+ overlay.addEventListener('click', function(e) {
1318
+ if (e.target === overlay) {
1319
+ closeImageViewer();
1320
+ }
1321
+ });
1322
+ content.appendChild(img);
1323
+ content.appendChild(closeBtn);
1324
+ overlay.appendChild(content);
1325
+ document.body.appendChild(overlay);
1326
+ }
1327
+ return overlay;
1328
+ }
1329
+
1330
+ function openImageViewer(dataUrl, params) {
1331
+ const overlay = ensureImageViewer();
1332
+ const img = overlay.querySelector('.image-viewer-content img');
1333
+ img.src = dataUrl;
1334
+ img.alt = (params && params.prompt) ? params.prompt : 'image';
1335
+ overlay.classList.add('show');
1336
+ }
1337
+
1338
+ function closeImageViewer() {
1339
+ const overlay = document.getElementById('imageViewer');
1340
+ if (overlay) {
1341
+ overlay.classList.remove('show');
1342
+ const img = overlay.querySelector('.image-viewer-content img');
1343
+ if (img) img.src = '';
1344
+ }
1345
+ }
1346
+
1347
+ function isViewerOpen() {
1348
+ const overlay = document.getElementById('imageViewer');
1349
+ return !!(overlay && overlay.classList.contains('show'));
1350
+ }
1351
+ // Image Viewer (Lightbox)
1352
+ function ensureImageViewer() {
1353
+ let overlay = document.getElementById('imageViewer');
1354
+ if (!overlay) {
1355
+ overlay = document.createElement('div');
1356
+ overlay.id = 'imageViewer';
1357
+ overlay.className = 'image-viewer-overlay';
1358
+ const content = document.createElement('div');
1359
+ content.className = 'image-viewer-content';
1360
+ const img = document.createElement('img');
1361
+ img.alt = 'preview';
1362
+ const closeBtn = document.createElement('button');
1363
+ closeBtn.className = 'image-viewer-close';
1364
+ closeBtn.textContent = '关闭';
1365
+ closeBtn.addEventListener('click', function(e) {
1366
+ e.preventDefault();
1367
+ e.stopPropagation();
1368
+ closeImageViewer();
1369
+ });
1370
+ // click outside content area closes viewer
1371
+ overlay.addEventListener('click', function(e) {
1372
+ if (e.target === overlay) {
1373
+ closeImageViewer();
1374
+ }
1375
+ });
1376
+ content.appendChild(img);
1377
+ content.appendChild(closeBtn);
1378
+ overlay.appendChild(content);
1379
+ document.body.appendChild(overlay);
1380
+ }
1381
+ return overlay;
1382
+ }
1383
+
1384
+ function openImageViewer(dataUrl, params) {
1385
+ const overlay = ensureImageViewer();
1386
+ const img = overlay.querySelector('.image-viewer-content img');
1387
+ img.src = dataUrl;
1388
+ img.alt = (params && params.prompt) ? params.prompt : 'image';
1389
+ overlay.classList.add('show');
1390
+ }
1391
+
1392
+ function closeImageViewer() {
1393
+ const overlay = document.getElementById('imageViewer');
1394
+ if (overlay) {
1395
+ overlay.classList.remove('show');
1396
+ const img = overlay.querySelector('.image-viewer-content img');
1397
+ if (img) img.src = '';
1398
+ }
1399
+ }
1400
+
1401
+ function isViewerOpen() {
1402
+ const overlay = document.getElementById('imageViewer');
1403
+ return !!(overlay && overlay.classList.contains('show'));
1404
+ }
1405
+
1406
  // 拖拽功能
1407
  let dragState = {
1408
  isDragging: false,
 
1449
  const rect = btn.getBoundingClientRect();
1450
  dragState.initialX = rect.left;
1451
  dragState.initialY = rect.top;
1452
+ dragState.currentX = rect.left;
1453
+ dragState.currentY = rect.top;
1454
 
1455
  e.preventDefault();
1456
  }
 
1488
  }
1489
 
1490
  function endDrag(e) {
 
 
1491
  const btn = qs('#mobileGenerateBtn');
1492
  if (!btn) return;
1493
 
1494
+ const wasDragging = dragState.isDragging;
1495
+
1496
+ // 立即清除拖拽状态
1497
  dragState.isDragging = false;
1498
  btn.classList.remove('dragging');
1499
 
1500
+ if (!wasDragging) return;
1501
+
1502
  // 如果移动距离很小,视为点击
1503
  const moveDistance = Math.sqrt(
1504
  Math.pow(dragState.currentX - dragState.initialX, 2) +
 
1507
 
1508
  if (moveDistance < 10) {
1509
  // 触发生成
1510
+ setTimeout(() => generate(), 50);
1511
  }
1512
 
1513
  // 保存位置
1514
  ls.set('generateBtnPosition', {
1515
+ x: dragState.currentX || dragState.initialX,
1516
+ y: dragState.currentY || dragState.initialY
1517
  });
1518
  }
1519
 
 
1597
  return new Blob([u8], { type: mime });
1598
  }
1599
 
1600
+ // 将文件读取为去掉 data: 前缀的纯 base64,限制最多3张
1601
+ async function filesToBase64Raw(fileList) {
1602
+ const files = Array.from(fileList).slice(0, 3);
1603
+ const arr = [];
1604
+ for (const f of files) {
1605
+ const b64 = await new Promise((resolve, reject) => {
1606
+ const reader = new FileReader();
1607
+ reader.onload = () => {
1608
+ const s = String(reader.result || '');
1609
+ const idx = s.indexOf(',');
1610
+ resolve(idx >= 0 ? s.slice(idx + 1) : s);
1611
+ };
1612
+ reader.onerror = () => reject(reader.error || new Error('read failed'));
1613
+ reader.readAsDataURL(f);
1614
+ });
1615
+ arr.push(b64);
1616
+ }
1617
+ return arr;
1618
+ }
1619
+
1620
+ function handleImagesChange(inputEl, statusElId) {
1621
+ try {
1622
+ const files = inputEl && inputEl.files ? inputEl.files : [];
1623
+ if (!files || files.length === 0) {
1624
+ state.imageB64s = [];
1625
+ const st = qs('#' + statusElId);
1626
+ if (st) st.textContent = '未选择';
1627
+ return;
1628
+ }
1629
+ filesToBase64Raw(files).then(list => {
1630
+ state.imageB64s = list;
1631
+ const st = qs('#' + statusElId);
1632
+ if (st) st.textContent = '已选择 ' + list.length + ' 张';
1633
+ }).catch(() => {});
1634
+ } catch(e) {}
1635
+ }
1636
+
1637
  async function saveToChosenFolder(filename, dataUrl) {
1638
  if (!state.folderHandle) return false;
1639
  try {
1640
+ // 如果无写入权限,避免在非用户激活上下文触发授权提示
1641
+ const perm = await state.folderHandle.queryPermission({ mode: 'readwrite' });
1642
+ if (perm !== 'granted') return false;
1643
  const fileHandle = await state.folderHandle.getFileHandle(filename, { create: true });
1644
  const writable = await fileHandle.createWritable();
1645
  await writable.write(dataURLtoBlob(dataUrl));
 
1668
  return;
1669
  }
1670
  const handle = await window.showDirectoryPicker();
1671
+ // 在用户点击的上下文中请求写入权限
1672
+ let perm = 'prompt';
1673
+ if ('queryPermission' in handle && 'requestPermission' in handle) {
1674
+ perm = await handle.queryPermission({ mode: 'readwrite' });
1675
+ if (perm !== 'granted') {
1676
+ perm = await handle.requestPermission({ mode: 'readwrite' });
1677
+ }
1678
+ }
1679
  state.folderHandle = handle;
1680
+ const ok = perm === 'granted';
1681
+ qs('#folderStatus').textContent = ok ? '已选择(可写)' : '已选择(只读)';
1682
+ qs('#folderStatusMobile').textContent = ok ? '已选择(可写)' : '已选择(只读)';
1683
  } catch(e) {
1684
  qs('#folderStatus').textContent = '未选择';
1685
  qs('#folderStatusMobile').textContent = '未选择';
 
1777
  alert('模型与提示词必填');
1778
  return;
1779
  }
1780
+ // qwen-image-edit 需要至少一张参考图
1781
+ if ((p.model || '').toLowerCase() === 'qwen-image-edit' && (!state.imageB64s || state.imageB64s.length < 1)) {
1782
+ alert('qwen-image-edit 需要至少 1 张参考图');
1783
+ return;
1784
+ }
1785
 
1786
  const isMobile = window.innerWidth <= 768;
1787
  const batchElement = qs(isMobile ? '#batchCountMobile' : '#batchCount');
 
1796
  const tasks = [];
1797
  for (let i = 1; i <= count; i++) {
1798
  const placeholder = createPlaceholderCard(i, p);
1799
+ const isQwenEdit = (p.model || '').toLowerCase() === 'qwen-image-edit';
1800
+ const argsBase = {
1801
  prompt: p.prompt,
1802
  negative_prompt: p.negative_prompt || '',
 
 
1803
  guidance_scale: p.guidance_scale,
1804
  num_inference_steps: p.num_inference_steps,
1805
  seed: state.seedRandom ? genRandomSeed() : 0
1806
  };
1807
+ const args = isQwenEdit
1808
+ ? {
1809
+ ...argsBase,
1810
+ width: p.width,
1811
+ height: p.height,
1812
+ true_cfg_scale: Number(qs(`#true_cfg_scale${isMobile ? 'Mobile' : ''}`)?.value) || 4,
1813
+ ...(state.imageB64s && state.imageB64s.length ? { image_b64s: state.imageB64s.slice(0, 3) } : {})
1814
+ }
1815
+ : {
1816
+ ...argsBase,
1817
+ width: p.width,
1818
+ height: p.height
1819
+ };
1820
+ // hidream 期望使用 resolution 字段
1821
+ if ((p.model || '').toLowerCase() === 'hidream') {
1822
+ args.resolution = (p.resolution && typeof p.resolution === 'string' && p.resolution.includes('x'))
1823
+ ? p.resolution
1824
+ : `${p.width}x${p.height}`;
1825
+ delete args.width;
1826
+ delete args.height;
1827
+ }
1828
  const body = { model: p.model, input_args: args };
1829
 
1830
  const task = (async () => {
 
1849
  const fname = `image_${Date.now()}_${Math.random().toString(16).slice(2)}.jpg`;
1850
  updateCardWithImage(placeholder, dataUrl, p, fname);
1851
 
1852
+ let saved = false;
1853
  if (state.folderHandle) {
1854
+ try {
1855
+ saved = await saveToChosenFolder(fname, dataUrl);
1856
+ } catch(e) {
1857
+ saved = false;
1858
+ }
1859
+ }
1860
+ if (!saved) {
1861
+ downloadImageMobile(fname, dataUrl);
1862
  }
1863
 
1864
  if (state.sound) {
 
1932
 
1933
  qs('#chooseFolder').addEventListener('click', chooseFolder);
1934
  qs('#generateBtn').addEventListener('click', generate);
1935
+
1936
+ // 参考图选择事件
1937
+ const imagesEl = qs('#images');
1938
+ if (imagesEl) imagesEl.addEventListener('change', () => handleImagesChange(imagesEl, 'imagesStatus'));
1939
+ const imagesElM = qs('#imagesMobile');
1940
+ if (imagesElM) imagesElM.addEventListener('change', () => handleImagesChange(imagesElM, 'imagesStatusMobile'));
1941
 
1942
  // 移动端事件绑定
1943
  qs('#mobileMenuToggle').addEventListener('click', toggleSidebar);
 
1956
  generate();
1957
  closeSidebar();
1958
  });
1959
+
1960
+ // 模型切换时动态显示/隐藏特定参数
1961
+ const ms = qs('#modelSelect');
1962
+ if (ms) ms.addEventListener('change', () => updateVisibleFieldsFor(''));
1963
+ const msm = qs('#modelSelectMobile');
1964
+ if (msm) msm.addEventListener('change', () => updateVisibleFieldsFor('Mobile'));
1965
 
1966
  // 初始化拖拽功能
1967
  initDragButton();
 
1980
  // 键盘事件
1981
  document.addEventListener('keydown', (e) => {
1982
  if (e.key === 'Enter' && !state.sidebarOpen) generate();
1983
+ if (e.key === 'Escape') {
1984
+ if (isViewerOpen()) {
1985
+ closeImageViewer();
1986
+ } else if (state.sidebarOpen) {
1987
+ closeSidebar();
1988
+ }
1989
+ }
1990
+ });// 响应式检测
1991
  window.addEventListener('resize', checkMobile);
1992
  checkMobile();
1993
 
 
2034
  fetchModels();
2035
  }
2036
 
2037
+ // 大图预览功能
2038
+ function showImageModal(imageUrl, params) {
2039
+ const modal = qs('#imageModal');
2040
+ const modalImg = qs('#imageModalImg');
2041
+ const modalInfo = qs('#imageModalInfo');
2042
+
2043
+ modalImg.src = imageUrl;
2044
+ if (params) {
2045
+ modalInfo.textContent = `${getDisplaySize(params)} | ${params.model}`;
2046
+ } else {
2047
+ modalInfo.textContent = '';
2048
+ }
2049
+ modal.classList.add('show');
2050
+
2051
+ // 阻止背景滚动
2052
+ document.body.style.overflow = 'hidden';
2053
+ }
2054
+
2055
+ function closeImageModal() {
2056
+ const modal = qs('#imageModal');
2057
+ modal.classList.remove('show');
2058
+ document.body.style.overflow = '';
2059
+ }
2060
+
2061
+ // 设置模态框事件监听
2062
+ function setupImageModal() {
2063
+ const modal = qs('#imageModal');
2064
+ const closeBtn = qs('#imageModalClose');
2065
+ const modalImg = qs('#imageModalImg');
2066
+
2067
+ if (closeBtn) {
2068
+ closeBtn.addEventListener('click', (e) => {
2069
+ e.stopPropagation();
2070
+ closeImageModal();
2071
+ });
2072
+ }
2073
+
2074
+ if (modal) {
2075
+ modal.addEventListener('click', (e) => {
2076
+ if (e.target === modal) {
2077
+ closeImageModal();
2078
+ }
2079
+ });
2080
+ }
2081
+
2082
+ if (modalImg) {
2083
+ modalImg.addEventListener('click', closeImageModal);
2084
+ }
2085
+
2086
+ // ESC键关闭
2087
+ document.addEventListener('keydown', (e) => {
2088
+ if (e.key === 'Escape' && modal.classList.contains('show')) {
2089
+ closeImageModal();
2090
+ }
2091
+ });
2092
+ }
2093
+
2094
  init();
2095
+ setupImageModal();
2096
  </script>
2097
  </body>
2098
  </html>
server.js CHANGED
@@ -36,7 +36,7 @@ const ALLOW_CLIENT_API_KEY = /^true$/i.test(process.env.ALLOW_CLIENT_API_KEY ||
36
  // Strict switches to close any possibility of routing/fallback
37
  // - STRICT_NO_ROUTING=true 不做本地映射,除非前端或调用方显式传 upstream_id
38
  // - STRICT_NO_FALLBACK=true 强制禁用回落(即使前端未设置 no_fallback)
39
- const STRICT_NO_ROUTING = /^true$/i.test(process.env.STRICT_NO_ROUTING || 'true');
40
  const STRICT_NO_FALLBACK = /^true$/i.test(process.env.STRICT_NO_FALLBACK || 'true');
41
  // Auto fallback to another model when upstream capacity/infrastructure errors
42
  const AUTO_FALLBACK = /^true$/i.test(process.env.AUTO_FALLBACK || 'true');
@@ -52,23 +52,42 @@ const FORCE_MINIMAL = /^true$/i.test(process.env.FORCE_MINIMAL || 'false');
52
  app.use(helmet({
53
  crossOriginResourcePolicy: { policy: 'cross-origin' },
54
  // Allow embedding on Hugging Face Spaces (disable X-Frame-Options)
55
- frameguard: false
 
 
 
 
56
  }));
57
 
58
  // Enable CSP and allow inline script/style for this SPA.
59
  // Also allow connections to the upstream image API.
60
- // Allow embedding inside Hugging Face Spaces iframe via frame-ancestors
61
  app.use(helmet.contentSecurityPolicy({
62
  useDefaults: true,
63
  directives: {
64
  "default-src": ["'self'"],
 
 
65
  "script-src": ["'self'", "'unsafe-inline'"],
 
 
 
 
66
  "style-src": ["'self'", "'unsafe-inline'"],
 
 
67
  "img-src": ["'self'", "data:", "blob:"],
68
- "font-src": ["'self'", "data:"],
69
- "connect-src": ["'self'", "https://image.chutes.ai"],
 
 
 
 
 
 
70
  "media-src": ["'self'", "data:", "blob:"],
71
- "frame-ancestors": ["'self'", "https://huggingface.co", "https://*.huggingface.co"]
 
72
  }
73
  }));
74
 
@@ -80,7 +99,8 @@ app.use((req, res, next) => {
80
 
81
  app.use(cors());
82
  app.use(compression());
83
- app.use(express.json({ limit: '2mb' }));
 
84
  app.use(morgan(LOG_LEVEL));
85
 
86
  // Static files
@@ -218,11 +238,25 @@ app.get('/api/models', async (req, res) => {
218
  const free = typeof m.free === 'boolean' ? m.free : false;
219
  return { id, name, free };
220
  });
221
- // Merge free flags from local mapping
222
  if (localList.length) {
223
- // 先合并免费标记
224
  models = mergeFreeFlags(models, localList);
225
- // 再把仅存在于本地配置但远端没有的模型追加进去(并集)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  const remoteKeys = new Set(models.map(m => ((m.id || m.name || '') + '').toLowerCase()));
227
  for (const lm of localList) {
228
  const key = ((lm.id || lm.name || '') + '').toLowerCase();
@@ -230,7 +264,9 @@ app.get('/api/models', async (req, res) => {
230
  const id = (lm.id || lm.name || '').toString();
231
  const name = (lm.name || id).toString();
232
  const free = !!lm.free;
233
- models.push({ id, name, free });
 
 
234
  remoteKeys.add(key);
235
  }
236
  }
@@ -243,7 +279,9 @@ app.get('/api/models', async (req, res) => {
243
  const id = (m.id || m.slug || m.model || m.name || `model-${idx}`).toString();
244
  const name = (m.name || id).toString();
245
  const free = !!m.free;
246
- return { id, name, free };
 
 
247
  });
248
  return res.json({ source: 'local', models: normalized });
249
  } catch (err) {
@@ -282,8 +320,17 @@ app.post('/api/generate', async (req, res) => {
282
  negative_prompt: (body.negative_prompt ?? (body.input_args ? body.input_args.negative_prompt : undefined) ?? '').toString(),
283
  width: clamp(parseInt(body.width ?? (body.input_args ? body.input_args.width : 1024), 10) || 1024, 128, 2048),
284
  height: clamp(parseInt(body.height ?? (body.input_args ? body.input_args.height : 1024), 10) || 1024, 128, 2048),
285
- guidance_scale: clamp(parseFloat(body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : 7.5)) || 7.5, 1, 20),
286
- num_inference_steps: clamp(parseInt(body.num_inference_steps ?? (body.input_args ? body.input_args.num_inference_steps : 25), 10) || 25, 1, 50),
 
 
 
 
 
 
 
 
 
287
  // Seed: support top-level or input_args.seed; if missing/null -> null (omit from payload)
288
  seed: (() => {
289
  const raw = (body.seed ?? (body.input_args ? body.input_args.seed : undefined));
@@ -330,9 +377,17 @@ app.post('/api/generate', async (req, res) => {
330
  return null;
331
  }
332
  const cfg = getModelConfig(localList, flat.model);
333
- const generateUrl = (cfg && cfg.upstream_url) ? cfg.upstream_url : GENERATE_API_URL;
334
- const preferMinimal = FORCE_MINIMAL || !!(cfg && cfg.minimal === true);
335
-
 
 
 
 
 
 
 
 
336
 
337
 
338
  const apiToken = req.headers['x-api-key'] || CHUTES_API_TOKEN;
@@ -342,29 +397,123 @@ app.post('/api/generate', async (req, res) => {
342
  ...(apiToken ? { 'Authorization': `Bearer ${apiToken}` } : {})
343
  };
344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  const variantA = {
346
  model: targetModel,
347
- input_args: {
348
- prompt: flat.prompt,
349
- negative_prompt: flat.negative_prompt || '',
350
- width: flat.width,
351
- height: flat.height,
352
- guidance_scale: flat.guidance_scale,
353
- num_inference_steps: flat.num_inference_steps,
354
- ...(flat.seed !== null ? { seed: flat.seed } : {})
355
- }
356
  };
357
 
358
  const variantB = {
359
  model: targetModel,
360
- prompt: flat.prompt,
361
- negative_prompt: flat.negative_prompt || '',
362
  width: flat.width,
363
- height: flat.height,
 
 
 
 
 
 
364
  guidance_scale: flat.guidance_scale,
365
  num_inference_steps: flat.num_inference_steps,
366
  ...(flat.seed !== null ? { seed: flat.seed } : {})
367
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
 
369
  // Minimal payload (some models reject extended fields). Include size/steps/seed for hunyuan-image-3 compatibility
370
  const variantCMinimal = {
@@ -377,6 +526,27 @@ app.post('/api/generate', async (req, res) => {
377
  }
378
  };
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  // duplicate removed
381
 
382
  async function tryCall(payload, label, url) {
@@ -390,14 +560,16 @@ app.post('/api/generate', async (req, res) => {
390
  return { ok: true, imageBase64: base64, contentType: ctype, tried: label };
391
  }
392
 
393
- // Success: JSON payload that contains image data
394
  if (status >= 200 && status < 300 && /application\/json/i.test(ctype)) {
395
  let raw = '';
396
  try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
397
  try {
398
  const j = JSON.parse(raw || '{}');
 
399
  if (j && j.image) {
400
  if (typeof j.image === 'string' && j.image.startsWith('data:')) {
 
401
  const match = /^data:([^;]+);base64,(.*)$/i.exec(j.image);
402
  if (match) {
403
  return { ok: true, imageBase64: match[2], contentType: match[1], tried: label };
@@ -412,13 +584,17 @@ app.post('/api/generate', async (req, res) => {
412
  return { ok: true, imageBase64: j.data, contentType: ct, tried: label };
413
  }
414
  } catch (e) {
415
- // fallthrough
416
  }
417
  }
418
 
419
- // Error mapping
420
  let raw = '';
421
- try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
 
 
 
 
422
  let code = 'UPSTREAM_ERROR';
423
  let hint = '';
424
  let mappedStatus = status;
@@ -429,11 +605,12 @@ app.post('/api/generate', async (req, res) => {
429
  } catch (e) {
430
  detailText = raw;
431
  }
 
432
  const lower = (detailText || '').toLowerCase();
433
  if (lower.includes('exhausted all available targets')) {
434
  code = 'UPSTREAM_CAPACITY_EXHAUSTED';
435
  hint = '上游容量不足(GPU/目标不可用或排队中),请稍后重试、换模型,或降低分辨率/步数。';
436
- mappedStatus = 503;
437
  } else if (status === 404 && lower.includes('model not found')) {
438
  code = 'UPSTREAM_MODEL_NOT_FOUND';
439
  hint = '上游模型不存在或标识不匹配。请更换模型,或在 data/models.json 为该模型添加正确的 \"upstream_id\" 映射后重试。';
@@ -450,24 +627,73 @@ app.post('/api/generate', async (req, res) => {
450
  }
451
 
452
  let result;
453
-
454
- // If this model prefers minimal payload (e.g. hunyuan-image-3), try minimal first
455
- if (preferMinimal) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  try {
457
- result = await tryCall(variantCMinimal, 'minimal', generateUrl);
458
- } catch (e0) { /* continue */ }
 
 
 
 
 
459
  }
460
 
461
- try {
462
- result = await tryCall(variantA, 'nested', generateUrl);
463
- } catch (e1) {
464
- try {
465
- result = await tryCall(variantB, 'flat', generateUrl);
466
- } catch (e2) {
467
- // Auto fallback to another model when capacity/infrastructure errors (disabled when NO_FALLBACK=true)
468
- const capacityCodes = ['UPSTREAM_CAPACITY_EXHAUSTED','UPSTREAM_NO_INSTANCES','UPSTREAM_INFRASTRUCTURE','UPSTREAM_BAD_GATEWAY'];
469
- if (AUTO_FALLBACK && !NO_FALLBACK && capacityCodes.includes(e2.code || '') ) {
470
- // choose fallback model (prefer free and different from current)
 
 
 
 
 
 
471
  function chooseFallback(currentId, list) {
472
  const key = (currentId || '').toLowerCase();
473
  const free = list.filter(m => m.free && (m.id || m.name || '').toLowerCase() !== key);
@@ -480,15 +706,13 @@ app.post('/api/generate', async (req, res) => {
480
  if (fallbackModel && fallbackModel !== targetModel) {
481
  const fbA = { ...variantA, model: fallbackModel };
482
  const fbB = { ...variantB, model: fallbackModel };
483
- // Choose URL for fallback model
484
  const fbCfg = getModelConfig(localList, fallbackModel);
485
  const fbUrl = (fbCfg && fbCfg.upstream_url) ? fbCfg.upstream_url : GENERATE_API_URL;
486
- try {
487
- result = await tryCall(fbA, 'nested-fallback', fbUrl);
488
- } catch (e3) {
489
- try {
490
- result = await tryCall(fbB, 'flat-fallback', fbUrl);
491
- } catch (e4) {
492
  const status = e4.status || 502;
493
  return res.status(status).json({
494
  ok: false,
@@ -499,6 +723,7 @@ app.post('/api/generate', async (req, res) => {
499
  });
500
  }
501
  }
 
502
  // success with fallback
503
  return res.json({
504
  ok: true,
@@ -519,14 +744,14 @@ app.post('/api/generate', async (req, res) => {
519
  });
520
  }
521
  }
522
- const status = e2.status || 502;
523
- return res.status(status).json({
524
- ok: false,
525
- error: e2.hint || e2.message || 'Upstream error',
526
- code: e2.code || 'UPSTREAM_ERROR',
527
- upstream_model: targetModel
528
- });
529
- }
530
  }
531
 
532
  return res.json({
 
36
  // Strict switches to close any possibility of routing/fallback
37
  // - STRICT_NO_ROUTING=true 不做本地映射,除非前端或调用方显式传 upstream_id
38
  // - STRICT_NO_FALLBACK=true 强制禁用回落(即使前端未设置 no_fallback)
39
+ const STRICT_NO_ROUTING = /^true$/i.test(process.env.STRICT_NO_ROUTING || 'false');
40
  const STRICT_NO_FALLBACK = /^true$/i.test(process.env.STRICT_NO_FALLBACK || 'true');
41
  // Auto fallback to another model when upstream capacity/infrastructure errors
42
  const AUTO_FALLBACK = /^true$/i.test(process.env.AUTO_FALLBACK || 'true');
 
52
  app.use(helmet({
53
  crossOriginResourcePolicy: { policy: 'cross-origin' },
54
  // Allow embedding on Hugging Face Spaces (disable X-Frame-Options)
55
+ frameguard: false,
56
+ // Avoid COOP/COEP blocking when embedded in an iframe
57
+ crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' },
58
+ crossOriginEmbedderPolicy: false,
59
+ originAgentCluster: false
60
  }));
61
 
62
  // Enable CSP and allow inline script/style for this SPA.
63
  // Also allow connections to the upstream image API.
64
+ // Allow embedding inside Hugging Face Spaces iframe via frame-ancestors/frame-src
65
  app.use(helmet.contentSecurityPolicy({
66
  useDefaults: true,
67
  directives: {
68
  "default-src": ["'self'"],
69
+ // Allow SPA inline scripts but forbid inline event attributes per CSP3
70
+ // Allow SPA inline scripts but forbid inline event attributes per CSP3
71
  "script-src": ["'self'", "'unsafe-inline'"],
72
+ "script-src-attr": ["'none'"],
73
+ // Permit external stylesheet from Baomitu CDN via style-src-elem
74
+ "script-src-attr": ["'none'"],
75
+ // Permit external stylesheet from Baomitu CDN via style-src-elem
76
  "style-src": ["'self'", "'unsafe-inline'"],
77
+ "style-src-elem": ["'self'", "'unsafe-inline'", "https://lib.baomitu.com"],
78
+ "style-src-elem": ["'self'", "'unsafe-inline'", "https://lib.baomitu.com"],
79
  "img-src": ["'self'", "data:", "blob:"],
80
+ // Allow Baomitu icon fonts
81
+ "font-src": ["'self'", "data:", "https://lib.baomitu.com"],
82
+ // Allow CSS sourcemap requests to Baomitu CDN
83
+ "connect-src": ["'self'", "https://image.chutes.ai", "https://lib.baomitu.com"],
84
+ // Allow Baomitu icon fonts
85
+ "font-src": ["'self'", "data:", "https://lib.baomitu.com"],
86
+ // Allow CSS sourcemap requests to Baomitu CDN
87
+ "connect-src": ["'self'", "https://image.chutes.ai", "https://lib.baomitu.com"],
88
  "media-src": ["'self'", "data:", "blob:"],
89
+ "frame-src": ["'self'", "https://huggingface.co", "https://*.huggingface.co", "https://*.hf.space"],
90
+ "frame-ancestors": ["'self'", "https://huggingface.co", "https://*.huggingface.co", "https://*.hf.space"]
91
  }
92
  }));
93
 
 
99
 
100
  app.use(cors());
101
  app.use(compression());
102
+ app.use(express.json({ limit: '50mb' }));
103
+ app.use(express.urlencoded({ limit: '50mb', extended: true }));
104
  app.use(morgan(LOG_LEVEL));
105
 
106
  // Static files
 
238
  const free = typeof m.free === 'boolean' ? m.free : false;
239
  return { id, name, free };
240
  });
241
+ // Merge free flags and default_params from local mapping
242
  if (localList.length) {
243
+ // Merge free flags for models that exist remotely
244
  models = mergeFreeFlags(models, localList);
245
+ // Also merge default_params from local config
246
+ const localMap = new Map();
247
+ for (const lm of localList) {
248
+ const key = ((lm.id || lm.name || '') + '').toLowerCase();
249
+ if (key) localMap.set(key, lm);
250
+ }
251
+ models = models.map(m => {
252
+ const key = ((m.id || m.name || '') + '').toLowerCase();
253
+ const local = localMap.get(key);
254
+ if (local && local.default_params) {
255
+ return { ...m, default_params: local.default_params };
256
+ }
257
+ return m;
258
+ });
259
+ // Also append local-only models (union), so new models in local config are visible in frontend
260
  const remoteKeys = new Set(models.map(m => ((m.id || m.name || '') + '').toLowerCase()));
261
  for (const lm of localList) {
262
  const key = ((lm.id || lm.name || '') + '').toLowerCase();
 
264
  const id = (lm.id || lm.name || '').toString();
265
  const name = (lm.name || id).toString();
266
  const free = !!lm.free;
267
+ const model = { id, name, free };
268
+ if (lm.default_params) model.default_params = lm.default_params;
269
+ models.push(model);
270
  remoteKeys.add(key);
271
  }
272
  }
 
279
  const id = (m.id || m.slug || m.model || m.name || `model-${idx}`).toString();
280
  const name = (m.name || id).toString();
281
  const free = !!m.free;
282
+ const model = { id, name, free };
283
+ if (m.default_params) model.default_params = m.default_params;
284
+ return model;
285
  });
286
  return res.json({ source: 'local', models: normalized });
287
  } catch (err) {
 
320
  negative_prompt: (body.negative_prompt ?? (body.input_args ? body.input_args.negative_prompt : undefined) ?? '').toString(),
321
  width: clamp(parseInt(body.width ?? (body.input_args ? body.input_args.width : 1024), 10) || 1024, 128, 2048),
322
  height: clamp(parseInt(body.height ?? (body.input_args ? body.input_args.height : 1024), 10) || 1024, 128, 2048),
323
+ guidance_scale: clamp((() => {
324
+ const raw = body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : undefined);
325
+ const parsed = parseFloat(raw);
326
+ return Number.isNaN(parsed) ? 7.5 : parsed;
327
+ })(), 0, 20),
328
+ guidance_scale: clamp((() => {
329
+ const raw = body.guidance_scale ?? (body.input_args ? body.input_args.guidance_scale : undefined);
330
+ const parsed = parseFloat(raw);
331
+ return Number.isNaN(parsed) ? 7.5 : parsed;
332
+ })(), 0, 20),
333
+ num_inference_steps: clamp(parseInt(body.num_inference_steps ?? (body.input_args ? body.input_args.num_inference_steps : 25), 10) || 25, 1, 100),
334
  // Seed: support top-level or input_args.seed; if missing/null -> null (omit from payload)
335
  seed: (() => {
336
  const raw = (body.seed ?? (body.input_args ? body.input_args.seed : undefined));
 
377
  return null;
378
  }
379
  const cfg = getModelConfig(localList, flat.model);
380
+ let generateUrl = (cfg && cfg.upstream_url) ? cfg.upstream_url : GENERATE_API_URL;
381
+ let preferMinimal = FORCE_MINIMAL || !!(cfg && cfg.minimal === true);
382
+ const isHidream = (
383
+ typeof flat.model === 'string' && flat.model.toLowerCase() === 'hidream'
384
+ ) || /hidream/i.test(generateUrl) || /hidream/i.test(String(targetModel || ''));
385
+ const isQwenEdit = (
386
+ typeof flat.model === 'string' && flat.model.toLowerCase() === 'qwen-image-edit'
387
+ ) || /qwen-image-edit/i.test(generateUrl) || /qwen-image-edit/i.test(String(targetModel || ''));
388
+ const isZImageTurbo = (
389
+ typeof flat.model === 'string' && flat.model.toLowerCase() === 'z-image-turbo'
390
+ ) || /z-image-turbo/i.test(generateUrl) || /z-image-turbo/i.test(String(targetModel || ''));
391
 
392
 
393
  const apiToken = req.headers['x-api-key'] || CHUTES_API_TOKEN;
 
397
  ...(apiToken ? { 'Authorization': `Bearer ${apiToken}` } : {})
398
  };
399
 
400
+ // hidream: 优先使用 models.json 的 upstream_url;否则使用 HIDREAM_UPSTREAM_URL;否则保持默认 GENERATE_API_URL
401
+ if (isHidream) {
402
+ const envUrl = process.env.HIDREAM_UPSTREAM_URL || '';
403
+ function isValidUrl(u) {
404
+ try { new URL(u); return true; } catch { return false; }
405
+ }
406
+ if (cfg && cfg.upstream_url && isValidUrl(cfg.upstream_url)) {
407
+ generateUrl = cfg.upstream_url;
408
+ } else if (envUrl && isValidUrl(envUrl)) {
409
+ generateUrl = envUrl;
410
+ }
411
+ }
412
+
413
+ // Normalize special-case upstream ids that differ from local display ids
414
+ // qwen-image-edit 在上游共用 qwen-image 的路由/标识
415
+ if (isQwenEdit && String(targetModel || '').toLowerCase() === 'qwen-image-edit') {
416
+ targetModel = 'qwen-image';
417
+ }
418
+
419
+ // Pass-through extras (e.g., image_b64s, true_cfg_scale, resolution, shift, max_sequence_length, etc.)
420
+ const inputExtras = (body && typeof body.input_args === 'object') ? { ...body.input_args } : {};
421
+ if (isHidream) {
422
+ // hidream 仅接受 resolution;确保存在并移除 width/height 噪声
423
+ if (!inputExtras.resolution) {
424
+ inputExtras.resolution = `${flat.width}x${flat.height}`;
425
+ }
426
+ delete inputExtras.width;
427
+ delete inputExtras.height;
428
+ }
429
+ if (!isQwenEdit) {
430
+ // 非 qwen-image-edit 时不传参考图与 true_cfg_scale
431
+ delete inputExtras.image_b64s;
432
+ delete inputExtras.true_cfg_scale;
433
+ }
434
+ // Z-Image-Turbo 特殊参数处理
435
+ if (isZImageTurbo) {
436
+ // 保留 shift 和 max_sequence_length 参数
437
+ if (inputExtras.shift === undefined) {
438
+ inputExtras.shift = 3.0; // 默认值
439
+ }
440
+ if (inputExtras.max_sequence_length === undefined) {
441
+ inputExtras.max_sequence_length = 512; // 默认值
442
+ }
443
+ }
444
+
445
+ // Build top-level extras for flat payloads (some upstreams expect image_b64s/true_cfg_scale at top-level)
446
+ const topLevelExtras = {};
447
+ const maybeImageB64s = (body && (body.image_b64s ?? (body.input_args && body.input_args.image_b64s)));
448
+ const maybeTrueCfg = (body && (body.true_cfg_scale ?? (body.input_args && body.input_args.true_cfg_scale)));
449
+ if (isQwenEdit) {
450
+ if (maybeImageB64s) topLevelExtras.image_b64s = maybeImageB64s;
451
+ if (maybeTrueCfg !== undefined && maybeTrueCfg !== null) topLevelExtras.true_cfg_scale = maybeTrueCfg;
452
+ }
453
+
454
+ // qwen-image-edit: 校验参考图数量(1-3)
455
+ if (isQwenEdit) {
456
+ const imgs = inputExtras.image_b64s || topLevelExtras.image_b64s;
457
+ const validCount = Array.isArray(imgs) ? imgs.length : 0;
458
+ if (validCount < 1 || validCount > 3) {
459
+ return res.status(400).json({ ok: false, error: 'qwen-image-edit 需要 1-3 张参考图 (image_b64s)' });
460
+ }
461
+ }
462
+
463
+ const commonArgs = {
464
+ prompt: flat.prompt,
465
+ negative_prompt: flat.negative_prompt || '',
466
+ guidance_scale: flat.guidance_scale,
467
+ num_inference_steps: flat.num_inference_steps,
468
+ ...(flat.seed !== null ? { seed: flat.seed } : {})
469
+ };
470
  const variantA = {
471
  model: targetModel,
472
+ input_args: { ...inputExtras, ...commonArgs, width: flat.width, height: flat.height }
 
 
 
 
 
 
 
 
473
  };
474
 
475
  const variantB = {
476
  model: targetModel,
477
+ ...topLevelExtras,
478
+ ...commonArgs,
479
  width: flat.width,
480
+ height: flat.height
481
+ };
482
+
483
+ // Hidream-specific flat payload expected by chutes-hidream endpoint (no input_args)
484
+ const variantHidreamFlat = isHidream ? {
485
+ prompt: flat.prompt,
486
+ resolution: (inputExtras && inputExtras.resolution) ? String(inputExtras.resolution) : `${flat.width}x${flat.height}`,
487
  guidance_scale: flat.guidance_scale,
488
  num_inference_steps: flat.num_inference_steps,
489
  ...(flat.seed !== null ? { seed: flat.seed } : {})
490
+ } : null;
491
+
492
+ // Z-Image-Turbo payload with input_args wrapper (required by the endpoint)
493
+ const variantZImageNested = isZImageTurbo ? {
494
+ input_args: {
495
+ prompt: flat.prompt,
496
+ height: flat.height,
497
+ width: flat.width,
498
+ num_inference_steps: flat.num_inference_steps,
499
+ guidance_scale: flat.guidance_scale,
500
+ shift: (inputExtras && inputExtras.shift !== undefined) ? Number(inputExtras.shift) : 3.0,
501
+ max_sequence_length: (inputExtras && inputExtras.max_sequence_length !== undefined) ? Number(inputExtras.max_sequence_length) : 512,
502
+ ...(flat.seed !== null ? { seed: flat.seed } : {})
503
+ }
504
+ } : null;
505
+
506
+ // Z-Image-Turbo minimal payload (top-level prompt only, as shown in curl example)
507
+ const variantZImageMinimal = isZImageTurbo ? {
508
+ prompt: flat.prompt,
509
+ height: flat.height,
510
+ width: flat.width,
511
+ num_inference_steps: flat.num_inference_steps,
512
+ guidance_scale: flat.guidance_scale,
513
+ shift: (inputExtras && inputExtras.shift !== undefined) ? Number(inputExtras.shift) : 3.0,
514
+ max_sequence_length: (inputExtras && inputExtras.max_sequence_length !== undefined) ? Number(inputExtras.max_sequence_length) : 512,
515
+ ...(flat.seed !== null ? { seed: flat.seed } : {})
516
+ } : null;
517
 
518
  // Minimal payload (some models reject extended fields). Include size/steps/seed for hunyuan-image-3 compatibility
519
  const variantCMinimal = {
 
526
  }
527
  };
528
 
529
+ // Flat minimal payload for model-specific upstreams that expect top-level { prompt }
530
+ const variantFlatMinimal = {
531
+ prompt: flat.prompt,
532
+ size: `${flat.width}x${flat.height}`,
533
+ steps: flat.num_inference_steps,
534
+ ...(flat.seed !== null ? { seed: flat.seed } : {})
535
+ };
536
+
537
+ // Qwen-image-edit: official top-level flat payload (no model, includes refs)
538
+ const variantQwenFlat = isQwenEdit ? {
539
+ prompt: flat.prompt,
540
+ negative_prompt: flat.negative_prompt || '',
541
+ width: flat.width,
542
+ height: flat.height,
543
+ guidance_scale: flat.guidance_scale,
544
+ num_inference_steps: flat.num_inference_steps,
545
+ ...(flat.seed !== null ? { seed: flat.seed } : {}),
546
+ ...(inputExtras && inputExtras.image_b64s ? { image_b64s: inputExtras.image_b64s } : (topLevelExtras.image_b64s ? { image_b64s: topLevelExtras.image_b64s } : {})),
547
+ ...(inputExtras && (inputExtras.true_cfg_scale !== undefined && inputExtras.true_cfg_scale !== null) ? { true_cfg_scale: inputExtras.true_cfg_scale } : (topLevelExtras.true_cfg_scale !== undefined ? { true_cfg_scale: topLevelExtras.true_cfg_scale } : {}))
548
+ } : null;
549
+
550
  // duplicate removed
551
 
552
  async function tryCall(payload, label, url) {
 
560
  return { ok: true, imageBase64: base64, contentType: ctype, tried: label };
561
  }
562
 
563
+ // Success: JSON response that may contain base64 or data URL
564
  if (status >= 200 && status < 300 && /application\/json/i.test(ctype)) {
565
  let raw = '';
566
  try { raw = Buffer.from(resp.data).toString(); } catch (e) {}
567
  try {
568
  const j = JSON.parse(raw || '{}');
569
+ // Common patterns: { image: "data:..."} or { image: "<base64>", contentType: "image/jpeg" } or { data: "<base64>" }
570
  if (j && j.image) {
571
  if (typeof j.image === 'string' && j.image.startsWith('data:')) {
572
+ // Already data URL; extract base64 and contentType
573
  const match = /^data:([^;]+);base64,(.*)$/i.exec(j.image);
574
  if (match) {
575
  return { ok: true, imageBase64: match[2], contentType: match[1], tried: label };
 
584
  return { ok: true, imageBase64: j.data, contentType: ct, tried: label };
585
  }
586
  } catch (e) {
587
+ // fallthrough to error mapping below
588
  }
589
  }
590
 
591
+ // Error handling branch (non-2xx or unrecognized payload)
592
  let raw = '';
593
+ try {
594
+ raw = Buffer.from(resp.data).toString();
595
+ } catch (e) {}
596
+
597
+ // Friendly diagnostics mapping
598
  let code = 'UPSTREAM_ERROR';
599
  let hint = '';
600
  let mappedStatus = status;
 
605
  } catch (e) {
606
  detailText = raw;
607
  }
608
+
609
  const lower = (detailText || '').toLowerCase();
610
  if (lower.includes('exhausted all available targets')) {
611
  code = 'UPSTREAM_CAPACITY_EXHAUSTED';
612
  hint = '上游容量不足(GPU/目标不可用或排队中),请稍后重试、换模型,或降低分辨率/步数。';
613
+ mappedStatus = 503; // service unavailable
614
  } else if (status === 404 && lower.includes('model not found')) {
615
  code = 'UPSTREAM_MODEL_NOT_FOUND';
616
  hint = '上游模型不存在或标识不匹配。请更换模型,或在 data/models.json 为该模型添加正确的 \"upstream_id\" 映射后重试。';
 
627
  }
628
 
629
  let result;
630
+ let lastError = null;
631
+
632
+ const isHunyuan =
633
+ (typeof flat.model === 'string' && flat.model.toLowerCase() === 'hunyuan-image-3') ||
634
+ /hunyuan-image-3/i.test(generateUrl) ||
635
+ /hunyuan-image-3/i.test(String(targetModel || ''));
636
+
637
+ // Model-specific payloads FIRST - try the correct format before generic fallbacks
638
+ if (isHidream) {
639
+ // HiDream: uses resolution instead of width/height
640
+ try {
641
+ result = await tryCall(variantHidreamFlat, 'hidream-flat', generateUrl);
642
+ } catch (eH) { lastError = eH; }
643
+ } else if (isZImageTurbo) {
644
+ // Z-Image-Turbo: try minimal payload first (as shown in curl example)
645
+ try {
646
+ result = await tryCall(variantZImageMinimal, 'z-image-turbo-minimal', generateUrl);
647
+ } catch (e0) { lastError = e0; }
648
+ // Fallback to nested payload
649
+ if (!result && variantZImageNested) {
650
+ try {
651
+ result = await tryCall(variantZImageNested, 'z-image-turbo-nested', generateUrl);
652
+ } catch (eZ) { lastError = eZ; }
653
+ }
654
+ } else if (isQwenEdit) {
655
+ // Qwen-image-edit: try official flat payload first
656
+ try {
657
+ result = await tryCall(variantQwenFlat, 'qwen-flat', generateUrl);
658
+ } catch (eQ) { lastError = eQ; }
659
+ } else if (isHunyuan) {
660
+ // Hunyuan: uses size instead of width/height
661
+ try {
662
+ result = await tryCall(variantFlatMinimal, 'flat-minimal', generateUrl);
663
+ } catch (e0) { lastError = e0; }
664
+ if (!result) {
665
+ try {
666
+ result = await tryCall(variantCMinimal, 'nested-minimal', generateUrl);
667
+ } catch (e1) { lastError = e1; }
668
+ }
669
+ } else if (preferMinimal) {
670
+ // Other models with minimal preference
671
  try {
672
+ result = await tryCall(variantFlatMinimal, 'flat-minimal', generateUrl);
673
+ } catch (e0) { lastError = e0; }
674
+ if (!result) {
675
+ try {
676
+ result = await tryCall(variantCMinimal, 'nested-minimal', generateUrl);
677
+ } catch (e1) { lastError = e1; }
678
+ }
679
  }
680
 
681
+ // Generic fallback payloads - only try if model-specific didn't succeed
682
+ if (!result) {
683
+ try { result = await tryCall(variantA, 'nested', generateUrl); }
684
+ catch (e1) { lastError = e1; }
685
+ }
686
+
687
+ if (!result) {
688
+ try { result = await tryCall(variantB, 'flat', generateUrl); }
689
+ catch (e2) { lastError = e2; }
690
+ }
691
+
692
+ // Final fallback logic
693
+ if (!result) {
694
+ // Auto fallback to another model when capacity/infrastructure errors (disabled when NO_FALLBACK=true)
695
+ const capacityCodes = ['UPSTREAM_CAPACITY_EXHAUSTED','UPSTREAM_NO_INSTANCES','UPSTREAM_INFRASTRUCTURE','UPSTREAM_BAD_GATEWAY'];
696
+ if (AUTO_FALLBACK && !NO_FALLBACK && lastError && capacityCodes.includes(lastError.code || '')) {
697
  function chooseFallback(currentId, list) {
698
  const key = (currentId || '').toLowerCase();
699
  const free = list.filter(m => m.free && (m.id || m.name || '').toLowerCase() !== key);
 
706
  if (fallbackModel && fallbackModel !== targetModel) {
707
  const fbA = { ...variantA, model: fallbackModel };
708
  const fbB = { ...variantB, model: fallbackModel };
 
709
  const fbCfg = getModelConfig(localList, fallbackModel);
710
  const fbUrl = (fbCfg && fbCfg.upstream_url) ? fbCfg.upstream_url : GENERATE_API_URL;
711
+
712
+ try { result = await tryCall(fbA, 'nested-fallback', fbUrl); }
713
+ catch (e3) {
714
+ try { result = await tryCall(fbB, 'flat-fallback', fbUrl); }
715
+ catch (e4) {
 
716
  const status = e4.status || 502;
717
  return res.status(status).json({
718
  ok: false,
 
723
  });
724
  }
725
  }
726
+
727
  // success with fallback
728
  return res.json({
729
  ok: true,
 
744
  });
745
  }
746
  }
747
+
748
+ const status = (lastError && lastError.status) || 502;
749
+ return res.status(status).json({
750
+ ok: false,
751
+ error: (lastError && (lastError.hint || lastError.message)) || 'Upstream error',
752
+ code: (lastError && lastError.code) || 'UPSTREAM_ERROR',
753
+ upstream_model: targetModel
754
+ });
755
  }
756
 
757
  return res.json({