@dsh-plus/image-studio 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -637,10 +637,11 @@ ${extra}
637
637
  {
638
638
  key: "size",
639
639
  kind: "string",
640
+ rules: "image-size",
640
641
  applies: ["generation", "edit"],
641
642
  generation: "current",
642
643
  advanced: false,
643
- description: "尺寸,如 1024x1024 / 1536x1024 / 1024x1536 / auto;宽高需为 16 的倍数、最大边 ≤3840、比例 ≤3:1(依模型限制)"
644
+ description: "尺寸:auto 或「宽x高」。推荐 1024x1024 / 1536x1024 / 1024x1536;自定义需宽高为 16 的倍数、长边 ≤3840、比例 ≤3:1、总像素 655360-8294400"
644
645
  },
645
646
  {
646
647
  key: "quality",
@@ -649,12 +650,14 @@ ${extra}
649
650
  "auto",
650
651
  "low",
651
652
  "medium",
652
- "high"
653
+ "high",
654
+ "xhigh",
655
+ "max"
653
656
  ],
654
657
  applies: ["generation", "edit"],
655
658
  generation: "current",
656
659
  advanced: false,
657
- description: "质量档位"
660
+ description: "质量档位(xhigh/max 为 image 2.5 新增高档位)"
658
661
  },
659
662
  {
660
663
  key: "background",
@@ -764,6 +767,90 @@ ${extra}
764
767
  return key === "model";
765
768
  }
766
769
  //#endregion
770
+ //#region src/params/size.ts
771
+ /** GPT Image 代自定义尺寸规则。 */
772
+ const IMAGE_SIZE_RULE = Object.freeze({
773
+ multiple: 16,
774
+ maxEdge: 3840,
775
+ minPixels: 655360,
776
+ maxPixels: 8294400,
777
+ maxAspect: 3
778
+ });
779
+ /** 免校验取值:实际尺寸由模型按提示词决定。 */
780
+ const SIZE_AUTO = "auto";
781
+ /** 预设尺寸表(顺序即展示序:常用在前;全部满足 16 倍数约束)。 */
782
+ const SIZE_PRESETS = Object.freeze([
783
+ Object.freeze({
784
+ value: SIZE_AUTO,
785
+ label: "auto(由模型决定)"
786
+ }),
787
+ Object.freeze({
788
+ value: "1024x1024",
789
+ label: "1024×1024 方形 1:1"
790
+ }),
791
+ Object.freeze({
792
+ value: "1536x1024",
793
+ label: "1536×1024 横向 3:2"
794
+ }),
795
+ Object.freeze({
796
+ value: "1024x1536",
797
+ label: "1024×1536 纵向 2:3"
798
+ }),
799
+ Object.freeze({
800
+ value: "2048x1152",
801
+ label: "2048×1152 宽屏 16:9"
802
+ }),
803
+ Object.freeze({
804
+ value: "1152x2048",
805
+ label: "1152×2048 竖屏 9:16"
806
+ }),
807
+ Object.freeze({
808
+ value: "3840x2160",
809
+ label: "3840×2160 4K 16:9"
810
+ }),
811
+ Object.freeze({
812
+ value: "2160x3840",
813
+ label: "2160×3840 4K 9:16"
814
+ })
815
+ ]);
816
+ const OK = { ok: true };
817
+ /** 「宽x高」规范形态(小写 x、无空白;`auto` 原样)。 */
818
+ function normalizeImageSize(value) {
819
+ const text = value.trim();
820
+ if (text === "auto") return SIZE_AUTO;
821
+ return text.replace(/[×✕✖]/g, "x").replace(/\s+/g, "").toLowerCase();
822
+ }
823
+ /** 规范化后的「宽x高」形态。 */
824
+ const SIZE_PATTERN = /^(\d{2,5})x(\d{2,5})$/;
825
+ /**
826
+ * 校验一个尺寸取值(`auto` 或「宽x高」,容忍大小写/全角乘号/空白)。
827
+ * @param value - 用户输入或预设值(提交/保存前校验)。
828
+ * @param rule - 尺寸约束(缺省 GPT Image 代规则)。
829
+ */
830
+ function checkImageSize(value, rule = IMAGE_SIZE_RULE) {
831
+ const text = normalizeImageSize(value);
832
+ if (text === "auto") return OK;
833
+ const match = SIZE_PATTERN.exec(text);
834
+ if (match === null) return fail("需为「宽x高」(如 1024x1024)或 auto");
835
+ const width = Number(match[1]);
836
+ const height = Number(match[2]);
837
+ if (width % rule.multiple !== 0 || height % rule.multiple !== 0) return fail(`宽高需为 ${rule.multiple} 的倍数(当前 ${width}x${height})`);
838
+ const long = Math.max(width, height);
839
+ const short = Math.min(width, height);
840
+ if (long > rule.maxEdge) return fail(`长边不能超过 ${rule.maxEdge}px(当前 ${long}px)`);
841
+ if (long / short > rule.maxAspect) return fail(`宽高比不能超过 ${rule.maxAspect}:1`);
842
+ const pixels = width * height;
843
+ if (pixels < rule.minPixels) return fail(`总像素不能少于 ${rule.minPixels}(当前 ${pixels})`);
844
+ if (pixels > rule.maxPixels) return fail(`总像素不能超过 ${rule.maxPixels}(当前 ${pixels})`);
845
+ return OK;
846
+ }
847
+ function fail(message) {
848
+ return {
849
+ ok: false,
850
+ message
851
+ };
852
+ }
853
+ //#endregion
767
854
  //#region src/params/spec.ts
768
855
  /** 归一化失败原因(边界校验用,端点转 invalid-request)。 */
769
856
  var ParamValidationError = class extends Error {
@@ -781,7 +868,7 @@ ${extra}
781
868
  switch (entry.kind) {
782
869
  case "string":
783
870
  if (typeof value !== "string" || value.length === 0) throw new ParamValidationError(key, "必须是非空字符串");
784
- return value;
871
+ return validateRule(entry.rules, key, value);
785
872
  case "enum":
786
873
  if (typeof value !== "string" || !entry.values?.includes(value)) throw new ParamValidationError(key, `必须是 ${entry.values?.join(" | ") ?? "枚举值"} 之一`);
787
874
  return value;
@@ -795,6 +882,18 @@ ${extra}
795
882
  return value;
796
883
  }
797
884
  }
885
+ /**
886
+ * 结构化值域规则分派(目录声明 → 校验实现单一入口)。
887
+ * 无规则的参数原样返回;规则拒绝时错误消息即为面向用户的原因。
888
+ */
889
+ function validateRule(rule, key, value) {
890
+ if (rule === "image-size") {
891
+ const checked = checkImageSize(value);
892
+ if (!checked.ok) throw new ParamValidationError(key, checked.message);
893
+ return normalizeImageSize(value);
894
+ }
895
+ return value;
896
+ }
798
897
  /** 校验预设里的参数表(结构合法但可跨端点;应用时再按端点归一化)。 */
799
898
  function validateParamSpecs(specs) {
800
899
  if (specs === null || typeof specs !== "object" || Array.isArray(specs)) throw new ParamValidationError("(整体)", "必须是对象");
@@ -845,6 +944,27 @@ ${extra}
845
944
  function imageUrl(imageId) {
846
945
  return `${BASE}/images/${imageId}`;
847
946
  }
947
+ /** 上传原图 URL(缩略图直连,非 fetch)。 */
948
+ function uploadImageUrl(uploadId) {
949
+ return `${BASE}/uploads/${uploadId}`;
950
+ }
951
+ /** 上传本地图片:raw body 流(query 携带原文件名,仅展示用)。 */
952
+ async function uploadImage(file) {
953
+ const query = new URLSearchParams({ name: file.name });
954
+ const res = await fetch(`${BASE}/uploads?${query.toString()}`, {
955
+ method: "POST",
956
+ credentials: "same-origin",
957
+ headers: { "content-type": "application/octet-stream" },
958
+ body: file
959
+ });
960
+ const body = await res.json();
961
+ if (!res.ok) throw new Error(body.message ?? body.error ?? `HTTP ${res.status}`);
962
+ return body;
963
+ }
964
+ /** 上传列表(最新在后;界面自行倒序)。 */
965
+ async function fetchUploads() {
966
+ return getJson(`${BASE}/uploads`);
967
+ }
848
968
  /** 凭据状态(describe,永不回传值)。 */
849
969
  function fetchCredentialStatus(refName) {
850
970
  return getJson(`${BASE}/credentials/${refName}`);
@@ -903,7 +1023,8 @@ ${extra}
903
1023
  maxConcurrent: String(value.maxConcurrent),
904
1024
  requestTimeoutMs: String(value.requestTimeoutMs),
905
1025
  proxy: value.proxy,
906
- galleryMaxItems: String(value.galleryMaxItems)
1026
+ galleryMaxItems: String(value.galleryMaxItems),
1027
+ uploadTtlHours: String(value.uploadTtlHours)
907
1028
  };
908
1029
  }
909
1030
  function parseJson(text, label) {
@@ -944,7 +1065,8 @@ ${extra}
944
1065
  maxConcurrent: Math.max(0, Math.floor(Number(draft.maxConcurrent) || 0)),
945
1066
  requestTimeoutMs: Math.max(1e4, Math.floor(Number(draft.requestTimeoutMs) || 3e5)),
946
1067
  proxy: draft.proxy,
947
- galleryMaxItems: Math.max(0, Math.floor(Number(draft.galleryMaxItems) || 0))
1068
+ galleryMaxItems: Math.max(0, Math.floor(Number(draft.galleryMaxItems) || 0)),
1069
+ uploadTtlHours: Math.max(1, Math.floor(Number(draft.uploadTtlHours) || 24))
948
1070
  };
949
1071
  }
950
1072
  /** 单个提供商预设的 API Key 行(describe 徽标 + 设置/删除,值不回显)。 */
@@ -1385,6 +1507,15 @@ ${extra}
1385
1507
  disabled,
1386
1508
  onEdit: (v) => patch({ galleryMaxItems: v })
1387
1509
  })]
1510
+ }),
1511
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TextField, {
1512
+ prefix: "imsc",
1513
+ id: "imsc-upload-ttl",
1514
+ label: t("card.uploadTtlHours"),
1515
+ numeric: true,
1516
+ value: draft.uploadTtlHours,
1517
+ disabled,
1518
+ onEdit: (v) => patch({ uploadTtlHours: v })
1388
1519
  })
1389
1520
  ]
1390
1521
  });
@@ -1444,8 +1575,15 @@ ${extra}
1444
1575
  "sources.mask": "遮罩(可选)",
1445
1576
  "sources.maskPick": "选择遮罩",
1446
1577
  "sources.maskClear": "清除",
1578
+ "sources.maskHint": "遮罩必须是与源图同尺寸的 PNG(透明区域为可重绘范围)",
1579
+ "sources.uploaded": "本地文件上传",
1580
+ "upload.pick": "上传图片",
1581
+ "upload.empty": "暂无上传图片,点上方「上传图片」从本地选择",
1582
+ "upload.failed": "上传失败:",
1447
1583
  "picker.empty": "画廊暂无图片",
1448
1584
  "picker.confirm": "使用所选({n})",
1585
+ "picker.section.gallery": "画廊",
1586
+ "picker.section.uploads": "本地上传",
1449
1587
  "generate.submit": "生成",
1450
1588
  "generate.submitted": "已提交任务 {id},进度见下方任务条",
1451
1589
  "generate.failed": "提交失败:",
@@ -1496,6 +1634,7 @@ ${extra}
1496
1634
  "card.requestTimeoutMs": "单请求超时(毫秒)",
1497
1635
  "card.proxy": "上游代理(空 = 直连)",
1498
1636
  "card.galleryMaxItems": "画廊保留上限(0 = 不清理)",
1637
+ "card.uploadTtlHours": "上传原图保留时长(小时)",
1499
1638
  "card.specsInvalid": "参数表非法:",
1500
1639
  "card.idInvalid": "ID 非法:",
1501
1640
  "card.save": "保存",
@@ -1552,8 +1691,15 @@ ${extra}
1552
1691
  "sources.mask": "Mask (optional)",
1553
1692
  "sources.maskPick": "Pick mask",
1554
1693
  "sources.maskClear": "Clear",
1694
+ "sources.maskHint": "The mask must be a PNG the same size as the source (transparent area is repainted)",
1695
+ "sources.uploaded": "Uploaded from disk",
1696
+ "upload.pick": "Upload image",
1697
+ "upload.empty": "No uploads yet — use \"Upload image\" above to pick a local file",
1698
+ "upload.failed": "Upload failed: ",
1555
1699
  "picker.empty": "No images in the gallery yet",
1556
1700
  "picker.confirm": "Use selected ({n})",
1701
+ "picker.section.gallery": "Gallery",
1702
+ "picker.section.uploads": "Uploads",
1557
1703
  "generate.submit": "Generate",
1558
1704
  "generate.submitted": "Task {id} submitted; progress in the task bar below",
1559
1705
  "generate.failed": "Submit failed: ",
@@ -1604,6 +1750,7 @@ ${extra}
1604
1750
  "card.requestTimeoutMs": "Request timeout (ms)",
1605
1751
  "card.proxy": "Upstream proxy (empty = direct)",
1606
1752
  "card.galleryMaxItems": "Gallery retention (0 = keep all)",
1753
+ "card.uploadTtlHours": "Upload retention (hours)",
1607
1754
  "card.specsInvalid": "Invalid param specs: ",
1608
1755
  "card.idInvalid": "Invalid ID: ",
1609
1756
  "card.save": "Save",
@@ -1790,18 +1937,25 @@ ${extra}
1790
1937
  className: "ims-detailPrompt",
1791
1938
  children: item.prompt
1792
1939
  }),
1793
- item.sourceIds.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
1940
+ item.sourceIds.length + item.sourceUploads.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
1794
1941
  className: "ims-detailLabel",
1795
1942
  children: t("gallery.detail.sources")
1796
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1943
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1797
1944
  className: "ims-sources",
1798
- children: item.sourceIds.map((imageId) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1945
+ children: [item.sourceIds.map((imageId) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1799
1946
  className: "ims-thumb",
1800
1947
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
1801
1948
  src: imageUrl(imageId),
1802
1949
  alt: ""
1803
1950
  })
1804
- }, imageId))
1951
+ }, imageId)), item.sourceUploads.map((uploadId) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1952
+ className: "ims-thumb",
1953
+ title: t("sources.uploaded"),
1954
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
1955
+ src: uploadImageUrl(uploadId),
1956
+ alt: ""
1957
+ })
1958
+ }, uploadId))]
1805
1959
  })] }) : null,
1806
1960
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h4", {
1807
1961
  className: "ims-detailLabel",
@@ -2042,7 +2196,13 @@ ${extra}
2042
2196
  }
2043
2197
  //#endregion
2044
2198
  //#region src/client/panel/params-form.tsx
2045
- /** 单参数行:开关 + 名称/说明 + 值控件。 */
2199
+ /** 结构化规则的即时提示:规则通过或取值未填时不占位。 */
2200
+ function ruleHint(entry, value) {
2201
+ if (entry.rules !== "image-size" || value.trim() === "") return null;
2202
+ const checked = checkImageSize(value);
2203
+ return checked.ok ? null : checked.message;
2204
+ }
2205
+ /** 单参数行:开关 + 名称/说明 + 值控件(+ 预设与校验提示)。 */
2046
2206
  function ParamRow(props) {
2047
2207
  const { t, entry, form, onChange } = props;
2048
2208
  const field = form[entry.key] ?? {
@@ -2056,11 +2216,15 @@ ${extra}
2056
2216
  ...next
2057
2217
  }
2058
2218
  });
2219
+ const text = String(field.value);
2220
+ const hint = field.enabled ? ruleHint(entry, text) : null;
2221
+ /** size 预设:快捷填写,仍可自由改动输入框。 */
2222
+ const presets = entry.rules === "image-size" ? SIZE_PRESETS : [];
2059
2223
  const control = (() => {
2060
2224
  if (entry.kind === "boolean") return null;
2061
2225
  if (entry.kind === "enum") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("select", {
2062
2226
  className: "ims-input ims-paramControl",
2063
- value: String(field.value),
2227
+ value: text,
2064
2228
  disabled: !field.enabled,
2065
2229
  onChange: (event) => patch({ value: event.target.value }),
2066
2230
  children: (entry.values ?? []).map((value) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
@@ -2074,15 +2238,17 @@ ${extra}
2074
2238
  inputMode: "numeric",
2075
2239
  min: entry.min,
2076
2240
  max: entry.max,
2077
- value: String(field.value),
2241
+ value: text,
2078
2242
  disabled: !field.enabled,
2079
2243
  onChange: (event) => patch({ value: event.target.value })
2080
2244
  });
2081
2245
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2082
- className: "ims-input ims-paramControl",
2246
+ className: `ims-input ims-paramControl${hint !== null ? " ims-inputInvalid" : ""}`,
2083
2247
  type: "text",
2084
- value: String(field.value),
2248
+ list: presets.length > 0 ? `ims-size-options-${entry.key}` : void 0,
2249
+ value: text,
2085
2250
  disabled: !field.enabled,
2251
+ "aria-invalid": hint !== null,
2086
2252
  onChange: (event) => patch({ value: event.target.value })
2087
2253
  });
2088
2254
  })();
@@ -2109,18 +2275,45 @@ ${extra}
2109
2275
  }),
2110
2276
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2111
2277
  className: "ims-paramText",
2112
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2113
- className: "ims-paramKey",
2114
- children: [entry.key, entry.generation !== "current" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2115
- className: "ims-paramGen",
2116
- children: entry.generation
2117
- }) : null]
2118
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2119
- className: "ims-paramDesc",
2120
- children: entry.description
2121
- })]
2278
+ children: [
2279
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2280
+ className: "ims-paramKey",
2281
+ children: [entry.key, entry.generation !== "current" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2282
+ className: "ims-paramGen",
2283
+ children: entry.generation
2284
+ }) : null]
2285
+ }),
2286
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2287
+ className: "ims-paramDesc",
2288
+ children: entry.description
2289
+ }),
2290
+ hint !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2291
+ className: "ims-paramWarn",
2292
+ role: "alert",
2293
+ children: hint
2294
+ }) : null
2295
+ ]
2122
2296
  }),
2123
- control
2297
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2298
+ className: "ims-paramValue",
2299
+ children: [control, presets.length > 0 && field.enabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2300
+ className: "ims-paramPresets",
2301
+ children: presets.map((preset) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2302
+ type: "button",
2303
+ title: preset.label,
2304
+ className: `ims-chip${preset.value === text ? " ims-chipOn" : ""}`,
2305
+ onClick: () => patch({ value: preset.value }),
2306
+ children: preset.value
2307
+ }, preset.value))
2308
+ }) : null]
2309
+ }),
2310
+ presets.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("datalist", {
2311
+ id: `ims-size-options-${entry.key}`,
2312
+ children: presets.map((preset) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
2313
+ value: preset.value,
2314
+ label: preset.label
2315
+ }, preset.value))
2316
+ }) : null
2124
2317
  ]
2125
2318
  });
2126
2319
  }
@@ -2161,10 +2354,84 @@ ${extra}
2161
2354
  });
2162
2355
  }
2163
2356
  //#endregion
2357
+ //#region src/client/panel/uploads.tsx
2358
+ /**
2359
+ * 本地文件上传的浏览器半:列表/上传/错误态封装为 hook,控件供生图表单与
2360
+ * 图片选择器复用。上传为串行(逐个文件),首个失败即中断并展示原因;
2361
+ * 成功后条目即时并入本地列表(选择器无需刷新即可见)。
2362
+ * @module image-studio/client/panel/uploads
2363
+ */
2364
+ /** 可接受的上传类型(宿主端另有魔数嗅探兜底)。 */
2365
+ const ACCEPT = "image/png,image/jpeg,image/webp,image/gif";
2366
+ /** 上传暂存区读写 hook(每个消费组件一份,互不共享状态)。 */
2367
+ function useUploads() {
2368
+ const [items, setItems] = (0, react.useState)([]);
2369
+ const [busy, setBusy] = (0, react.useState)(false);
2370
+ const [error, setError] = (0, react.useState)(null);
2371
+ const reload = (0, react.useCallback)(() => {
2372
+ setBusy(true);
2373
+ fetchUploads().then((res) => setItems(res.items)).catch(() => setItems([])).finally(() => setBusy(false));
2374
+ }, []);
2375
+ return {
2376
+ items,
2377
+ busy,
2378
+ error,
2379
+ add: (0, react.useCallback)(async (files) => {
2380
+ const list = Array.from(files);
2381
+ if (list.length === 0) return [];
2382
+ setBusy(true);
2383
+ setError(null);
2384
+ const saved = [];
2385
+ try {
2386
+ for (const file of list) {
2387
+ const entry = await uploadImage(file);
2388
+ saved.push(entry);
2389
+ }
2390
+ setItems((current) => [...current, ...saved]);
2391
+ return saved;
2392
+ } catch (err) {
2393
+ setError(err instanceof Error ? err.message : String(err));
2394
+ return saved;
2395
+ } finally {
2396
+ setBusy(false);
2397
+ }
2398
+ }, []),
2399
+ reload
2400
+ };
2401
+ }
2402
+ /** 隐藏 file input + 触发按钮(移动端显式 accept 图像类型,避免退化为相册单选)。 */
2403
+ function UploadButton(props) {
2404
+ const { t, uploads, onUploaded, multiple, disabled, className, children } = props;
2405
+ const inputRef = (0, react.useRef)(null);
2406
+ const onChange = (event) => {
2407
+ const files = event.target.files;
2408
+ if (files !== null && files.length > 0) uploads.add(files).then((entries) => {
2409
+ if (entries.length > 0) onUploaded(entries);
2410
+ });
2411
+ event.target.value = "";
2412
+ };
2413
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2414
+ type: "button",
2415
+ className: className ?? "ims-btn ims-btnGhost",
2416
+ disabled: disabled === true || uploads.busy,
2417
+ onClick: () => inputRef.current?.click(),
2418
+ children
2419
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2420
+ ref: inputRef,
2421
+ type: "file",
2422
+ hidden: true,
2423
+ multiple,
2424
+ accept: props.accept ?? ACCEPT,
2425
+ "aria-label": t("upload.pick"),
2426
+ onChange
2427
+ })] });
2428
+ }
2429
+ //#endregion
2164
2430
  //#region src/client/panel/picker.tsx
2165
2431
  /**
2166
- * 画廊图片选择器(模态):源图多选(≤16)与遮罩单选共用。
2167
- * 扁平化全部画廊条目的图片,倒序排列;底部确认条显示已选数量。
2432
+ * 源图选择器(模态):源图多选(≤MAX_SOURCE_IMAGES)与遮罩单选共用。
2433
+ * 两个来源分区——画廊(已生成图片)与本地上传(暂存区),可混合选择;
2434
+ * 选中态以 SourceRef 记录(kind 区分 id 空间),底部确认条显示已选数量。
2168
2435
  * @module image-studio/client/panel/picker
2169
2436
  */
2170
2437
  function flattenImages(items) {
@@ -2176,17 +2443,66 @@ ${extra}
2176
2443
  });
2177
2444
  return out;
2178
2445
  }
2446
+ /** 引用键(选中判定与去重;两个 id 空间不重叠但不互相假设)。 */
2447
+ function refKey(ref) {
2448
+ return `${ref.kind}:${ref.id}`;
2449
+ }
2450
+ /** 缩略图地址(画廊 / 上传两套端点)。 */
2451
+ function refUrl(ref) {
2452
+ return ref.kind === "gallery" ? imageUrl(ref.id) : uploadImageUrl(ref.id);
2453
+ }
2179
2454
  function ImagePicker(props) {
2180
- const { t, title, items, multiple, initial, onConfirm, onClose } = props;
2455
+ const { t, title, items, uploads, multiple, initial, onConfirm, onClose } = props;
2181
2456
  const [selected, setSelected] = (0, react.useState)(initial);
2457
+ const [section, setSection] = (0, react.useState)(uploads.items.length > 0 && items.length === 0 ? "uploads" : "gallery");
2182
2458
  const images = flattenImages(items);
2183
- const toggle = (imageId) => {
2459
+ const uploadItems = [...uploads.items].reverse();
2460
+ const selectedKeys = selected.map(refKey);
2461
+ const toggle = (ref) => {
2462
+ const key = refKey(ref);
2184
2463
  if (multiple) {
2185
- setSelected((current) => current.includes(imageId) ? current.filter((id) => id !== imageId) : current.length >= 16 ? current : [...current, imageId]);
2464
+ setSelected((current) => current.some((item) => refKey(item) === key) ? current.filter((item) => refKey(item) !== key) : current.length >= 16 ? current : [...current, ref]);
2186
2465
  return;
2187
2466
  }
2188
- setSelected((current) => current.includes(imageId) ? [] : [imageId]);
2467
+ setSelected((current) => current.some((item) => refKey(item) === key) ? [] : [ref]);
2468
+ };
2469
+ /** 新上传条目直接选中(少一次点击;超限按剩余额度截断)。 */
2470
+ const selectUploaded = (entries) => {
2471
+ const refs = entries.map((entry) => ({
2472
+ kind: "upload",
2473
+ id: entry.id
2474
+ }));
2475
+ setSelected((current) => multiple ? [...current, ...refs].slice(0, 16) : refs);
2476
+ setSection("uploads");
2189
2477
  };
2478
+ const grid = (cells, empty) => cells.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2479
+ className: "ims-empty",
2480
+ children: empty
2481
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2482
+ className: "ims-pickerGrid",
2483
+ role: "listbox",
2484
+ "aria-multiselectable": multiple,
2485
+ children: cells.map((cell) => {
2486
+ const index = selectedKeys.indexOf(refKey(cell.ref));
2487
+ const on = index >= 0;
2488
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2489
+ type: "button",
2490
+ role: "option",
2491
+ "aria-selected": on,
2492
+ className: `ims-pickerCell${on ? " ims-pickerCellOn" : ""}`,
2493
+ title: cell.title,
2494
+ onClick: () => toggle(cell.ref),
2495
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
2496
+ src: refUrl(cell.ref),
2497
+ alt: cell.title,
2498
+ loading: "lazy"
2499
+ }), on ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2500
+ className: "ims-pickerMark",
2501
+ children: index + 1
2502
+ }) : null]
2503
+ }, refKey(cell.ref));
2504
+ })
2505
+ });
2190
2506
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Modal, {
2191
2507
  open: true,
2192
2508
  onClose,
@@ -2198,43 +2514,69 @@ ${extra}
2198
2514
  children: [
2199
2515
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2200
2516
  className: "ims-subHead",
2201
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
2202
- className: "ims-subTitle",
2203
- children: title
2517
+ children: [
2518
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
2519
+ className: "ims-subTitle",
2520
+ children: title
2521
+ }),
2522
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(UploadButton, {
2523
+ t,
2524
+ uploads,
2525
+ multiple,
2526
+ accept: multiple ? void 0 : "image/png",
2527
+ onUploaded: selectUploaded,
2528
+ className: "ims-btn ims-btnPrimary ims-btnSmall",
2529
+ children: [
2530
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconPlusOutline16, { size: 14 }),
2531
+ " ",
2532
+ t("upload.pick")
2533
+ ]
2534
+ }),
2535
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2536
+ type: "button",
2537
+ className: "ims-btn ims-btnGhost",
2538
+ onClick: onClose,
2539
+ children: t("common.close")
2540
+ })
2541
+ ]
2542
+ }),
2543
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2544
+ className: "ims-seg",
2545
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2546
+ type: "button",
2547
+ className: `ims-segItem${section === "gallery" ? " ims-segItemOn" : ""}`,
2548
+ "aria-pressed": section === "gallery",
2549
+ onClick: () => setSection("gallery"),
2550
+ children: t("picker.section.gallery")
2204
2551
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2205
2552
  type: "button",
2206
- className: "ims-btn ims-btnGhost",
2207
- onClick: onClose,
2208
- children: t("common.close")
2553
+ className: `ims-segItem${section === "uploads" ? " ims-segItemOn" : ""}`,
2554
+ "aria-pressed": section === "uploads",
2555
+ onClick: () => {
2556
+ uploads.reload();
2557
+ setSection("uploads");
2558
+ },
2559
+ children: t("picker.section.uploads")
2209
2560
  })]
2210
2561
  }),
2211
- images.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2212
- className: "ims-empty",
2213
- children: t("picker.empty")
2214
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2215
- className: "ims-pickerGrid",
2216
- role: "listbox",
2217
- "aria-multiselectable": multiple,
2218
- children: images.map((image) => {
2219
- const on = selected.includes(image.imageId);
2220
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2221
- type: "button",
2222
- role: "option",
2223
- "aria-selected": on,
2224
- className: `ims-pickerCell${on ? " ims-pickerCellOn" : ""}`,
2225
- title: image.prompt,
2226
- onClick: () => toggle(image.imageId),
2227
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
2228
- src: imageUrl(image.imageId),
2229
- alt: image.prompt,
2230
- loading: "lazy"
2231
- }), on ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2232
- className: "ims-pickerMark",
2233
- children: selected.indexOf(image.imageId) + 1
2234
- }) : null]
2235
- }, image.imageId);
2236
- })
2237
- }),
2562
+ section === "gallery" ? grid(images.map((image) => ({
2563
+ ref: {
2564
+ kind: "gallery",
2565
+ id: image.imageId
2566
+ },
2567
+ title: image.prompt
2568
+ })), t("picker.empty")) : grid(uploadItems.map((entry) => ({
2569
+ ref: {
2570
+ kind: "upload",
2571
+ id: entry.id
2572
+ },
2573
+ title: entry.name
2574
+ })), t("upload.empty")),
2575
+ uploads.error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2576
+ className: "ims-status ims-statusError",
2577
+ role: "alert",
2578
+ children: [t("upload.failed"), uploads.error]
2579
+ }) : null,
2238
2580
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2239
2581
  className: "ims-subFoot",
2240
2582
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -2261,7 +2603,7 @@ ${extra}
2261
2603
  * - 提供商预设选择 + 凭据状态徽标 + inline 覆盖(凭据沿用预设);
2262
2604
  * - 提示词编辑 + 预设插入/另存;
2263
2605
  * - 目录驱动的参数表单 + 参数预设应用/另存;
2264
- * - 图生图追加源图(≤16)与可选遮罩选择(画廊取图);
2606
+ * - 图生图追加源图(≤16,画廊选择与本地文件上传混用)与可选遮罩;
2265
2607
  * - 提交 generate → 202 taskId 交由面板任务条轮询。
2266
2608
  * 预设另存走 settings RPC(scope/api 注入),成功后经 onPresetSaved 刷新快照。
2267
2609
  * @module image-studio/client/panel/generator
@@ -2286,8 +2628,8 @@ ${extra}
2286
2628
  const [credentialOk, setCredentialOk] = (0, react.useState)(null);
2287
2629
  const [prompt, setPrompt] = (0, react.useState)("");
2288
2630
  const [form, setForm] = (0, react.useState)({});
2289
- const [sourceIds, setSourceIds] = (0, react.useState)([]);
2290
- const [maskId, setMaskId] = (0, react.useState)(null);
2631
+ const [sources, setSources] = (0, react.useState)([]);
2632
+ const [mask, setMask] = (0, react.useState)(null);
2291
2633
  const [picker, setPicker] = (0, react.useState)(null);
2292
2634
  const [naming, setNaming] = (0, react.useState)(null);
2293
2635
  const [status, setStatus] = (0, react.useState)(IDLE);
@@ -2296,6 +2638,7 @@ ${extra}
2296
2638
  const formReadyRef = (0, react.useRef)(false);
2297
2639
  /** 已消费的种子 nonce(防重复回填)。 */
2298
2640
  const seedRef = (0, react.useRef)(0);
2641
+ const uploads = useUploads();
2299
2642
  (0, react.useEffect)(() => {
2300
2643
  if (formReadyRef.current || catalog.length === 0) return;
2301
2644
  formReadyRef.current = true;
@@ -2306,7 +2649,7 @@ ${extra}
2306
2649
  if (seed === null || seed.nonce === seedRef.current || !formReadyRef.current) return;
2307
2650
  seedRef.current = seed.nonce;
2308
2651
  setPrompt(seed.prompt);
2309
- setSourceIds(seed.sourceIds);
2652
+ setSources(seed.sources);
2310
2653
  setForm(applyParams(catalog, endpoint, seed.params));
2311
2654
  if (seed.providerPresetId !== null) setProviderId(seed.providerPresetId);
2312
2655
  setStatus(IDLE);
@@ -2400,7 +2743,7 @@ ${extra}
2400
2743
  });
2401
2744
  return;
2402
2745
  }
2403
- if (endpoint === "edit" && sourceIds.length === 0) {
2746
+ if (endpoint === "edit" && sources.length === 0) {
2404
2747
  setStatus({
2405
2748
  kind: "error",
2406
2749
  text: t("generate.needSources")
@@ -2429,8 +2772,8 @@ ${extra}
2429
2772
  model: inline.model.trim()
2430
2773
  };
2431
2774
  if (endpoint === "edit") {
2432
- request.sourceIds = sourceIds;
2433
- if (maskId !== null) request.maskId = maskId;
2775
+ request.sources = sources;
2776
+ if (mask !== null) request.mask = mask;
2434
2777
  }
2435
2778
  setSubmitting(true);
2436
2779
  setStatus(IDLE);
@@ -2598,45 +2941,66 @@ ${extra}
2598
2941
  }),
2599
2942
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2600
2943
  className: "ims-sources",
2601
- children: [sourceIds.map((imageId) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2602
- className: "ims-thumb",
2603
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
2604
- src: imageUrl(imageId),
2605
- alt: ""
2606
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2944
+ children: [
2945
+ sources.map((ref) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2946
+ className: "ims-thumb",
2947
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
2948
+ src: refUrl(ref),
2949
+ alt: ""
2950
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2951
+ type: "button",
2952
+ className: "ims-thumbRemove",
2953
+ "aria-label": t("common.delete"),
2954
+ onClick: () => setSources((current) => current.filter((item) => !(item.kind === ref.kind && item.id === ref.id))),
2955
+ children: "×"
2956
+ })]
2957
+ }, `${ref.kind}:${ref.id}`)),
2958
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2607
2959
  type: "button",
2608
- className: "ims-thumbRemove",
2609
- "aria-label": t("common.delete"),
2610
- onClick: () => setSourceIds((current) => current.filter((id) => id !== imageId)),
2611
- children: "×"
2612
- })]
2613
- }, imageId)), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2614
- type: "button",
2615
- className: "ims-thumbAdd",
2616
- onClick: () => setPicker("sources"),
2617
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconLayers, { size: 18 }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sources.pick") })]
2618
- })]
2960
+ className: "ims-thumbAdd",
2961
+ disabled: sources.length >= 16,
2962
+ onClick: () => setPicker("sources"),
2963
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconLayers, { size: 18 }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sources.pick") })]
2964
+ }),
2965
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(UploadButton, {
2966
+ t,
2967
+ uploads,
2968
+ multiple: true,
2969
+ disabled: sources.length >= 16,
2970
+ className: "ims-thumbAdd",
2971
+ onUploaded: (entries) => setSources((current) => [...current, ...entries.map((entry) => ({
2972
+ kind: "upload",
2973
+ id: entry.id
2974
+ }))].slice(0, 16)),
2975
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconPlusOutline16, { size: 18 }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("upload.pick") })]
2976
+ })
2977
+ ]
2619
2978
  }),
2620
2979
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2621
2980
  className: "ims-hint",
2622
- children: t("sources.count").replace("{n}", String(sourceIds.length))
2981
+ children: t("sources.count").replace("{n}", String(sources.length))
2623
2982
  }),
2983
+ uploads.error !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
2984
+ className: "ims-status ims-statusError",
2985
+ role: "alert",
2986
+ children: [t("upload.failed"), uploads.error]
2987
+ }) : null,
2624
2988
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
2625
2989
  className: "ims-blockTitle",
2626
2990
  children: t("sources.mask")
2627
2991
  }),
2628
2992
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2629
2993
  className: "ims-sources",
2630
- children: [maskId !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2994
+ children: [mask !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2631
2995
  className: "ims-thumb",
2632
2996
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
2633
- src: imageUrl(maskId),
2997
+ src: refUrl(mask),
2634
2998
  alt: ""
2635
2999
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2636
3000
  type: "button",
2637
3001
  className: "ims-thumbRemove",
2638
3002
  "aria-label": t("sources.maskClear"),
2639
- onClick: () => setMaskId(null),
3003
+ onClick: () => setMask(null),
2640
3004
  children: "×"
2641
3005
  })]
2642
3006
  }) : null, /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
@@ -2645,6 +3009,10 @@ ${extra}
2645
3009
  onClick: () => setPicker("mask"),
2646
3010
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconLayers, { size: 18 }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("sources.maskPick") })]
2647
3011
  })]
3012
+ }),
3013
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3014
+ className: "ims-hint",
3015
+ children: t("sources.maskHint")
2648
3016
  })
2649
3017
  ]
2650
3018
  }) : null
@@ -2746,11 +3114,12 @@ ${extra}
2746
3114
  t,
2747
3115
  title: t(picker === "sources" ? "sources.label" : "sources.mask"),
2748
3116
  items: gallery,
3117
+ uploads,
2749
3118
  multiple: picker === "sources",
2750
- initial: picker === "sources" ? sourceIds : [],
2751
- onConfirm: (ids) => {
2752
- if (picker === "sources") setSourceIds(ids);
2753
- else setMaskId(ids[0] ?? null);
3119
+ initial: picker === "sources" ? sources : mask === null ? [] : [mask],
3120
+ onConfirm: (refs) => {
3121
+ if (picker === "sources") setSources(refs);
3122
+ else setMask(refs[0] ?? null);
2754
3123
  setPicker(null);
2755
3124
  },
2756
3125
  onClose: () => setPicker(null)
@@ -2980,7 +3349,13 @@ ${extra}
2980
3349
  failed: galleryFailed,
2981
3350
  onRefresh: reloadGallery,
2982
3351
  onEdit: (item) => studio.seedEdit({
2983
- sourceIds: [...item.imageIds],
3352
+ sources: [...item.imageIds.map((id) => ({
3353
+ kind: "gallery",
3354
+ id
3355
+ })), ...item.sourceUploads.map((id) => ({
3356
+ kind: "upload",
3357
+ id
3358
+ }))],
2984
3359
  prompt: item.prompt,
2985
3360
  params: item.params,
2986
3361
  providerPresetId: item.providerPresetId
@@ -3243,6 +3618,32 @@ ${extra}
3243
3618
  white-space: nowrap;
3244
3619
  }
3245
3620
  .ims-paramControl { width: 132px; flex: none; min-height: 30px; padding: 4px 8px; }
3621
+ .ims-inputInvalid {
3622
+ border-color: var(--dsw-alias-state-error-primary);
3623
+ }
3624
+ .ims-paramValue { display: grid; gap: 4px; justify-items: end; }
3625
+ .ims-paramPresets { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
3626
+ .ims-chip {
3627
+ padding: 1px 6px;
3628
+ border: 1px solid var(--dsw-alias-border-l2);
3629
+ border-radius: 999px;
3630
+ background: transparent;
3631
+ color: var(--dsw-alias-label-secondary);
3632
+ font-family: var(--ds-font-family-code);
3633
+ font-size: 10px;
3634
+ line-height: 16px;
3635
+ cursor: pointer;
3636
+ }
3637
+ .ims-chip:hover { background: var(--dsw-alias-interactive-bg-hover); }
3638
+ .ims-chipOn {
3639
+ border-color: var(--dsw-alias-brand-primary);
3640
+ color: var(--dsw-alias-brand-primary);
3641
+ }
3642
+ .ims-paramWarn {
3643
+ margin-top: 2px;
3644
+ color: var(--dsw-alias-state-error-primary);
3645
+ font-size: 11px;
3646
+ }
3246
3647
  .ims-adv { margin-top: 8px; }
3247
3648
  .ims-advSummary {
3248
3649
  cursor: pointer;
@@ -3369,6 +3770,30 @@ ${extra}
3369
3770
  }
3370
3771
  .ims-subFoot .ims-status { margin: 0 auto 0 0; }
3371
3772
 
3773
+ /* 选择器来源切换(画廊 / 本地上传) */
3774
+ .ims-seg {
3775
+ display: flex;
3776
+ flex: none;
3777
+ gap: 4px;
3778
+ padding: 8px 14px 0;
3779
+ }
3780
+ .ims-segItem {
3781
+ padding: 4px 10px;
3782
+ border: 1px solid transparent;
3783
+ border-radius: 999px;
3784
+ background: transparent;
3785
+ color: var(--dsw-alias-label-secondary);
3786
+ font-size: 12px;
3787
+ cursor: pointer;
3788
+ }
3789
+ .ims-segItem:hover { background: var(--dsw-alias-interactive-bg-hover); }
3790
+ .ims-segItemOn {
3791
+ border-color: var(--dsw-alias-border-l2);
3792
+ background: var(--dsw-alias-bg-layer-1);
3793
+ color: var(--dsw-alias-label-primary);
3794
+ font-weight: 600;
3795
+ }
3796
+
3372
3797
  /* 选择器网格 */
3373
3798
  .ims-pickerGrid {
3374
3799
  flex: 1;