初始化 Photoshop AI 编辑面板

This commit is contained in:
hanhan
2026-08-03 19:37:45 +08:00
commit c4127c7820
22 changed files with 5265 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.debug
*.log
.DS_Store
Thumbs.db
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+107
View File
@@ -0,0 +1,107 @@
# AI Edit
Photoshop CEP panel for image generation. It sends text prompts to your own image API, previews the result, and places generated PNGs into the active document as new masked layers.
## Architecture
| Layer | File | Role |
| --- | --- | --- |
| Panel UI | `src/panels/ImageGenerator.jsx` | React + Tailwind, runs in the CEP CEF panel |
| Bridge | `src/cep.js` | Node file/network access and `evalScript` calls |
| Host | `cep/host/host.jsx` | ExtendScript: masked layer placement, file and prompt dialogs |
| Manifest | `cep/CSXS/manifest.xml` | CEP extension declaration |
Node runs in the panel (`--enable-nodejs --mixed-context`), so temp files and HTTP
both go through Node rather than UXP storage or `fetch`.
## Styling
Tailwind CSS 3, compiled by PostCSS into `dist/styles/panel.css`. Utility classes
carry the exact pixel values from the original design, so arbitrary values
(`h-[47px]`, `bg-[#2f2f2f]`) are used throughout rather than the default scale.
- `tailwind.config.js` — accent color, the rounded font stack, and the `narrow` (max 340px) breakpoint
- `src/index.css` — Tailwind entry plus base rules for `html`, `body`, and `#root`
`src/styles.css` and `src/panels/ImageGenerator.css` are the pre-Tailwind
originals. Nothing imports them; they are kept as the reference for the
original pixel values.
## Prompt Input
The prompt box is a `contentEditable` div. CEF does not reliably receive IME
composition events, so the button above 词库 opens a native Photoshop
ScriptUI window for typing Chinese. Text crosses `evalScript` as
`escape()`-encoded ASCII in both directions.
## API Shape
The default request is compatible with OpenAI-style image endpoints:
```json
{
"model": "gpt-image-2",
"prompt": "your prompt",
"n": 1,
"size": "1024x1024",
"response_format": "b64_json"
}
```
Accepted response shapes:
```json
{ "data": [{ "b64_json": "..." }] }
```
```json
{ "data": [{ "url": "https://..." }] }
```
`output` and `images` arrays are also tolerated for gateway variants.
## Photoshop Placement
Generated images first appear only in the panel's thumbnail list. Clicking a
thumbnail places it as an embedded smart-object layer with a new layer mask.
The selection that existed when generation started is saved as a temporary
alpha channel, so the mask still uses the original selection even if the user
changes the selection before clicking. Without a selection, the layer gets a
reveal-all mask.
When a selection exists, its merged visible pixels are exported as the first
reference image. Uploaded reference files follow it. The panel sends these
images as data URLs in the request body's `images` array.
The request size is read from Photoshop: the full canvas dimensions are used
without a selection, and the selection bounds are used when a selection exists.
## Build
```bash
npm install
npm run build
```
Output goes to `dist`, which is the extension folder itself (`dist/CSXS/manifest.xml`).
## Install
```bash
npm run cep:setup
```
This enables `PlayerDebugMode` for CSXS 912 and links `dist` into the Adobe
extensions folder. On Windows it writes to `HKCU\Software\Adobe\CSXS.*` and
creates a directory junction; on macOS it uses `defaults write` and a symlink.
Restart Photoshop, then open the panel from `Plugins`.
## Development
```bash
npm run watch
```
Reload the panel from its flyout menu, or open `http://localhost:8088` for
Chrome DevTools (the port comes from `cep/.debug`).
+61
View File
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<ExtensionManifest Version="9.0"
ExtensionBundleId="com.aiedit.panel"
ExtensionBundleName="AI Edit"
ExtensionBundleVersion="1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ExtensionList>
<Extension Id="com.aiedit.panel.main" Version="1.0.0" />
</ExtensionList>
<ExecutionEnvironment>
<HostList>
<Host Name="PHXS" Version="[20.0,99.9]" />
<Host Name="PHSP" Version="[20.0,99.9]" />
</HostList>
<LocaleList>
<Locale Code="All" />
</LocaleList>
<RequiredRuntimeList>
<RequiredRuntime Name="CSXS" Version="9.0" />
</RequiredRuntimeList>
</ExecutionEnvironment>
<DispatchInfoList>
<Extension Id="com.aiedit.panel.main">
<DispatchInfo>
<Resources>
<MainPath>./index.html</MainPath>
<ScriptPath>./host/host.jsx</ScriptPath>
<CEFCommandLine>
<Parameter>--enable-nodejs</Parameter>
<Parameter>--mixed-context</Parameter>
</CEFCommandLine>
</Resources>
<Lifecycle>
<AutoVisible>true</AutoVisible>
</Lifecycle>
<UI>
<Type>Panel</Type>
<Menu>AI 生图</Menu>
<Geometry>
<Size>
<Height>876</Height>
<Width>390</Width>
</Size>
<MinSize>
<Height>420</Height>
<Width>300</Width>
</MinSize>
<MaxSize>
<Height>2000</Height>
<Width>2000</Width>
</MaxSize>
</Geometry>
<Icons>
<Icon Type="Normal">./icons/icon_N.png</Icon>
<Icon Type="DarkNormal">./icons/icon_D.png</Icon>
</Icons>
</UI>
</DispatchInfo>
</Extension>
</DispatchInfoList>
</ExtensionManifest>
+281
View File
@@ -0,0 +1,281 @@
// ExtendScript host for the AI Edit CEP panel.
// ASCII only: ExtendScript guesses this file's encoding, so all localized
// strings are passed in from the panel instead of written here.
function aiEditHasDocument() {
try {
return app.documents.length > 0 ? "yes" : "no";
} catch (e) {
return "no";
}
}
function aiEditShowComposite(doc) {
try {
doc.activeChannels = doc.componentChannels;
} catch (e) {
try {
doc.activeChannels = [doc.channels[0]];
} catch (fallbackError) {}
}
}
function aiEditPickImages(title, filter) {
try {
var picked = File.openDialog(title, filter, true);
if (!picked) return "";
if (!(picked instanceof Array)) picked = [picked];
var paths = [];
for (var i = 0; i < picked.length; i++) paths.push(picked[i].fsName);
return paths.join("\n");
} catch (e) {
return "";
}
}
// A native window is the reliable way to type CJK: the CEF panel does not
// always receive IME composition events.
function aiEditPromptDialog(title, label, okText, cancelText, initial) {
try {
var dialog = new Window("dialog", title);
dialog.alignChildren = "fill";
dialog.margins = 14;
dialog.spacing = 10;
dialog.add("statictext", undefined, label);
var field = dialog.add("edittext", undefined, initial || "", {
multiline: true,
wantReturn: true
});
field.preferredSize = [420, 150];
var row = dialog.add("group");
row.alignment = "right";
row.spacing = 8;
var cancel = row.add("button", undefined, cancelText, { name: "cancel" });
var confirm = row.add("button", undefined, okText, { name: "ok" });
var text = null;
confirm.onClick = function () {
text = field.text;
dialog.close(1);
};
cancel.onClick = function () {
dialog.close(0);
};
field.active = true;
if (dialog.show() !== 1 || text === null) return "cancel";
// escape() keeps the result ASCII so evalScript can carry it back.
return "ok:" + escape(text);
} catch (e) {
return "cancel";
}
}
function aiEditGetGenerationSize() {
try {
if (app.documents.length === 0) return "";
var doc = app.activeDocument;
var width = doc.width.as("px");
var height = doc.height.as("px");
try {
var bounds = doc.selection.bounds;
width = bounds[2].as("px") - bounds[0].as("px");
height = bounds[3].as("px") - bounds[1].as("px");
} catch (noSelection) {}
return Math.max(1, Math.round(width)) + "x" + Math.max(1, Math.round(height));
} catch (e) {
return "";
}
}
function aiEditCaptureSelection() {
var doc = null;
var reference = null;
try {
if (app.documents.length === 0) return "none";
doc = app.activeDocument;
aiEditShowComposite(doc);
var bounds;
try {
bounds = doc.selection.bounds;
} catch (selectionError) {
return "none";
}
var token = "AI Edit Selection " + new Date().getTime();
var channel = doc.channels.add();
channel.name = token;
doc.selection.store(channel);
aiEditShowComposite(doc);
var width = Math.max(1, Math.round(bounds[2].as("px") - bounds[0].as("px")));
var height = Math.max(1, Math.round(bounds[3].as("px") - bounds[1].as("px")));
var boundsText = [
Math.round(bounds[0].as("px")),
Math.round(bounds[1].as("px")),
width,
height
].join(",");
// Avoid copy/copyMerged: Photoshop reports that command as unavailable
// for some active layers and documents. A duplicate keeps the merged
// visible pixels and can be cropped without using the clipboard.
reference = doc.duplicate("AI Edit Reference", false);
reference.flatten();
// Do not call selection.clear here. Photoshop rejects that command
// when the duplicated document resolves to a locked background layer.
// Cropping to the captured bounds keeps the selected image usable as
// a reference without changing pixels through the clipboard/delete UI.
reference.crop(bounds);
var file = new File(Folder.temp.fsName + "/ai-edit-selection-" + new Date().getTime() + ".png");
var options = new PNGSaveOptions();
reference.saveAs(file, options, true, Extension.LOWERCASE);
reference.close(SaveOptions.DONOTSAVECHANGES);
app.activeDocument = doc;
aiEditShowComposite(doc);
return "ok:" + token + "|" + file.fsName + "|" + boundsText;
} catch (e) {
try {
if (reference) reference.close(SaveOptions.DONOTSAVECHANGES);
if (doc) {
app.activeDocument = doc;
aiEditShowComposite(doc);
}
} catch (closeError) {}
return "error:" + e.toString();
}
}
function aiEditAddLayerMask(hasSelection) {
var classicMask = new ActionDescriptor();
classicMask.putClass(charIDToTypeID("Nw "), charIDToTypeID("Chnl"));
var classicTarget = new ActionReference();
classicTarget.putEnumerated(
charIDToTypeID("Chnl"),
charIDToTypeID("Chnl"),
charIDToTypeID("Msk ")
);
classicMask.putReference(charIDToTypeID("At "), classicTarget);
classicMask.putEnumerated(
charIDToTypeID("Usng"),
charIDToTypeID("UsrM"),
charIDToTypeID(hasSelection ? "RvlS" : "RvlA")
);
try {
executeAction(charIDToTypeID("Mk "), classicMask, DialogModes.NO);
return;
} catch (classicError) {}
var modernMask = new ActionDescriptor();
var newChannel = new ActionReference();
newChannel.putClass(stringIDToTypeID("channel"));
modernMask.putReference(charIDToTypeID("null"), newChannel);
var currentLayer = new ActionReference();
currentLayer.putEnumerated(
stringIDToTypeID("layer"),
stringIDToTypeID("ordinal"),
stringIDToTypeID("targetEnum")
);
modernMask.putReference(charIDToTypeID("At "), currentLayer);
modernMask.putEnumerated(
stringIDToTypeID("using"),
stringIDToTypeID("userMaskEnabled"),
stringIDToTypeID(hasSelection ? "revealSelection" : "revealAll")
);
executeAction(charIDToTypeID("Mk "), modernMask, DialogModes.NO);
}
function aiEditPlaceImage(filePath, layerName, channelName, boundsText) {
try {
if (app.documents.length === 0) return "error:missing-document";
var file = new File(filePath);
if (!file.exists) return "error:missing-file";
var doc = app.activeDocument;
var selectionChannel = null;
var restoreChannel = null;
var hadCurrentSelection = false;
var hasSelection = false;
if (channelName) {
try {
try {
doc.selection.bounds;
hadCurrentSelection = true;
restoreChannel = doc.channels.add();
restoreChannel.name = "AI Edit Restore " + new Date().getTime();
doc.selection.store(restoreChannel);
} catch (noCurrentSelection) {}
selectionChannel = doc.channels.getByName(channelName);
doc.selection.load(selectionChannel);
hasSelection = true;
} catch (channelError) {}
}
var place = new ActionDescriptor();
place.putPath(charIDToTypeID("null"), file);
place.putEnumerated(charIDToTypeID("FTcs"), charIDToTypeID("QCSt"), charIDToTypeID("Qcsa"));
var offset = new ActionDescriptor();
offset.putUnitDouble(charIDToTypeID("Hrzn"), charIDToTypeID("#Pxl"), 0);
offset.putUnitDouble(charIDToTypeID("Vrtc"), charIDToTypeID("#Pxl"), 0);
place.putObject(charIDToTypeID("Ofst"), charIDToTypeID("Ofst"), offset);
place.putBoolean(stringIDToTypeID("linked"), false);
executeAction(charIDToTypeID("Plc "), place, DialogModes.NO);
var placedLayer = app.activeDocument.activeLayer;
if (layerName) placedLayer.name = layerName;
if (boundsText) {
var boundsValues = boundsText.split(",");
if (boundsValues.length === 4) {
var targetLeft = Number(boundsValues[0]);
var targetTop = Number(boundsValues[1]);
var targetWidth = Number(boundsValues[2]);
var targetHeight = Number(boundsValues[3]);
var placedBounds = placedLayer.bounds;
var placedWidth = Math.max(1, placedBounds[2].as("px") - placedBounds[0].as("px"));
var placedHeight = Math.max(1, placedBounds[3].as("px") - placedBounds[1].as("px"));
placedLayer.resize(
targetWidth / placedWidth * 100,
targetHeight / placedHeight * 100,
AnchorPosition.TOPLEFT
);
placedBounds = placedLayer.bounds;
placedLayer.translate(
targetLeft - placedBounds[0].as("px"),
targetTop - placedBounds[1].as("px")
);
}
}
app.activeDocument.activeLayer = placedLayer;
aiEditAddLayerMask(hasSelection);
if (selectionChannel) {
if (hadCurrentSelection && restoreChannel) doc.selection.load(restoreChannel);
else doc.selection.deselect();
if (restoreChannel) restoreChannel.remove();
}
aiEditShowComposite(doc);
return "ok";
} catch (e) {
try {
aiEditShowComposite(app.activeDocument);
} catch (restoreViewError) {}
return "error:" + e.toString();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="styles/panel.css">
</head>
<body>
<div id="root"></div>
<script src="index.js"></script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
# Direction Approval
- User instruction: "请不要修改样式,必须严格按照我的设置还原"
- Approved source: `C:/Users/Administrator/AppData/Local/Temp/codex-clipboard-fd114ed3-645d-43e5-9cba-821120aa02cf.png`
- Decision: Skip design variations and reproduce the supplied two-page UI directly.
- Scope: Photoshop UXP panel in `D:/Software/Adobe/uxp/ai-edit`.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "com.aiedit.panel",
"version": "1.0.0",
"scripts": {
"watch": "nodemon -w src -e js,jsx,json,css,html -w webpack.config.js -w cep -x npm run build",
"build": "webpack --mode development",
"cep:setup": "node scripts/setup-cep.js"
},
"author": "Adobe Inc",
"license": "Apache-2.0",
"devDependencies": {
"@babel/core": "^7.8.7",
"@babel/plugin-proposal-object-rest-spread": "^7.8.3",
"@babel/plugin-syntax-class-properties": "^7.10.4",
"@babel/plugin-transform-react-jsx": "^7.8.3",
"autoprefixer": "^10.5.4",
"babel-loader": "^8.0.6",
"copy-webpack-plugin": "^5.0.3",
"css-loader": "^6.11.0",
"mini-css-extract-plugin": "^2.10.2",
"nodemon": "^2.0.7",
"postcss": "^8.5.25",
"postcss-loader": "^8.2.1",
"tailwindcss": "^3.4.19",
"webpack": "^5.88.1",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"react": "^16.8.6",
"react-dom": "^16.8.6"
}
}
+3540
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
nodemon: true
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+63
View File
@@ -0,0 +1,63 @@
// Enables CEP debug mode and links dist/ into the Adobe extensions folder.
// Run once per machine: npm run cep:setup
const { execFileSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");
const CSXS_VERSIONS = [9, 10, 11, 12];
const EXTENSION_NAME = "com.aiedit.panel";
const dist = path.resolve(__dirname, "..", "dist");
function enableDebugMode() {
if (process.platform === "win32") {
for (const version of CSXS_VERSIONS) {
execFileSync("reg", [
"add", `HKCU\\Software\\Adobe\\CSXS.${version}`,
"/v", "PlayerDebugMode", "/t", "REG_SZ", "/d", "1", "/f"
], { stdio: "ignore" });
}
} else {
for (const version of CSXS_VERSIONS) {
execFileSync("defaults", [
"write", `com.adobe.CSXS.${version}`, "PlayerDebugMode", "1"
], { stdio: "ignore" });
}
}
console.log(`PlayerDebugMode enabled for CSXS ${CSXS_VERSIONS.join(", ")}`);
}
function extensionsFolder() {
return process.platform === "win32"
? path.join(process.env.APPDATA, "Adobe", "CEP", "extensions")
: path.join(os.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
}
function linkExtension() {
const root = extensionsFolder();
fs.mkdirSync(root, { recursive: true });
const target = path.join(root, EXTENSION_NAME);
const existing = fs.lstatSync(target, { throwIfNoEntry: false });
if (existing) {
if (!existing.isSymbolicLink()) {
console.error(`${target} exists and is not a link. Remove it manually, then rerun.`);
process.exit(1);
}
fs.unlinkSync(target);
}
// Junctions avoid needing administrator rights on Windows.
fs.symlinkSync(dist, target, process.platform === "win32" ? "junction" : "dir");
console.log(`Linked ${target} -> ${dist}`);
}
if (!fs.existsSync(dist)) {
console.error("dist/ not found. Run `npm run build` first.");
process.exit(1);
}
enableDebugMode();
linkExtension();
console.log("Restart Photoshop, then open Plugins > AI Edit.");
+255
View File
@@ -0,0 +1,255 @@
// Bridge between the panel UI and the Photoshop host / Node runtime.
const HOST_ERRORS = {
"missing-file": "临时文件不存在",
"missing-document": "请先打开一个 Photoshop 文档"
};
function nodeRequire(id) {
const resolver = window.cep_node ? window.cep_node.require : window.require;
if (!resolver) throw new Error("Node 运行时不可用,请确认面板启用了 --enable-nodejs");
return resolver(id);
}
// evalScript only transports ASCII reliably, and host.jsx is ASCII-only, so any
// localized string has to cross the bridge as a \uXXXX escape.
function toExtendScriptString(value) {
return JSON.stringify(String(value)).replace(
/[^\x00-\x7F]/g,
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
);
}
function evalScript(script) {
return new Promise((resolve, reject) => {
const cep = window.__adobe_cep__;
if (!cep) {
reject(new Error("CEP 环境不可用"));
return;
}
cep.evalScript(script, (result) => {
if (result === "EvalScript error.") reject(new Error("ExtendScript 执行失败"));
else resolve(result);
});
});
}
function call(fn, ...args) {
return evalScript(`${fn}(${args.map(toExtendScriptString).join(", ")})`);
}
export async function hasActiveDocument() {
try {
return (await evalScript("aiEditHasDocument()")) === "yes";
} catch (error) {
return false;
}
}
export async function getGenerationSize() {
const result = await evalScript("aiEditGetGenerationSize()");
return result && /^\d+x\d+$/.test(result) ? result : "";
}
export async function captureSelectionReference() {
const result = await evalScript("aiEditCaptureSelection()");
if (!result || result === "none") return null;
if (result.indexOf("error:") === 0) {
throw new Error(HOST_ERRORS[result.slice(6)] || result.slice(6));
}
const parts = result.slice(3).split("|");
if (parts.length < 3) throw new Error("无法读取 Photoshop 选区");
return {
channelName: parts[0],
filePath: parts[1],
bounds: parts[2]
};
}
export async function pickReferenceImages() {
const result = await call("aiEditPickImages", "选择参考图", "图片:*.png;*.jpg;*.jpeg;*.webp");
return result ? result.split("\n").filter(Boolean) : [];
}
// Returns null when the user cancels, so an empty string stays a real answer.
export async function openPromptDialog(initial) {
const result = await call(
"aiEditPromptDialog",
"输入提示词",
"在此输入描述提示词,支持中文输入法:",
"确定",
"取消",
initial || ""
);
if (!result || !result.startsWith("ok:")) return null;
// ExtendScript sends escape()-encoded text; unescape is the only decoder
// that understands its %uXXXX form.
return unescape(result.slice(3));
}
// Requests go through Node rather than fetch: the panel talks to whatever
// endpoint the user configures, and no CORS headers are expected back.
function request(url, { method = "GET", headers = {}, body } = {}, redirects = 5) {
const { Buffer } = nodeRequire("buffer");
return new Promise((resolve, reject) => {
let target;
try {
target = new URL(url);
} catch (error) {
reject(new Error(`地址无效:${url}`));
return;
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
reject(new Error(`不支持的协议:${target.protocol}`));
return;
}
const client = nodeRequire(target.protocol === "https:" ? "https" : "http");
// Older CEP runtimes ship a Node that cannot take a URL object here.
const options = {
protocol: target.protocol,
hostname: target.hostname,
port: target.port || undefined,
path: `${target.pathname}${target.search}`,
method,
headers
};
const req = client.request(options, (res) => {
const { statusCode, headers: resHeaders } = res;
if (statusCode >= 300 && statusCode < 400 && resHeaders.location) {
res.resume();
if (redirects <= 0) {
reject(new Error("重定向次数过多"));
return;
}
const next = new URL(resHeaders.location, target);
// Never carry the API key to a host the user did not configure.
const nextHeaders = { ...headers };
if (next.origin !== target.origin) delete nextHeaders.Authorization;
request(next.toString(), { method, headers: nextHeaders, body }, redirects - 1)
.then(resolve, reject);
return;
}
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve({ statusCode, buffer: Buffer.concat(chunks) }));
});
req.on("error", (error) => reject(new Error(error.message || "网络请求失败")));
if (body) req.write(body);
req.end();
});
}
export async function postJson(url, { apiKey, body }) {
const { Buffer } = nodeRequire("buffer");
const payload = JSON.stringify(body);
const { statusCode, buffer } = await request(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
Authorization: `Bearer ${apiKey}`
},
body: payload
});
return { statusCode, text: buffer.toString("utf8") };
}
export async function postMultipart(url, { apiKey, fields, files }) {
const { Buffer } = nodeRequire("buffer");
const boundary = `----AIEditBoundary${Date.now().toString(16)}`;
const chunks = [];
const addText = (value) => chunks.push(Buffer.from(value, "utf8"));
Object.keys(fields || {}).forEach((name) => {
addText(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
`${String(fields[name])}\r\n`
);
});
(files || []).forEach((file) => {
const match = String(file.dataUrl).match(/^data:([^;]+);base64,(.*)$/);
if (!match) throw new Error("参考图数据格式无效");
addText(
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\n` +
`Content-Type: ${match[1]}\r\n\r\n`
);
chunks.push(Buffer.from(match[2], "base64"));
addText("\r\n");
});
addText(`--${boundary}--\r\n`);
const body = Buffer.concat(chunks);
const { statusCode, buffer } = await request(url, {
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"Content-Length": body.length,
Authorization: `Bearer ${apiKey}`
},
body
});
return { statusCode, text: buffer.toString("utf8") };
}
export async function downloadImage(url) {
const { statusCode, buffer } = await request(url);
if (statusCode < 200 || statusCode >= 300) throw new Error(`图片下载失败:${statusCode}`);
return buffer;
}
export function fileName(filePath) {
return String(filePath).split(/[\\/]/).pop();
}
export function writeTempImage(buffer, index) {
const fs = nodeRequire("fs");
const os = nodeRequire("os");
const path = nodeRequire("path");
const { Buffer } = nodeRequire("buffer");
const folder = path.join(os.tmpdir(), "ai-edit");
if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });
const target = path.join(folder, `ai-edit-${Date.now()}-${index}.png`);
fs.writeFileSync(target, Buffer.from(buffer));
return target;
}
export function readFileAsDataUrl(filePath) {
const fs = nodeRequire("fs");
const buffer = fs.readFileSync(filePath);
const extension = String(filePath).split(".").pop().toLowerCase();
const mime = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp"
}[extension] || "image/png";
return `data:${mime};base64,${buffer.toString("base64")}`;
}
export async function placeImageFile(filePath, layerName, channelName, bounds) {
const result = await call(
"aiEditPlaceImage",
filePath,
layerName,
channelName || "",
bounds || ""
);
if (result === "ok") return;
const detail = String(result || "").replace(/^error:/, "");
throw new Error(HOST_ERRORS[detail] || detail || "图片置入失败");
}
+56
View File
@@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Element-level styles Tailwind classes cannot reach (html/body/#root). */
@layer base {
html,
body {
@apply h-full w-full min-w-0 overflow-hidden bg-[#202124] p-0;
}
body {
@apply m-0 text-[12px] text-[#f2f3f5];
}
#root {
@apply h-full w-full;
}
button {
@apply cursor-pointer;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[contenteditable]:focus-visible {
@apply outline outline-2 outline-offset-1 outline-[#6ba5ff];
}
}
/* ===================================
全局隐藏滚动条(保留滚动能力)
CEP / Chromium
=================================== */
/* Chrome / Chromium */
::-webkit-scrollbar {
width: 0px;
height: 0px;
}
/* Firefox */
* {
scrollbar-width: none;
}
/* IE / Edge旧版 */
* {
-ms-overflow-style: none;
}
+7
View File
@@ -0,0 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import { ImageGenerator } from "./panels/ImageGenerator.jsx";
ReactDOM.render(<ImageGenerator />, document.getElementById("root"));
+564
View File
@@ -0,0 +1,564 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
downloadImage,
captureSelectionReference,
fileName,
getGenerationSize,
hasActiveDocument,
openPromptDialog,
pickReferenceImages,
placeImageFile,
postMultipart,
postJson,
readFileAsDataUrl,
writeTempImage,
} from "../cep.js";
const SETTINGS_KEY = "ai-edit.settings.v3";
const DEFAULT_SETTINGS = {
endpoint: "",
apiKey: "",
model: "gpt-image-2",
size: "1024x1024",
quality: "low",
background: "auto",
count: 1,
taskMode: "default",
};
const MODEL_OPTIONS = [
"gpt-image-2",
"gpt-image-1",
"gpt-image-1.5",
"gemini-3.1-flash-lite-image",
].map((model) => ({ label: model, value: model }));
const QUALITY_OPTIONS = [
{ label: "1K", value: "low" },
{ label: "2K", value: "medium" },
{ label: "3K", value: "high" },
];
const ICON_PATHS = {
help: [
"M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4",
"M12 18h.01",
"M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z",
],
settings: [
"M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z",
"M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34A1.7 1.7 0 0 0 14 20.92V21h-4v-.08A1.7 1.7 0 0 0 9 19.36a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.63 15 1.7 1.7 0 0 0 3.08 14H3v-4h.08A1.7 1.7 0 0 0 4.64 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.63 1.7 1.7 0 0 0 10 3.08V3h4v.08A1.7 1.7 0 0 0 15 4.64a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.37 9 1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15Z",
],
refresh: [
"M20 7v5h-5",
"M4 17v-5h5",
"M6.1 9a7 7 0 0 1 11.6-2.6L20 9",
"M4 15l2.3 2.6A7 7 0 0 0 18 15",
],
expand: ["M8 3H3v5", "M16 3h5v5", "M8 21H3v-5", "M16 21h5v-5"],
upload: [
"M12 16V5",
"m7.5 9.5 4.5-4.5 4.5 4.5",
"M20 16.5a4.5 4.5 0 0 1-4.5 4.5h-8A4.5 4.5 0 0 1 7 12.03 6 6 0 0 1 18.55 10 3.5 3.5 0 0 1 20 16.5Z",
],
};
const SELECT_CLASS = "block w-full min-w-0 flex-auto";
const SELECT_FIELD =
"h-[33px] w-full appearance-none rounded-[6px] border-0 bg-[#2f2f2f] pl-[10px] pr-[34px] text-[13px] font-semibold text-[#f4f4f4] hover:bg-[#3a3a3a]";
const MINI_HELP =
"ml-[3px] inline-flex h-[13px] w-[13px] items-center justify-center rounded-full border border-white text-[9px] font-bold leading-[13px] text-white";
const GROUP_LABEL =
"flex h-[20px] items-center text-[12px] font-bold text-[#eeeeee]";
const PRIMARY_BUTTON =
"flex h-[28px] w-full items-center justify-center rounded-[5px] border-0 bg-accent px-[10px] text-[12px] font-bold text-white hover:bg-[#3d88fa]";
const TOOL_BUTTON =
"flex w-[34px] cursor-pointer flex-col items-center justify-center border border-[#6e6e6e] bg-[#3d3d3d] p-0 text-[#ededed] hover:bg-[#474747]";
const Icon = ({ name, size = 18, stroke = "#f4f4f4" }) => (
<svg
className="pointer-events-none block flex-none"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={stroke}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{ICON_PATHS[name].map((path, index) => (
<path d={path} key={`${name}-${index}`} />
))}
</svg>
);
function loadSettings() {
try {
const saved = localStorage.getItem(SETTINGS_KEY);
return saved
? { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }
: DEFAULT_SETTINGS;
} catch (error) {
return DEFAULT_SETTINGS;
}
}
function cleanEndpoint(endpoint, operation = "generations") {
const trimmed = endpoint.trim();
if (!trimmed) return "";
const suffix = `/images/${operation}`;
if (trimmed.endsWith("/images/generations"))
return trimmed.replace(/\/images\/generations$/, suffix);
if (trimmed.endsWith("/images/edits"))
return trimmed.replace(/\/images\/edits$/, suffix);
return `${trimmed.replace(/\/$/, "")}${suffix}`;
}
function extractImagePayload(result) {
const candidates = [];
if (Array.isArray(result && result.data)) candidates.push(...result.data);
if (Array.isArray(result && result.output)) candidates.push(...result.output);
if (Array.isArray(result && result.images)) candidates.push(...result.images);
return candidates
.map((item) => {
if (!item) return null;
if (item.b64_json) return { type: "base64", value: item.b64_json };
if (item.image) return { type: "base64", value: item.image };
if (item.url) return { type: "url", value: item.url };
if (typeof item === "string" && item.startsWith("http"))
return { type: "url", value: item };
if (typeof item === "string") return { type: "base64", value: item };
return null;
})
.filter(Boolean);
}
function base64ToArrayBuffer(base64) {
const binary = atob(base64.replace(/^data:image\/\w+;base64,/, ""));
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1)
bytes[index] = binary.charCodeAt(index);
return bytes.buffer;
}
async function payloadToBuffer(payload) {
if (payload.type === "url") return downloadImage(payload.value);
return base64ToArrayBuffer(payload.value);
}
function payloadToSrc(payload) {
if (payload.type === "url") return payload.value;
if (payload.value.startsWith("data:")) return payload.value;
return `data:image/png;base64,${payload.value}`;
}
function SegmentedControl({ options, value, onChange }) {
return (
<div className="flex h-[27px] overflow-hidden rounded-[5px] bg-[#2f2f2f]">
{options.map((option) => (
<button
key={option.value}
className={`h-[27px] min-w-0 flex-1 rounded-none border-0 px-[4px] text-[12px] font-semibold ${
value === option.value
? "bg-[#1688f7] text-white"
: "bg-transparent text-[#c5c5c5]"
}`}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
export const ImageGenerator = () => {
const [settings, setSettings] = useState(loadSettings);
const [prompt, setPrompt] = useState("");
const [status, setStatus] = useState("");
const [isBusy, setIsBusy] = useState(false);
const [referenceFiles, setReferenceFiles] = useState([]);
const [activeView, setActiveView] = useState("generate");
const [isPromptFocused, setIsPromptFocused] = useState(false);
const [results, setResults] = useState([]);
const editorRef = useRef(null);
useEffect(() => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}, [settings]);
const updateSetting = (key, value) =>
setSettings((current) => ({ ...current, [key]: value }));
const canGenerate = useMemo(
() =>
Boolean(settings.endpoint && settings.apiKey && prompt.trim() && !isBusy),
[settings, prompt, isBusy],
);
// The editor is contentEditable, so the dialog result has to be written in directly.
const editPromptInPhotoshop = async () => {
const text = await openPromptDialog(prompt);
if (text === null) return;
setPrompt(text);
if (editorRef.current) editorRef.current.textContent = text;
};
const chooseReferenceImages = async () => {
try {
const selected = await pickReferenceImages();
setReferenceFiles(selected);
setStatus(selected.length ? `已选择 ${selected.length} 张参考图` : "");
} catch (error) {
setStatus("");
}
};
const generate = async () => {
if (!canGenerate) {
setStatus(
settings.endpoint && settings.apiKey
? "请输入描述提示词"
: "请先在设置中填写 API 地址和 API Key",
);
return;
}
setIsBusy(true);
setStatus("正在生成...");
try {
const selectionReference = await captureSelectionReference();
const isEdit = Boolean(selectionReference);
const generationSize = await getGenerationSize();
if (isEdit) setStatus("正在编辑选区...");
const referenceImages = selectionReference
? [selectionReference.filePath, ...referenceFiles].map(readFileAsDataUrl)
: referenceFiles.map(readFileAsDataUrl);
const requestFields = {
model: settings.model.trim(),
prompt: prompt.trim(),
n: Number(settings.count) || 1,
size: generationSize || settings.size,
quality: settings.quality,
response_format: "b64_json",
...(settings.background !== "auto"
? { background: settings.background }
: {}),
};
const requestResult = isEdit
? await postMultipart(
cleanEndpoint(settings.endpoint, "edits"),
{
apiKey: settings.apiKey.trim(),
fields: requestFields,
files: referenceImages.map((dataUrl, index) => ({
fieldName: referenceImages.length === 1 ? "image" : "image[]",
fileName: `ai-edit-reference-${index + 1}.png`,
dataUrl,
})),
},
)
: await postJson(cleanEndpoint(settings.endpoint, "generations"), {
apiKey: settings.apiKey.trim(),
body: {
...requestFields,
...(referenceImages.length
? { images: referenceImages, image: referenceImages[0] }
: {}),
},
});
const { statusCode, text: resultText } = requestResult;
let result;
try {
result = JSON.parse(resultText);
} catch (error) {
throw new Error(resultText || "接口返回不是 JSON");
}
if (statusCode < 200 || statusCode >= 300)
throw new Error(
(result.error && (result.error.message || result.error.code)) ||
`接口请求失败:${statusCode}`,
);
const payloads = extractImagePayload(result);
if (!payloads.length) throw new Error("接口响应里没有找到图片数据");
setResults((current) =>
[
...payloads.map((payload) => ({
src: payloadToSrc(payload),
channelName: selectionReference && selectionReference.channelName,
bounds: selectionReference && selectionReference.bounds,
})),
...current,
].slice(0, 12),
);
setStatus(`完成:已生成 ${payloads.length} 张,请点击缩略图置入`);
} catch (error) {
setStatus(error.message || String(error));
} finally {
setIsBusy(false);
}
};
const placeResult = async (result, index) => {
if (!(await hasActiveDocument())) {
setStatus("请先在 Photoshop 中打开一个文档");
return;
}
setStatus("正在置入...");
try {
const payload = result.src.startsWith("http")
? { type: "url", value: result.src }
: { type: "base64", value: result.src };
const buffer = await payloadToBuffer(payload);
const file = await writeTempImage(buffer, index + 1);
await placeImageFile(
file,
`AI 生图 - ${index + 1}`,
result.channelName,
result.bounds,
);
setStatus("已置入当前文档");
} catch (error) {
setStatus(error.message || String(error));
}
};
const saveSettings = () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
setStatus("设置已保存");
setActiveView("generate");
};
return (
<main className="relative h-screen min-h-[520px] w-full min-w-[300px] overflow-y-auto overflow-x-hidden bg-[#4a4a4a] px-[19px] pb-[28px] pt-[15px] text-[#eeeeee] narrow:px-[14px]">
<header className="flex h-[47px] items-start justify-between">
<div className="flex items-center pt-[1px] text-white">
<span
className="relative mr-[8px] block h-[17px] w-[17px] rounded-t-[9px] rounded-b-[3px] border-2 border-b-0 border-white before:absolute before:bottom-[-2px] before:left-[-4px] before:right-[-4px] before:h-[2px] before:rounded-[2px] before:bg-white before:content-[''] after:absolute after:bottom-[-6px] after:left-[5px] after:h-[3px] after:w-[5px] after:rounded-b-[4px] after:bg-white after:content-['']"
aria-hidden="true"
/>
<strong className="text-[14px] font-bold leading-[25px]">
AI-Edite
</strong>
</div>
<div className="flex items-center pt-[6px]">
<div
className="ml-[7px] flex h-[24px] w-[26px] cursor-pointer items-center justify-center rounded-[4px] bg-transparent text-[#f4f4f4] hover:bg-white/10"
title="帮助"
onClick={() => setStatus("请先配置 API,再输入提示词生成图片")}
>
<Icon name="help" size={17} />
</div>
<div
className="ml-[7px] flex h-[24px] w-[26px] cursor-pointer items-center justify-center rounded-[4px] bg-transparent text-[#f4f4f4] hover:bg-white/10"
title="设置"
onClick={() =>
setActiveView(activeView === "settings" ? "generate" : "settings")
}
>
<Icon name="settings" size={17} />
</div>
</div>
</header>
{activeView === "settings" ? (
<section className="w-full pt-[5px]">
<label className="mb-[16px] block">
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
API地址
</span>
<input
className="h-[34px] w-full rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee] placeholder:text-[#898989]"
value={settings.endpoint}
onChange={(event) =>
updateSetting("endpoint", event.target.value)
}
placeholder="请输入API地址"
/>
</label>
<label className="mb-[16px] block">
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
API Key
</span>
<input
className="h-[34px] w-full rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee] placeholder:text-[#898989]"
type="password"
value={settings.apiKey}
onChange={(event) => updateSetting("apiKey", event.target.value)}
placeholder="请输入API-Key"
/>
</label>
<label className="mb-[16px] block">
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
模型
</span>
<span className="relative block w-full">
<select
className="h-[34px] w-full appearance-none rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee]"
value={settings.model}
onChange={(event) => updateSetting("model", event.target.value)}
>
{MODEL_OPTIONS.map((model) => (
<option key={model.value} value={model.value}>
{model.label}
</option>
))}
</select>
</span>
</label>
<button
className={`${PRIMARY_BUTTON} mt-[22px]`}
onClick={saveSettings}
>
保存设置
</button>
</section>
) : (
<section className="w-full">
<div className="mx-[2px] mb-[10px] flex h-[36px] items-center">
<span className="flex-[0_0_39px] text-[13px] font-bold">模型</span>
<span className={`relative ${SELECT_CLASS}`}>
<select
className={SELECT_FIELD}
value={settings.model}
onChange={(event) => updateSetting("model", event.target.value)}
>
{MODEL_OPTIONS.map((model) => (
<option key={model.value} value={model.value}>
{model.label}
</option>
))}
</select>
</span>
<div
className="ml-[9px] flex h-[33px] flex-[0_0_41px] cursor-pointer items-center justify-center rounded-[5px] border-0 bg-[#2f2f2f] p-0 text-white hover:bg-[#3a3a3a]"
title="刷新模型"
onClick={() => setStatus("模型列表已刷新")}
>
<Icon name="refresh" size={17} />
</div>
</div>
<div
className="relative mb-[17px] h-[116px] overflow-y-auto rounded-[5px] bg-[#2f2f2f] p-[5px]"
ref={editorRef}
contentEditable
onInput={(event) =>
setPrompt(event.currentTarget.textContent || "")
}
onFocus={() => setIsPromptFocused(true)}
onBlur={() => setIsPromptFocused(false)}
suppressContentEditableWarning
>
{!prompt && !isPromptFocused && (
<span className="pointer-events-none absolute left-[9px] top-[8px] text-[13px] font-semibold leading-[18px] text-[#a8a8a8]">
描述提示词...
</span>
)}
</div>
<div
className="mb-[13px] flex h-[135px] w-full cursor-pointer flex-col items-center justify-center rounded-[5px] border-0 bg-[#2f2f2f] p-[12px] text-center text-[#e7e7e7] hover:bg-[#3a3a3a]"
onClick={chooseReferenceImages}
>
<div className="mb-[10px] flex h-[30px] w-full items-center justify-center">
<Icon name="upload" size={30} stroke="#b0b0b0" />
</div>
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap text-center text-[13px] font-bold leading-[19px] text-[#ececec]">
{referenceFiles.length
? `已上传 ${referenceFiles.length} 张参考图`
: "点击或者拖拽上传参考图"}
</div>
<div className="mt-[8px] w-full overflow-hidden text-ellipsis whitespace-nowrap text-center text-[10px] leading-[14px] text-[#b6b6b6]">
{referenceFiles.length
? referenceFiles.map(fileName).join("、")
: "(画布选区为图1,上传图依次为图2、图3...)"}
</div>
</div>
<div className="mb-[4px] -mr-[15px] flex flex-wrap narrow:-mr-[9px]">
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
<span className={GROUP_LABEL}>数量</span>
<SegmentedControl
value={settings.count}
onChange={(value) => updateSetting("count", value)}
options={[
{ label: "1张", value: 1 },
{ label: "2张", value: 2 },
{ label: "3张", value: 3 },
]}
/>
</div>
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
<span className={GROUP_LABEL}>画质</span>
<SegmentedControl
value={settings.quality}
onChange={(value) => updateSetting("quality", value)}
options={QUALITY_OPTIONS}
/>
</div>
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
<span className={GROUP_LABEL}>背景</span>
<SegmentedControl
value={settings.background}
onChange={(value) => updateSetting("background", value)}
options={[
{ label: "默认", value: "auto" },
{ label: "透明底", value: "transparent" },
]}
/>
</div>
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
<span className={GROUP_LABEL}>
生图模式 <span className={MINI_HELP}>?</span>
</span>
<SegmentedControl
value={settings.taskMode}
onChange={(value) => updateSetting("taskMode", value)}
options={[
{ label: "默认", value: "default" },
{ label: "多任务", value: "multi" },
]}
/>
</div>
</div>
<button className={PRIMARY_BUTTON} onClick={generate}>
{isBusy ? "生成中..." : "立即生成"}
</button>
{results.length > 0 && (
<div className="-mr-[6px] mt-[14px] flex flex-wrap">
{results.map((result, index) => (
<button
className="mb-[6px] mr-[6px] block h-[84px] min-w-0 flex-[0_0_calc(25%_-_6px)] overflow-hidden rounded-[3px] border-0 bg-[#2f2f2f] p-0"
key={`${index}-${result.src.slice(-16)}`}
title="点击置入当前文档"
onClick={() => placeResult(result, index)}
>
<img
className="block h-full w-full object-cover"
src={result.src}
alt={`生成结果 ${index + 1}`}
/>
</button>
))}
</div>
)}
</section>
)}
<div
className="mt-[7px] min-h-[14px] overflow-hidden text-ellipsis whitespace-nowrap text-center text-[9px] leading-[14px] text-[#e1e1e1]"
aria-live="polite"
>
{status}
</div>
</main>
);
};
+22
View File
@@ -0,0 +1,22 @@
module.exports = {
content: ["./src/**/*.{js,jsx}"],
theme: {
extend: {
colors: {
accent: "#2f7cf6"
},
fontFamily: {
// Rounded stack: first installed font wins, falling back to the
// locally available Maple Mono NF CN for Chinese.
sans: [
"MiSans", "HarmonyOS Sans SC", "Nunito", "Quicksand", "Varela Round",
"Maple Mono NF CN", "Microsoft YaHei UI", "Microsoft YaHei"
]
},
screens: {
narrow: { max: "340px" }
}
}
},
plugins: []
};
+44
View File
@@ -0,0 +1,44 @@
const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
entry: './src/index.jsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'index.js'
},
target: 'web',
devtool: 'eval-cheap-source-map',
resolve: {
extensions: [".js", ".jsx"]
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: "babel-loader",
options: {
plugins: [
"@babel/transform-react-jsx",
"@babel/proposal-object-rest-spread",
"@babel/plugin-syntax-class-properties",
]
}
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"]
}
]
},
plugins: [
new MiniCssExtractPlugin({ filename: 'styles/panel.css' }),
new CopyPlugin([
'cep'
], {
copyUnmodified: true
})
]
};