增强画布生成与资产管理

This commit is contained in:
2026-06-24 00:14:45 +08:00
parent 1f3bfc1a16
commit 9ee693c6a0
32 changed files with 4307 additions and 845 deletions
+2
View File
@@ -0,0 +1,2 @@
OPENAI_API_KEY=sk-your-key
OPENAI_BASE_URL=https://api.kkrich.ltd/v1
Binary file not shown.
Binary file not shown.
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@radix-ui/react-alert-dialog": "^1.1.17",
"@radix-ui/react-label": "^2.1.10",
"@radix-ui/react-select": "^2.3.1",
"@radix-ui/react-separator": "^1.1.10",
+133 -69
View File
@@ -11,6 +11,9 @@ importers:
'@aws-sdk/client-s3':
specifier: ^3.1075.0
version: 3.1075.0
'@radix-ui/react-alert-dialog':
specifier: ^1.1.17
version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-label':
specifier: ^2.1.10
version: 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -617,6 +620,19 @@ packages:
'@radix-ui/primitive@1.1.4':
resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==}
'@radix-ui/react-alert-dialog@1.1.17':
resolution: {integrity: sha512-563ygGeyWPrxyVCNp7OV4rE2aIXhFPknpFyo4wbDlcyMMPZ6ySh+zC5WTvY0ZFLgPTg/QB6tA8PyDQyJ2b4cPg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-arrow@1.1.10':
resolution: {integrity: sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==}
peerDependencies:
@@ -661,6 +677,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-dialog@1.1.17':
resolution: {integrity: sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.2':
resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
peerDependencies:
@@ -957,40 +986,40 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@smithy/core@3.26.0':
resolution: {integrity: sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==}
'@smithy/core@3.25.1':
resolution: {integrity: sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.4.2':
resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==}
'@smithy/credential-provider-imds@4.4.1':
resolution: {integrity: sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.5.2':
resolution: {integrity: sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==}
'@smithy/fetch-http-handler@5.5.1':
resolution: {integrity: sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==}
engines: {node: '>=18.0.0'}
'@smithy/is-array-buffer@2.2.0':
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
'@smithy/is-array-buffer@2.0.0':
resolution: {integrity: sha512-z3PjFjMyZNI98JFRJi/U0nGoLWMSJlDjAW4QUX2WNZLas5C0CmVV6LJ01JI0k90l7FvpmixjWxPFmENSClQ7ug==}
engines: {node: '>=14.0.0'}
'@smithy/node-http-handler@4.8.2':
resolution: {integrity: sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==}
'@smithy/node-http-handler@4.8.1':
resolution: {integrity: sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.5.2':
resolution: {integrity: sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==}
'@smithy/signature-v4@5.5.1':
resolution: {integrity: sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.15.0':
resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==}
engines: {node: '>=18.0.0'}
'@smithy/util-buffer-from@2.2.0':
resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
'@smithy/util-buffer-from@2.0.0':
resolution: {integrity: sha512-/YNnLoHsR+4W4Vf2wL5lGv0ksg8Bmk3GEGxn2vEQt52AQaPSCuaO5PM5VM7lP1K9qHRKHwrPGktqVoAHKWHxzw==}
engines: {node: '>=14.0.0'}
'@smithy/util-utf8@2.3.0':
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
'@smithy/util-utf8@2.0.0':
resolution: {integrity: sha512-rctU1VkziY84n5OXe3bPNpKR001ZCME2JCaBBFgtiM2hfKbHFudc/BkMuPab8hRbLd0j3vbnBTTZ1igBf0wgiQ==}
engines: {node: '>=14.0.0'}
'@swc/helpers@0.5.15':
@@ -1300,8 +1329,8 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
browserslist@4.28.4:
resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -2160,8 +2189,8 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nanoid@3.3.14:
resolution: {integrity: sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==}
nanoid@3.3.15:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -2797,7 +2826,7 @@ snapshots:
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.973.13
'@aws-sdk/util-locate-window': 3.965.8
'@smithy/util-utf8': 2.3.0
'@smithy/util-utf8': 2.0.0
tslib: 2.8.1
'@aws-crypto/sha256-browser@5.2.0':
@@ -2807,7 +2836,7 @@ snapshots:
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.973.13
'@aws-sdk/util-locate-window': 3.965.8
'@smithy/util-utf8': 2.3.0
'@smithy/util-utf8': 2.0.0
tslib: 2.8.1
'@aws-crypto/sha256-js@5.2.0':
@@ -2823,7 +2852,7 @@ snapshots:
'@aws-crypto/util@5.2.0':
dependencies:
'@aws-sdk/types': 3.973.13
'@smithy/util-utf8': 2.3.0
'@smithy/util-utf8': 2.0.0
tslib: 2.8.1
'@aws-sdk/checksums@3.1000.8':
@@ -2833,7 +2862,7 @@ snapshots:
'@aws-crypto/util': 5.2.0
'@aws-sdk/core': 3.974.23
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2848,9 +2877,9 @@ snapshots:
'@aws-sdk/middleware-sdk-s3': 3.972.54
'@aws-sdk/signature-v4-multi-region': 3.996.35
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/fetch-http-handler': 5.5.2
'@smithy/node-http-handler': 4.8.2
'@smithy/core': 3.25.1
'@smithy/fetch-http-handler': 5.5.1
'@smithy/node-http-handler': 4.8.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2859,8 +2888,8 @@ snapshots:
'@aws-sdk/types': 3.973.13
'@aws-sdk/xml-builder': 3.972.31
'@aws/lambda-invoke-store': 0.2.4
'@smithy/core': 3.26.0
'@smithy/signature-v4': 5.5.2
'@smithy/core': 3.25.1
'@smithy/signature-v4': 5.5.1
'@smithy/types': 4.15.0
bowser: 2.14.1
tslib: 2.8.1
@@ -2869,7 +2898,7 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.974.23
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2877,9 +2906,9 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.974.23
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/fetch-http-handler': 5.5.2
'@smithy/node-http-handler': 4.8.2
'@smithy/core': 3.25.1
'@smithy/fetch-http-handler': 5.5.1
'@smithy/node-http-handler': 4.8.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2894,8 +2923,8 @@ snapshots:
'@aws-sdk/credential-provider-web-identity': 3.972.55
'@aws-sdk/nested-clients': 3.997.23
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/credential-provider-imds': 4.4.2
'@smithy/core': 3.25.1
'@smithy/credential-provider-imds': 4.4.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2904,7 +2933,7 @@ snapshots:
'@aws-sdk/core': 3.974.23
'@aws-sdk/nested-clients': 3.997.23
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2917,8 +2946,8 @@ snapshots:
'@aws-sdk/credential-provider-sso': 3.972.55
'@aws-sdk/credential-provider-web-identity': 3.972.55
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/credential-provider-imds': 4.4.2
'@smithy/core': 3.25.1
'@smithy/credential-provider-imds': 4.4.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2926,7 +2955,7 @@ snapshots:
dependencies:
'@aws-sdk/core': 3.974.23
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2936,7 +2965,7 @@ snapshots:
'@aws-sdk/nested-clients': 3.997.23
'@aws-sdk/token-providers': 3.1074.0
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2945,7 +2974,7 @@ snapshots:
'@aws-sdk/core': 3.974.23
'@aws-sdk/nested-clients': 3.997.23
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2959,7 +2988,7 @@ snapshots:
'@aws-sdk/core': 3.974.23
'@aws-sdk/signature-v4-multi-region': 3.996.35
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2970,16 +2999,16 @@ snapshots:
'@aws-sdk/core': 3.974.23
'@aws-sdk/signature-v4-multi-region': 3.996.35
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/fetch-http-handler': 5.5.2
'@smithy/node-http-handler': 4.8.2
'@smithy/core': 3.25.1
'@smithy/fetch-http-handler': 5.5.1
'@smithy/node-http-handler': 4.8.1
'@smithy/types': 4.15.0
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.35':
dependencies:
'@aws-sdk/types': 3.973.13
'@smithy/signature-v4': 5.5.2
'@smithy/signature-v4': 5.5.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -2988,7 +3017,7 @@ snapshots:
'@aws-sdk/core': 3.974.23
'@aws-sdk/nested-clients': 3.997.23
'@aws-sdk/types': 3.973.13
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -3048,7 +3077,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.2
browserslist: 4.28.4
lru-cache: 5.1.1
semver: 6.3.1
@@ -3389,6 +3418,19 @@ snapshots:
'@radix-ui/primitive@1.1.4': {}
'@radix-ui/react-alert-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.4)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.4)
'@radix-ui/react-dialog': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-arrow@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -3422,6 +3464,28 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-dialog@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@radix-ui/primitive': 1.1.4
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.4)
'@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.4)
'@radix-ui/react-dismissable-layer': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.4)
'@radix-ui/react-focus-scope': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.4)
'@radix-ui/react-portal': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-primitive': 2.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.4)
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.4)
aria-hidden: 1.2.6
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.4)':
dependencies:
react: 19.2.4
@@ -3701,37 +3765,37 @@ snapshots:
'@rtsao/scc@1.1.0': {}
'@smithy/core@3.26.0':
'@smithy/core@3.25.1':
dependencies:
'@aws-crypto/crc32': 5.2.0
'@smithy/types': 4.15.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.2':
'@smithy/credential-provider-imds@4.4.1':
dependencies:
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.5.2':
'@smithy/fetch-http-handler@5.5.1':
dependencies:
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
'@smithy/is-array-buffer@2.2.0':
'@smithy/is-array-buffer@2.0.0':
dependencies:
tslib: 2.8.1
'@smithy/node-http-handler@4.8.2':
'@smithy/node-http-handler@4.8.1':
dependencies:
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
'@smithy/signature-v4@5.5.2':
'@smithy/signature-v4@5.5.1':
dependencies:
'@smithy/core': 3.26.0
'@smithy/core': 3.25.1
'@smithy/types': 4.15.0
tslib: 2.8.1
@@ -3739,14 +3803,14 @@ snapshots:
dependencies:
tslib: 2.8.1
'@smithy/util-buffer-from@2.2.0':
'@smithy/util-buffer-from@2.0.0':
dependencies:
'@smithy/is-array-buffer': 2.2.0
'@smithy/is-array-buffer': 2.0.0
tslib: 2.8.1
'@smithy/util-utf8@2.3.0':
'@smithy/util-utf8@2.0.0':
dependencies:
'@smithy/util-buffer-from': 2.2.0
'@smithy/util-buffer-from': 2.0.0
tslib: 2.8.1
'@swc/helpers@0.5.15':
@@ -4084,13 +4148,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.28.2:
browserslist@4.28.4:
dependencies:
baseline-browser-mapping: 2.10.38
caniuse-lite: 1.0.30001799
electron-to-chromium: 1.5.376
node-releases: 2.0.48
update-browserslist-db: 1.2.3(browserslist@4.28.2)
update-browserslist-db: 1.2.3(browserslist@4.28.4)
call-bind-apply-helpers@1.0.2:
dependencies:
@@ -5059,7 +5123,7 @@ snapshots:
ms@2.1.3: {}
nanoid@3.3.14: {}
nanoid@3.3.15: {}
napi-build-utils@2.0.0: {}
@@ -5212,13 +5276,13 @@ snapshots:
postcss@8.4.31:
dependencies:
nanoid: 3.3.14
nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.15:
dependencies:
nanoid: 3.3.14
nanoid: 3.3.15
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -5713,9 +5777,9 @@ snapshots:
undici-types@6.21.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
update-browserslist-db@1.2.3(browserslist@4.28.4):
dependencies:
browserslist: 4.28.2
browserslist: 4.28.4
escalade: 3.2.0
picocolors: 1.1.1
+18
View File
@@ -2,6 +2,24 @@ allowBuilds:
better-sqlite3: true
sharp: true
minimumReleaseAgeExclude:
- '@aws-sdk/checksums@3.1000.8'
- '@aws-sdk/client-s3@3.1075.0'
- '@aws-sdk/core@3.974.23'
- '@aws-sdk/credential-provider-env@3.972.49'
- '@aws-sdk/credential-provider-http@3.972.51'
- '@aws-sdk/credential-provider-ini@3.972.56'
- '@aws-sdk/credential-provider-login@3.972.55'
- '@aws-sdk/credential-provider-node@3.972.58'
- '@aws-sdk/credential-provider-process@3.972.49'
- '@aws-sdk/credential-provider-sso@3.972.55'
- '@aws-sdk/credential-provider-web-identity@3.972.55'
- '@aws-sdk/middleware-flexible-checksums@3.974.33'
- '@aws-sdk/middleware-sdk-s3@3.972.54'
- '@aws-sdk/nested-clients@3.997.23'
- '@aws-sdk/token-providers@3.1074.0'
- '@aws-sdk/xml-builder@3.972.31'
onlyBuiltDependencies:
- better-sqlite3
- sharp
-86
View File
@@ -1,86 +0,0 @@
import { NextResponse } from "next/server";
import {
getGeneratedImagePathFromUrl,
readPersistedImageFile,
} from "@/lib/server/storage/image-file-storage";
export const runtime = "nodejs";
export async function POST(request: Request) {
const payload = (await request.json().catch(() => ({}))) as {
filePath?: string;
imageUrl?: string;
};
const imageUrl = payload.imageUrl?.trim();
const filePath = resolveReadableImagePath(imageUrl, payload.filePath?.trim());
if (filePath) {
const buffer = await readPersistedImageFile(filePath);
if (!buffer) {
return NextResponse.json({ error: "图片文件不存在" }, { status: 404 });
}
return createImageResponse(buffer, getImageContentType(filePath));
}
if (!imageUrl) {
return NextResponse.json({ error: "图片地址不可读取" }, { status: 400 });
}
const remoteImage = await readRemoteImage(imageUrl);
if (!remoteImage) {
return NextResponse.json({ error: "远程图片不可访问" }, { status: 502 });
}
return createImageResponse(remoteImage.buffer, remoteImage.contentType);
}
function createImageResponse(buffer: Buffer, contentType: string) {
return new Response(buffer, {
headers: {
"Cache-Control": "no-store",
"Content-Type": contentType,
},
});
}
function resolveReadableImagePath(imageUrl?: string, filePath?: string) {
if (filePath?.startsWith("s3://")) return filePath;
if (imageUrl) return getGeneratedImagePathFromUrl(imageUrl);
return null;
}
async function readRemoteImage(imageUrl: string) {
if (!isAllowedRemoteImageUrl(imageUrl)) return null;
const response = await fetch(imageUrl, { cache: "no-store" });
if (!response.ok) return null;
const contentType = response.headers.get("content-type") || "image/png";
if (!contentType.startsWith("image/")) return null;
return {
buffer: Buffer.from(await response.arrayBuffer()),
contentType,
};
}
function isAllowedRemoteImageUrl(imageUrl: string) {
try {
const url = new URL(imageUrl);
return (
url.protocol === "https:" &&
url.hostname === "file.hanhan.ltd" &&
url.pathname.startsWith("/imagegen-tools/generated/")
);
} catch {
return false;
}
}
function getImageContentType(filePath: string) {
const extension = filePath.split(".").pop()?.toLowerCase();
if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
if (extension === "webp") return "image/webp";
return "image/png";
}
+99
View File
@@ -0,0 +1,99 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function POST(request: Request) {
const payload = (await request.json().catch(() => ({}))) as {
baseUrl?: string;
apiKey?: string;
};
const baseUrl = normalizeBaseUrl(payload.baseUrl);
const apiKey = payload.apiKey?.trim();
if (!baseUrl || !apiKey) {
return NextResponse.json(
{ error: "请填写 Base URL 和 API Key" },
{ status: 400 },
);
}
const response = await fetch(`${baseUrl}/v1/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
},
cache: "no-store",
}).catch((error) => {
throw Object.assign(new Error("模型列表拉取失败"), {
cause: error,
status: 502,
});
});
const payloadText = await response.text();
const body = parseJson(payloadText);
if (!response.ok) {
return NextResponse.json(
{
error:
getStringField(body, "error") ||
getNestedStringField(body, ["error", "message"]) ||
`模型列表拉取失败:HTTP ${response.status}`,
},
{ status: response.status },
);
}
const models = extractModelIds(body);
return NextResponse.json({ models });
}
function normalizeBaseUrl(value: string | undefined) {
if (!value) return "";
return value.trim().replace(/\/+$/, "");
}
function parseJson(value: string) {
try {
return JSON.parse(value) as unknown;
} catch {
return {};
}
}
function extractModelIds(payload: unknown) {
const candidate = payload as { data?: unknown };
const list = Array.isArray(candidate.data)
? candidate.data
: Array.isArray(payload)
? payload
: [];
return Array.from(
new Set(
list
.map((item) =>
typeof item === "string"
? item
: typeof item === "object" && item !== null && "id" in item
? String((item as { id: unknown }).id)
: "",
)
.filter(Boolean),
),
).sort((a, b) => a.localeCompare(b));
}
function getStringField(payload: unknown, key: string) {
if (!payload || typeof payload !== "object" || !(key in payload)) return "";
const value = (payload as Record<string, unknown>)[key];
return typeof value === "string" ? value : "";
}
function getNestedStringField(payload: unknown, keys: string[]) {
let current = payload;
for (const key of keys) {
if (!current || typeof current !== "object" || !(key in current)) return "";
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : "";
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import {
defaultCanvasSettings,
normalizeCanvasSettings,
} from "@/lib/canvas/settings";
import {
getJsonSetting,
setJsonSetting,
} from "@/lib/server/repositories/app-settings-repository";
export const runtime = "nodejs";
const canvasSettingsKey = "canvasSettings";
export async function GET() {
const settings = getJsonSetting(canvasSettingsKey, defaultCanvasSettings);
return NextResponse.json({ item: normalizeCanvasSettings(settings) });
}
export async function PUT(request: Request) {
const payload = await request.json().catch(() => ({}));
const settings = normalizeCanvasSettings(payload);
setJsonSetting(canvasSettingsKey, settings);
return NextResponse.json({ item: settings });
}
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import {
deleteHistoryItem,
updateHistoryNote,
} from "@/lib/server/repositories/history-repository";
export const runtime = "nodejs";
type RouteContext = {
params: Promise<{ id: string }>;
};
export async function DELETE(_request: Request, context: RouteContext) {
const { id } = await context.params;
if (!id) {
return NextResponse.json({ error: "Missing history id" }, { status: 400 });
}
const deleted = deleteHistoryItem(id);
if (!deleted) {
return NextResponse.json({ error: "素材不存在或已删除" }, { status: 404 });
}
return NextResponse.json({ ok: true });
}
export async function PATCH(request: Request, context: RouteContext) {
const { id } = await context.params;
if (!id) {
return NextResponse.json({ error: "Missing history id" }, { status: 400 });
}
const payload = await request.json().catch(() => ({}));
const note = typeof payload.note === "string" ? payload.note.slice(0, 1000) : "";
const updated = updateHistoryNote(id, note);
if (!updated) {
return NextResponse.json({ error: "素材不存在或已删除" }, { status: 404 });
}
return NextResponse.json({ ok: true, note });
}
+14 -3
View File
@@ -1,8 +1,19 @@
import { NextResponse } from "next/server";
import { listHistory } from "@/lib/server/repositories/history-repository";
import {
listHistory,
type HistorySource,
} from "@/lib/server/repositories/history-repository";
export const runtime = "nodejs";
export async function GET() {
return NextResponse.json({ items: listHistory() });
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const source = normalizeSource(searchParams.get("source"));
const limit = Number(searchParams.get("limit") ?? "100");
return NextResponse.json({ items: listHistory({ source, limit }) });
}
function normalizeSource(value: string | null): HistorySource | "all" | undefined {
if (value === "direct" || value === "canvas" || value === "all") return value;
return undefined;
}
+6 -1
View File
@@ -2,7 +2,12 @@ import { HomeShell, type ActiveView } from "@/components/direct/home-shell";
function normalizeView(value: string | string[] | undefined): ActiveView {
const candidate = Array.isArray(value) ? value[0] : value;
if (candidate === "history" || candidate === "settings" || candidate === "canvas") {
if (
candidate === "history" ||
candidate === "assets" ||
candidate === "settings" ||
candidate === "canvas"
) {
return candidate;
}
return "studio";
File diff suppressed because it is too large Load Diff
+398 -105
View File
@@ -8,10 +8,21 @@ import {
type FormEvent,
type KeyboardEvent,
} from "react";
import { FileText, ImageIcon, Plus, Sparkles, Trash2, Upload, X } from "lucide-react";
import { Archive, FileText, ImageIcon, Search, Sparkles, Trash2, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
@@ -23,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
import { splitPromptSegments } from "@/components/canvas/prompt-mention-preview";
import type { PromptMentionSource } from "@/lib/canvas/prompt-mentions";
import { cn } from "@/lib/utils";
import type { CanvasSettings } from "@/lib/canvas/settings";
import type {
CanvasConfigNodeMetadata,
CanvasImageNodeMetadata,
@@ -45,11 +57,13 @@ type CanvasNodeInspectorProps = {
isHistoryLoading: boolean;
job: ImageJobPayload | null;
showLibrary: boolean;
canvasSettings: CanvasSettings;
promptMentionSources: PromptMentionSource[];
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
onDeleteNode: (id: string) => void;
onGenerateNode: (id: string) => void;
onInsertHistory: (item: HistoryItem) => void;
onDeleteHistory: (item: HistoryItem) => void;
onUploadImage: (file: File) => void;
onReplaceNodeImage: (nodeId: string, file: File) => void;
onCloseLibrary: () => void;
@@ -61,21 +75,26 @@ export function CanvasNodeInspector({
isHistoryLoading,
job,
showLibrary,
canvasSettings,
promptMentionSources,
onPatchNode,
onDeleteNode,
onGenerateNode,
onInsertHistory,
onDeleteHistory,
onUploadImage,
onReplaceNodeImage,
onCloseLibrary,
}: CanvasNodeInspectorProps) {
const [historyFilter, setHistoryFilter] = useState("");
const [libraryKind, setLibraryKind] = useState<"all" | "text" | "image" | "video">("all");
const filteredHistory = history.filter((item) =>
`${item.prompt} ${item.model} ${item.size}`
.toLowerCase()
.includes(historyFilter.toLowerCase()),
);
const visibleHistory =
libraryKind === "all" || libraryKind === "image" ? filteredHistory : [];
if (!node && !showLibrary && !job?.progressMessage) {
return null;
@@ -91,6 +110,7 @@ export function CanvasNodeInspector({
<FloatingCard className="pointer-events-auto flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-200">
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto pr-1">
<SelectedNodeForm
canvasSettings={canvasSettings}
node={node}
promptMentionSources={promptMentionSources}
onDeleteNode={onDeleteNode}
@@ -102,92 +122,27 @@ export function CanvasNodeInspector({
</FloatingCard>
) : null}
</div>
{showLibrary ? (
<FloatingCard className="pointer-events-auto max-h-[calc(100vh-150px)] overflow-auto">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-950"></div>
<div className="mt-1 text-xs text-zinc-500">
</div>
</div>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onCloseLibrary}
>
<X className="size-4" />
</button>
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<Input
className="h-10 rounded-xl border-zinc-200 bg-white"
placeholder="搜索提示词、模型或尺寸..."
value={historyFilter}
onChange={(event) => setHistoryFilter(event.target.value)}
<MaterialLibraryDialog
filter={historyFilter}
isLoading={isHistoryLoading}
items={visibleHistory}
kind={libraryKind}
onClose={onCloseLibrary}
onDelete={onDeleteHistory}
onFilterChange={setHistoryFilter}
onInsert={onInsertHistory}
onKindChange={setLibraryKind}
onUpload={onUploadImage}
/>
<label className="inline-flex h-10 shrink-0 cursor-pointer items-center gap-2 rounded-xl bg-zinc-950 px-3 text-sm font-medium text-white hover:bg-zinc-800">
<Upload className="size-4" />
<input
className="hidden"
type="file"
accept="image/*"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onUploadImage(file);
event.currentTarget.value = "";
}}
/>
</label>
</div>
<div className="mt-4 grid gap-2">
{isHistoryLoading ? (
<LibraryPlaceholder>...</LibraryPlaceholder>
) : null}
{!isHistoryLoading && filteredHistory.length === 0 ? (
<LibraryPlaceholder></LibraryPlaceholder>
) : null}
{filteredHistory.slice(0, 10).map((item) => (
<button
key={item.id}
type="button"
className="flex items-center gap-3 rounded-2xl border border-zinc-200 bg-white p-2.5 text-left transition hover:border-zinc-950 hover:shadow-[0_10px_24px_rgba(24,24,27,.08)]"
onClick={() => onInsertHistory(item)}
>
<div className="flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-zinc-900">
{item.imageUrl ? (
<img
alt={item.prompt}
className="h-full w-full object-cover"
src={item.imageUrl}
/>
) : (
<ImageIcon className="size-5 text-white/60" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-sm leading-5 text-zinc-700">
{item.prompt || "无提示词"}
</div>
<div className="mt-1 text-[11px] text-zinc-500">
{item.model} · {item.size}
</div>
</div>
<Plus className="size-4 shrink-0 text-zinc-500" />
</button>
))}
</div>
</FloatingCard>
) : null}
</div>
</div>
);
}
function SelectedNodeForm({
canvasSettings,
node,
promptMentionSources,
onPatchNode,
@@ -195,6 +150,7 @@ function SelectedNodeForm({
onGenerateNode,
onReplaceNodeImage,
}: {
canvasSettings: CanvasSettings;
node: CanvasNode;
promptMentionSources: PromptMentionSource[];
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
@@ -218,12 +174,12 @@ function SelectedNodeForm({
</div>
</div>
<div className="flex items-center gap-1">
{node.type === "image" ? (
{node.type === "image" || node.type === "prompt" ? (
<Button
className="rounded-full bg-zinc-950 text-white shadow-none hover:bg-zinc-800"
size="icon"
onClick={() => onGenerateNode(node.id)}
title="从当前图像节点生成"
title={node.type === "prompt" ? "从当前提示词生成" : "从当前图像节点生成"}
>
<Sparkles />
</Button>
@@ -253,11 +209,13 @@ function SelectedNodeForm({
<PromptFields
mentionSources={promptMentionSources}
node={node}
onGenerateNode={onGenerateNode}
onPatchNode={onPatchNode}
/>
) : null}
{node.type === "config" ? (
<ConfigFields
canvasSettings={canvasSettings}
node={node}
onGenerateNode={onGenerateNode}
onPatchNode={onPatchNode}
@@ -265,6 +223,7 @@ function SelectedNodeForm({
) : null}
{node.type === "image" ? (
<ImageFields
canvasSettings={canvasSettings}
mentionSources={promptMentionSources}
node={node}
onPatchNode={onPatchNode}
@@ -279,28 +238,266 @@ function SelectedNodeForm({
function PromptFields({
mentionSources,
node,
onGenerateNode,
onPatchNode,
}: {
mentionSources: PromptMentionSource[];
node: CanvasNode;
onGenerateNode: (id: string) => void;
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
}) {
const metadata = node.metadata as CanvasPromptNodeMetadata;
function patchPrompt(nextPrompt: string) {
function patch(next: Partial<CanvasPromptNodeMetadata>) {
onPatchNode(node.id, {
metadata: { ...metadata, prompt: nextPrompt },
metadata: { ...metadata, ...next },
});
}
return (
<>
<Field label="正向提示词">
<MentionPromptEditor
emptyState="当前提示词节点还没有上游图片素材或文本,连接节点后即可使用 `@` 引用。"
mentionHelp="只允许引用已连接到当前提示词节点的图片素材或文本。"
mentionSources={mentionSources}
placeholder={mentionSources.length ? "输入提示词,键入 @ 引用已连接素材" : "输入提示词"}
value={metadata.prompt || ""}
onChange={patchPrompt}
onChange={(prompt) => patch({ prompt })}
/>
</Field>
<div className="flex items-center justify-between rounded-2xl bg-zinc-100 p-3">
<div>
<Label className="text-xs text-zinc-800"></Label>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<Switch
checked={Boolean(metadata.enableNegativePrompt)}
onCheckedChange={(enableNegativePrompt) =>
patch({ enableNegativePrompt })
}
/>
</div>
{metadata.enableNegativePrompt ? (
<Field label="反向提示词">
<MentionPromptEditor
emptyState="反向提示词也可以引用已连接素材或文本,例如排除某个风格、元素或构图。"
mentionHelp="反向提示词开启后才会参与生成;关闭开关会保留文本但不发送。"
mentionSources={mentionSources}
placeholder="例如:不要文字、水印、畸形手指、低清晰度"
value={metadata.negativePrompt || ""}
onChange={(negativePrompt) => patch({ negativePrompt })}
/>
</Field>
) : null}
<button
type="button"
className="flex h-12 w-full items-center justify-center rounded-2xl bg-zinc-950 text-sm font-medium text-white transition hover:bg-zinc-800"
onClick={() => onGenerateNode(node.id)}
>
</button>
</>
);
}
type MaterialKind = "all" | "text" | "image" | "video";
const materialTabs: Array<{ id: MaterialKind; label: string }> = [
{ id: "all", label: "全部" },
{ id: "text", label: "文本" },
{ id: "image", label: "图片" },
{ id: "video", label: "视频" },
];
function MaterialLibraryDialog({
filter,
isLoading,
items,
kind,
onClose,
onDelete,
onFilterChange,
onInsert,
onKindChange,
onUpload,
}: {
filter: string;
isLoading: boolean;
items: HistoryItem[];
kind: MaterialKind;
onClose: () => void;
onDelete: (item: HistoryItem) => void;
onFilterChange: (value: string) => void;
onInsert: (item: HistoryItem) => void;
onKindChange: (kind: MaterialKind) => void;
onUpload: (file: File) => void;
}) {
return (
<div
className="pointer-events-auto absolute inset-0 z-[95] flex items-center justify-center bg-black/10 p-6 backdrop-blur-[1px]"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
className="flex h-[min(620px,calc(100vh-48px))] w-[min(860px,calc(100vw-48px))] flex-col rounded-xl bg-white shadow-[0_24px_80px_rgba(24,24,27,.18)] ring-1 ring-zinc-200"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4">
<h2 className="text-base font-semibold text-zinc-950"></h2>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onClose}
title="关闭"
>
<X className="size-5" />
</button>
</div>
<div className="shrink-0 px-12 pt-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-zinc-400" />
<Input
className="h-9 rounded border-zinc-300 bg-white pl-9"
placeholder="搜索素材"
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-5">
{materialTabs.map((tab) => (
<button
key={tab.id}
type="button"
className={cn(
"rounded-md px-2.5 py-1 text-sm transition",
kind === tab.id
? "bg-zinc-950 font-semibold text-white"
: "text-zinc-700 hover:bg-zinc-100 hover:text-zinc-950",
)}
onClick={() => onKindChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
<label className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 text-sm font-medium text-zinc-700 transition hover:border-zinc-400 hover:text-zinc-950">
<Upload className="size-4" />
<input
className="hidden"
type="file"
accept="image/*"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) onUpload(file);
event.currentTarget.value = "";
}}
/>
</label>
</div>
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto px-12 py-5">
{isLoading ? (
<MaterialEmptyState>...</MaterialEmptyState>
) : items.length ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(128px,1fr))] gap-4">
{items.map((item) => (
<MaterialImageCard
key={item.id}
item={item}
onDelete={onDelete}
onInsert={onInsert}
/>
))}
</div>
) : (
<MaterialEmptyState></MaterialEmptyState>
)}
</div>
</div>
</div>
);
}
function MaterialImageCard({
item,
onDelete,
onInsert,
}: {
item: HistoryItem;
onDelete: (item: HistoryItem) => void;
onInsert: (item: HistoryItem) => void;
}) {
return (
<div className="group relative">
<button
type="button"
className="block w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-100 text-left shadow-sm transition hover:border-zinc-950 hover:shadow-[0_14px_34px_rgba(24,24,27,.12)]"
onClick={() => onInsert(item)}
title="插入画布"
>
<div className="aspect-square overflow-hidden bg-zinc-900">
{item.imageUrl ? (
<img
alt={item.prompt || "素材图片"}
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
src={item.imageUrl}
/>
) : (
<div className="flex h-full items-center justify-center">
<ImageIcon className="size-7 text-white/60" />
</div>
)}
</div>
<div className="space-y-1 bg-white p-2.5">
<div className="line-clamp-1 text-xs font-medium text-zinc-900">
{item.prompt || "无提示词"}
</div>
<div className="truncate text-[11px] text-zinc-500">
{item.model} · {item.size}
</div>
</div>
</button>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
type="button"
className="absolute right-2 top-2 flex size-7 items-center justify-center rounded-full bg-white/90 text-zinc-500 opacity-0 shadow-sm ring-1 ring-zinc-200 backdrop-blur transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
onClick={(event) => event.stopPropagation()}
title="删除素材"
>
<Trash2 className="size-3.5" />
</button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(event) => event.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
使
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(item)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function MaterialEmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full min-h-[300px] flex-col items-center justify-center text-zinc-400">
<Archive className="mb-3 size-12 stroke-[1.4]" />
<div className="text-sm">{children}</div>
</div>
);
}
@@ -740,10 +937,12 @@ function placeCaretAfter(node: Node) {
}
function ConfigFields({
canvasSettings,
node,
onGenerateNode,
onPatchNode,
}: {
canvasSettings: CanvasSettings;
node: CanvasNode;
onGenerateNode: (id: string) => void;
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
@@ -751,6 +950,7 @@ function ConfigFields({
const metadata = node.metadata as CanvasConfigNodeMetadata;
const patch = (next: Partial<CanvasConfigNodeMetadata>) =>
onPatchNode(node.id, { metadata: { ...metadata, ...next } });
const selectedChannelId = metadata.channelId || canvasSettings.selectedChannelId;
return (
<>
@@ -766,13 +966,12 @@ function ConfigFields({
<InfoChip> 0 </InfoChip>
<InfoChip> 0 </InfoChip>
</div>
<Field label="模型">
<Input
className="h-10 rounded-xl border-zinc-200 bg-white"
value={metadata.model}
onChange={(event) => patch({ model: event.target.value })}
<ModelChannelFields
channelId={selectedChannelId}
model={metadata.model}
settings={canvasSettings}
onChange={(next) => patch(next)}
/>
</Field>
<SizeSelector
size={metadata.size}
onSizeChange={(size) => patch({ size })}
@@ -827,12 +1026,115 @@ function ConfigFields({
);
}
type ModelChannelPatch = {
channelId?: string;
model?: string;
};
function ModelChannelFields({
channelId,
model,
settings,
onChange,
}: {
channelId: string;
model: string;
settings: CanvasSettings;
onChange: (next: ModelChannelPatch) => void;
}) {
const selectedChannel =
settings.channels.find((channel) => channel.id === channelId) ??
settings.channels[0];
const channelModels = selectedChannel?.models ?? [];
const resolvedModel =
model ||
settings.defaultModels.image ||
settings.modelPreferences.image[0] ||
channelModels[0] ||
"";
const modelOptions = resolvedModel && !channelModels.includes(resolvedModel)
? [resolvedModel, ...channelModels]
: channelModels;
return (
<div className="rounded-2xl border border-zinc-200 bg-white p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-zinc-900"></div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<span className="rounded-full bg-zinc-100 px-2 py-1 text-[11px] text-zinc-600">
</span>
</div>
<div className="grid gap-3">
<Field label="渠道配置">
<Select
value={selectedChannel?.id ?? ""}
onValueChange={(nextChannelId) => {
const nextChannel =
settings.channels.find((channel) => channel.id === nextChannelId) ??
settings.channels[0];
const nextModels = nextChannel?.models ?? [];
const nextModel = nextModels.includes(resolvedModel)
? resolvedModel
: nextModels[0] || resolvedModel;
onChange({ channelId: nextChannelId, model: nextModel });
}}
>
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
<SelectValue placeholder="选择渠道" />
</SelectTrigger>
<SelectContent>
{settings.channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="模型名称">
{modelOptions.length ? (
<Select
value={resolvedModel}
onValueChange={(nextModel) => onChange({ model: nextModel })}
>
<SelectTrigger className="h-10 rounded-xl border-zinc-200 bg-white">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{modelOptions.map((modelName) => (
<SelectItem key={modelName} value={modelName}>
{modelName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
className="h-10 rounded-xl border-zinc-200 bg-white"
placeholder="先在渠道配置中添加模型"
value={resolvedModel}
onChange={(event) => onChange({ model: event.target.value })}
/>
)}
</Field>
</div>
</div>
);
}
function ImageFields({
canvasSettings,
mentionSources,
node,
onPatchNode,
onReplaceNodeImage,
}: {
canvasSettings: CanvasSettings;
mentionSources: PromptMentionSource[];
node: CanvasNode;
onPatchNode: (id: string, patch: Partial<CanvasNode>) => void;
@@ -841,6 +1143,7 @@ function ImageFields({
const metadata = node.metadata as CanvasImageNodeMetadata;
const patch = (next: Partial<CanvasImageNodeMetadata>) =>
onPatchNode(node.id, { metadata: { ...metadata, ...next } });
const selectedChannelId = metadata.channelId || canvasSettings.selectedChannelId;
return (
<>
@@ -890,14 +1193,12 @@ function ImageFields({
) : null}
</div>
</Field>
<Field label="模型">
<Input
className="h-10 rounded-xl border-zinc-200 bg-white"
placeholder="gpt-image-2"
value={metadata.model || ""}
onChange={(event) => patch({ model: event.target.value })}
<ModelChannelFields
channelId={selectedChannelId}
model={metadata.model || ""}
settings={canvasSettings}
onChange={(next) => patch(next)}
/>
</Field>
<Field label="模式">
<Select
value={metadata.mode}
@@ -1079,11 +1380,3 @@ function MiniBadge({
</span>
);
}
function LibraryPlaceholder({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-2xl bg-zinc-100 p-3 text-sm text-zinc-500">
{children}
</div>
);
}
+386 -4
View File
@@ -10,8 +10,10 @@ import {
} from "react";
import { createPortal } from "react-dom";
import {
Brush,
Code2,
Download,
Eraser,
FileText,
ImageIcon,
Info,
@@ -24,6 +26,7 @@ import {
Sparkles,
Trash2,
Upload,
WandSparkles,
X,
} from "lucide-react";
import { PromptMentionPreview } from "@/components/canvas/prompt-mention-preview";
@@ -49,6 +52,7 @@ type CanvasNodeProps = {
activeConnecting: boolean;
connectionTarget: boolean;
promptMentionSources?: PromptMentionSource[];
showImageInfoBadge?: boolean;
onSelect: (id: string, additive?: boolean) => void;
onDragStart: (event: ReactMouseEvent, node: CanvasNodeType) => void;
onConnectStart: (id: string) => void;
@@ -56,9 +60,16 @@ type CanvasNodeProps = {
onResize: (id: string, width: number, height: number) => void;
onDelete: (id: string) => void;
onGenerate: (id: string) => void;
onLocalEdit: (payload: LocalImageEditPayload) => void;
onReplaceImage: (id: string, file: File) => void;
};
export type LocalImageEditPayload = {
nodeId: string;
prompt: string;
mask: File;
};
export function CanvasNode({
node,
scale,
@@ -69,6 +80,7 @@ export function CanvasNode({
activeConnecting,
connectionTarget,
promptMentionSources = [],
showImageInfoBadge = false,
onSelect,
onDragStart,
onConnectStart,
@@ -76,6 +88,7 @@ export function CanvasNode({
onResize,
onDelete,
onGenerate,
onLocalEdit,
onReplaceImage,
}: CanvasNodeProps) {
const [previewImage, setPreviewImage] = useState<{
@@ -143,9 +156,9 @@ export function CanvasNode({
imageReferenceLabel={imageReferenceLabel}
node={node}
onOpenImagePreview={(image) => setPreviewImage(image)}
onGenerate={onGenerate}
onSelect={onSelect}
promptMentionSources={promptMentionSources}
showImageInfoBadge={showImageInfoBadge}
/>
</div>
@@ -156,6 +169,7 @@ export function CanvasNode({
visible={selected}
onDelete={onDelete}
onGenerate={onGenerate}
onLocalEdit={onLocalEdit}
onOpenImagePreview={(image) => setPreviewImage(image)}
onReplaceImage={onReplaceImage}
onSelect={onSelect}
@@ -280,16 +294,16 @@ function NodeBody({
imageReferenceLabel,
node,
onOpenImagePreview,
onGenerate,
onSelect,
promptMentionSources,
showImageInfoBadge,
}: {
imageReferenceLabel?: string;
node: CanvasNodeType;
onOpenImagePreview: (image: { title: string; url: string }) => void;
onGenerate: (id: string) => void;
onSelect: (id: string) => void;
promptMentionSources: PromptMentionSource[];
showImageInfoBadge: boolean;
}) {
if (node.type === "prompt") {
const metadata = node.metadata as CanvasPromptNodeMetadata;
@@ -303,6 +317,11 @@ function NodeBody({
prompt={metadata.prompt || ""}
textClassName="text-zinc-700"
/>
{metadata.enableNegativePrompt && metadata.negativePrompt?.trim() ? (
<div className="mt-3 rounded-2xl border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500">
{metadata.negativePrompt.trim()}
</div>
) : null}
</div>
);
}
@@ -328,6 +347,7 @@ function NodeBody({
node={node}
onOpenImagePreview={onOpenImagePreview}
onSelect={onSelect}
showImageInfoBadge={showImageInfoBadge}
/>
);
}
@@ -338,12 +358,14 @@ function ImageNodeBody({
node,
onOpenImagePreview,
onSelect,
showImageInfoBadge,
}: {
imageReferenceLabel?: string;
metadata: CanvasImageNodeMetadata;
node: CanvasNodeType;
onOpenImagePreview: (image: { title: string; url: string }) => void;
onSelect: (id: string) => void;
showImageInfoBadge: boolean;
}) {
if (metadata.status === "loading") {
return (
@@ -398,10 +420,49 @@ function ImageNodeBody({
src={metadata.imageUrl}
/>
</div>
{showImageInfoBadge ? (
<ImageInfoBadge metadata={metadata} />
) : null}
</div>
);
}
function ImageInfoBadge({ metadata }: { metadata: CanvasImageNodeMetadata }) {
const label = formatImageInfo(metadata);
if (!label) return null;
return (
<div className="pointer-events-none absolute bottom-3 right-3 z-10 rounded-full bg-zinc-900/72 px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white shadow-[0_10px_24px_rgba(24,24,27,.22)] ring-1 ring-white/20 backdrop-blur-md">
{label}
</div>
);
}
function formatImageInfo(metadata: CanvasImageNodeMetadata) {
const width = metadata.naturalWidth;
const height = metadata.naturalHeight;
const dimensions =
width && height ? `${Math.round(width)} x ${Math.round(height)}` : "";
const fileSize = formatFileSize(metadata.fileSize);
return [dimensions, fileSize].filter(Boolean).join(" · ");
}
function formatFileSize(value: number | undefined) {
if (!value || value <= 0) return "";
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB"];
let size = value / 1024;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}`;
}
function ImageNodeTitle({ node }: { node: CanvasNodeType }) {
return (
<div className="flex min-w-0 items-center gap-2">
@@ -516,7 +577,7 @@ function ImageZoomDialog({
<div
ref={scrollRef}
className={cn(
"thin-scrollbar flex min-h-0 flex-1 overflow-auto bg-white",
"flex min-h-0 flex-1 overflow-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
dragState ? "cursor-grabbing" : "cursor-grab",
)}
onMouseDown={(event) => {
@@ -583,6 +644,306 @@ function ImageZoomDialog({
);
}
type LocalEditTool = "brush" | "erase";
type LocalEditPoint = {
displayX: number;
displayY: number;
maskX: number;
maskY: number;
};
function LocalImageEditDialog({
image,
onClose,
onSubmit,
}: {
image: { title: string; url: string };
onClose: () => void;
onSubmit: (payload: { prompt: string; mask: File }) => void;
}) {
const imageRef = useRef<HTMLImageElement | null>(null);
const overlayRef = useRef<HTMLCanvasElement | null>(null);
const maskRef = useRef<HTMLCanvasElement | null>(null);
const [tool, setTool] = useState<LocalEditTool>("brush");
const [brushSize, setBrushSize] = useState(100);
const [prompt, setPrompt] = useState("");
const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
const [isDrawing, setIsDrawing] = useState(false);
const [hasMask, setHasMask] = useState(false);
const lastPointRef = useRef<LocalEditPoint | null>(null);
const syncCanvases = () => {
const imageElement = imageRef.current;
const overlay = overlayRef.current;
const mask = maskRef.current;
if (!imageElement || !overlay || !mask || !naturalSize.width || !naturalSize.height) {
return;
}
const rect = imageElement.getBoundingClientRect();
overlay.width = Math.max(1, Math.round(rect.width));
overlay.height = Math.max(1, Math.round(rect.height));
mask.width = naturalSize.width;
mask.height = naturalSize.height;
};
useEffect(() => {
syncCanvases();
window.addEventListener("resize", syncCanvases);
return () => window.removeEventListener("resize", syncCanvases);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [naturalSize.width, naturalSize.height]);
const getCanvasPoint = (event: ReactMouseEvent<HTMLCanvasElement>) => {
const canvas = overlayRef.current;
if (!canvas || !naturalSize.width || !naturalSize.height) return null;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return {
displayX: x,
displayY: y,
maskX: (x / Math.max(1, rect.width)) * naturalSize.width,
maskY: (y / Math.max(1, rect.height)) * naturalSize.height,
};
};
const drawStroke = (from: LocalEditPoint, to: LocalEditPoint) => {
const overlay = overlayRef.current;
const mask = maskRef.current;
if (!overlay || !mask || !naturalSize.width) return;
const overlayContext = overlay.getContext("2d");
const maskContext = mask.getContext("2d");
if (!overlayContext || !maskContext) return;
const displayScale = overlay.width / naturalSize.width;
const displayBrushSize = brushSize * displayScale;
overlayContext.save();
overlayContext.globalCompositeOperation = tool === "erase" ? "destination-out" : "source-over";
overlayContext.strokeStyle = "rgba(92, 120, 255, 0.38)";
overlayContext.lineWidth = displayBrushSize;
overlayContext.lineCap = "round";
overlayContext.lineJoin = "round";
overlayContext.beginPath();
overlayContext.moveTo(from.displayX, from.displayY);
overlayContext.lineTo(to.displayX, to.displayY);
overlayContext.stroke();
overlayContext.restore();
maskContext.save();
maskContext.globalCompositeOperation = tool === "erase" ? "destination-out" : "source-over";
maskContext.strokeStyle = "rgba(255, 255, 255, 1)";
maskContext.lineWidth = brushSize;
maskContext.lineCap = "round";
maskContext.lineJoin = "round";
maskContext.beginPath();
maskContext.moveTo(from.maskX, from.maskY);
maskContext.lineTo(to.maskX, to.maskY);
maskContext.stroke();
maskContext.restore();
setHasMask(true);
};
const resetMask = () => {
const overlay = overlayRef.current;
const mask = maskRef.current;
overlay?.getContext("2d")?.clearRect(0, 0, overlay.width, overlay.height);
mask?.getContext("2d")?.clearRect(0, 0, mask.width, mask.height);
setHasMask(false);
};
const submitEdit = () => {
if (!prompt.trim()) return;
if (!hasMask) return;
const mask = maskRef.current;
if (!mask) return;
const apiMask = document.createElement("canvas");
apiMask.width = mask.width;
apiMask.height = mask.height;
const apiMaskContext = apiMask.getContext("2d");
if (!apiMaskContext) return;
apiMaskContext.fillStyle = "rgba(255, 255, 255, 1)";
apiMaskContext.fillRect(0, 0, apiMask.width, apiMask.height);
apiMaskContext.globalCompositeOperation = "destination-out";
apiMaskContext.drawImage(mask, 0, 0);
apiMask.toBlob((blob) => {
if (!blob) return;
onSubmit({
prompt: prompt.trim(),
mask: new File([blob], "local-edit-mask.png", { type: "image/png" }),
});
}, "image/png");
};
return createPortal(
<div
className="fixed inset-0 z-[210] flex items-center justify-center bg-black/42 p-4 backdrop-blur-[1px]"
data-canvas-ui
onMouseDown={(event) => {
event.stopPropagation();
if (event.target === event.currentTarget) onClose();
}}
>
<div
className="grid h-[632px] max-h-[calc(100vh-32px)] w-[980px] max-w-[calc(100vw-32px)] grid-cols-[minmax(0,1fr)_320px] gap-5 overflow-hidden rounded-lg bg-white p-5 shadow-2xl ring-1 ring-zinc-200 max-lg:grid-cols-1"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="flex min-h-0 items-center justify-center overflow-hidden rounded-lg bg-zinc-100">
<div className="relative inline-block max-h-full max-w-full">
<img
ref={imageRef}
alt={image.title}
className="block max-h-full max-w-full select-none object-contain"
draggable={false}
src={image.url}
onLoad={(event) => {
const element = event.currentTarget;
setNaturalSize({
width: element.naturalWidth || 1,
height: element.naturalHeight || 1,
});
requestAnimationFrame(syncCanvases);
}}
/>
<canvas
ref={overlayRef}
className="absolute inset-0 touch-none"
onMouseDown={(event) => {
const point = getCanvasPoint(event);
if (!point) return;
setIsDrawing(true);
lastPointRef.current = point;
drawStroke(point, point);
}}
onMouseMove={(event) => {
if (!isDrawing) return;
const point = getCanvasPoint(event);
const lastPoint = lastPointRef.current;
if (!point || !lastPoint) return;
drawStroke(lastPoint, point);
lastPointRef.current = point;
}}
onMouseLeave={() => {
setIsDrawing(false);
lastPointRef.current = null;
}}
onMouseUp={() => {
setIsDrawing(false);
lastPointRef.current = null;
}}
/>
<canvas ref={maskRef} className="hidden" />
</div>
</div>
<aside className="flex min-h-0 flex-col">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-zinc-950"></h2>
<p className="mt-2 text-sm text-zinc-500">
{naturalSize.width && naturalSize.height
? `${naturalSize.width} x ${naturalSize.height}px`
: "读取图片尺寸中"}
</p>
</div>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onClose}
title="关闭"
>
<X className="size-5" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
className={cn(
"flex h-9 items-center justify-center gap-2 rounded-md border text-sm font-medium transition",
tool === "brush"
? "border-zinc-950 bg-zinc-950 text-white"
: "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50",
)}
onClick={() => setTool("brush")}
>
<Brush className="size-4" />
</button>
<button
type="button"
className={cn(
"flex h-9 items-center justify-center gap-2 rounded-md border text-sm font-medium transition",
tool === "erase"
? "border-zinc-950 bg-zinc-950 text-white"
: "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50",
)}
onClick={() => setTool("erase")}
>
<Eraser className="size-4" />
</button>
</div>
<label className="mt-5 block text-sm font-medium text-zinc-700">
<span className="flex items-center justify-between">
<span></span>
<span>{brushSize}px</span>
</span>
<input
className="mt-3 w-full accent-zinc-950"
max={220}
min={12}
onChange={(event) => setBrushSize(Number(event.target.value))}
type="range"
value={brushSize}
/>
</label>
<label className="mt-6 flex min-h-0 flex-1 flex-col text-sm font-medium text-zinc-700">
<textarea
className="mt-2 min-h-[140px] flex-1 resize-none rounded-md border border-zinc-200 px-3 py-2 text-sm font-normal leading-6 text-zinc-950 outline-none transition focus:border-zinc-400"
onChange={(event) => setPrompt(event.target.value)}
placeholder="例如:把选中区域改成金属材质,保持原图光影"
value={prompt}
/>
</label>
<div className="mt-5 flex items-center justify-between gap-3">
<button
type="button"
className="flex h-9 items-center justify-center rounded-md border border-zinc-200 bg-white px-4 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50"
onClick={resetMask}
>
</button>
<div className="flex items-center gap-2">
<button
type="button"
className="flex h-9 items-center justify-center rounded-md border border-zinc-200 bg-white px-4 text-sm font-medium text-zinc-700 transition hover:bg-zinc-50"
onClick={onClose}
>
</button>
<button
type="button"
className="flex h-9 items-center justify-center gap-2 rounded-md bg-zinc-950 px-4 text-sm font-medium text-white transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40"
disabled={!prompt.trim() || !hasMask}
onClick={submitEdit}
>
<WandSparkles className="size-4" />
AI
</button>
</div>
</div>
</aside>
</div>
</div>,
document.body,
);
}
function NodeIcon({ type }: { type: CanvasNodeType["type"] }) {
const className = "size-4";
if (type === "prompt") return <FileText className={className} />;
@@ -596,6 +957,7 @@ function ImageNodeToolbar({
visible,
onDelete,
onGenerate,
onLocalEdit,
onOpenImagePreview,
onReplaceImage,
onSelect,
@@ -605,11 +967,13 @@ function ImageNodeToolbar({
visible: boolean;
onDelete: (id: string) => void;
onGenerate: (id: string) => void;
onLocalEdit: (payload: LocalImageEditPayload) => void;
onOpenImagePreview: (image: { title: string; url: string }) => void;
onReplaceImage: (id: string, file: File) => void;
onSelect: (id: string) => void;
}) {
const replaceInputRef = useRef<HTMLInputElement | null>(null);
const [showLocalEdit, setShowLocalEdit] = useState(false);
return (
<>
@@ -656,6 +1020,14 @@ function ImageNodeToolbar({
label="替换图片"
onClick={() => replaceInputRef.current?.click()}
/>
<ImageToolbarButton
icon={<Brush className="size-4" />}
label="局部编辑"
onClick={() => {
onSelect(node.id);
setShowLocalEdit(true);
}}
/>
<ImageToolbarButton
icon={<Sparkles className="size-4" />}
label="生成"
@@ -665,6 +1037,16 @@ function ImageNodeToolbar({
}}
/>
</div>
{showLocalEdit ? (
<LocalImageEditDialog
image={{ title: node.title, url: imageUrl }}
onClose={() => setShowLocalEdit(false)}
onSubmit={(payload) => {
setShowLocalEdit(false);
onLocalEdit({ ...payload, nodeId: node.id });
}}
/>
) : null}
</>
);
}
@@ -5,6 +5,17 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Pencil, Plus, RefreshCcw, Trash2, X } from "lucide-react";
import { toast, Toaster } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import type { CanvasProjectListItem } from "@/lib/canvas/types";
@@ -105,9 +116,6 @@ export function CanvasProjectsClient() {
}
async function deleteProject(project: CanvasProjectListItem) {
const confirmed = window.confirm(`删除画布「${project.title}」?此操作不可撤销。`);
if (!confirmed) return;
setDeletingProjectId(project.id);
try {
const response = await fetch(`/api/canvas/projects/${project.id}`, {
@@ -187,15 +195,32 @@ export function CanvasProjectsClient() {
>
<Pencil className="size-4" />
</button>
<AlertDialog>
<AlertDialogTrigger asChild>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-red-50 hover:text-red-600 disabled:pointer-events-none disabled:opacity-40"
disabled={deletingProjectId === project.id}
onClick={() => void deleteProject(project)}
title="删除画布"
>
<Trash2 className="size-4" />
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{project.title}</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => void deleteProject(project)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
+166 -29
View File
@@ -1,5 +1,6 @@
"use client";
import {useEffect, useRef, useState} from "react";
import {
ClipboardPaste,
Copy,
@@ -7,6 +8,7 @@ import {
FolderOpen,
Grid2X2,
Hand,
Home,
ImageIcon,
Keyboard,
Loader2,
@@ -14,9 +16,12 @@ import {
Minus,
Plus,
Redo2,
BookOpenText,
Save,
Settings,
Sparkles,
SquareDashedMousePointer,
LayoutGrid,
Trash2,
Type,
Undo2,
@@ -30,6 +35,7 @@ type CanvasToolbarProps = {
title: string;
isSaving: boolean;
isGenerating: boolean;
lastSavedAt: Date | null;
backgroundMode: CanvasBackgroundMode;
scale: number;
showLibrary: boolean;
@@ -38,16 +44,20 @@ type CanvasToolbarProps = {
canRedo: boolean;
canDeleteSelection: boolean;
hideDockLabels: boolean;
onBack: () => void;
onAddNode: (type: CanvasNodeType) => void;
onUndo: () => void;
onRedo: () => void;
onGenerate: () => void;
onSave: () => void;
onUploadMaterial: () => void;
onCopyJson: () => void;
onImportJson: () => void;
onDeleteSelection: () => void;
onClearCanvas: () => void;
onGoHome: () => void;
onGoCanvasList: () => void;
onCreateCanvas: () => void;
onDeleteCurrentCanvas: () => void;
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
onZoomIn: () => void;
onZoomOut: () => void;
@@ -62,6 +72,7 @@ export function CanvasToolbar({
title,
isSaving,
isGenerating,
lastSavedAt,
backgroundMode,
scale,
showLibrary,
@@ -70,16 +81,20 @@ export function CanvasToolbar({
canRedo,
canDeleteSelection,
hideDockLabels,
onBack,
onAddNode,
onUndo,
onRedo,
onGenerate,
onSave,
onUploadMaterial,
onCopyJson,
onImportJson,
onDeleteSelection,
onClearCanvas,
onGoHome,
onGoCanvasList,
onCreateCanvas,
onDeleteCurrentCanvas,
onBackgroundModeChange,
onZoomIn,
onZoomOut,
@@ -89,31 +104,96 @@ export function CanvasToolbar({
onShowShortcuts,
onOpenSettings,
}: CanvasToolbarProps) {
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!menuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (menuRef.current?.contains(target)) return;
setMenuOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setMenuOpen(false);
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [menuOpen]);
return (
<>
<div
className="pointer-events-none absolute inset-x-0 top-0 z-[90] flex items-start justify-between p-4"
data-canvas-ui
>
<div className="pointer-events-auto flex items-center gap-2.5 rounded-[13px] bg-white/92 px-3 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<div ref={menuRef}
className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-3 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl relative">
<div className="flex items-center gap-3">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onBack}
title="返回画布列表"
className="flex size-8 items-center justify-center rounded-md text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={() => setMenuOpen((current) => !current)}
title="打开菜单"
aria-expanded={menuOpen}
aria-haspopup="menu"
>
<Menu className="size-4" />
<Menu className="size-5"/>
</button>
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-zinc-950">{title}</div>
<div className="mt-0.5 text-[10px] tracking-[0.16em] text-zinc-400 uppercase">
</div>
</div>
<div className="truncate text-[15px] font-semibold text-zinc-950">{title}</div>
</div>
<div className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<StatusPill isGenerating={isGenerating} isSaving={isSaving} />
{menuOpen ? (
<div
className="absolute left-0 top-[52px] w-[244px] overflow-hidden rounded-[14px] bg-white p-2 shadow-[0_24px_60px_rgba(24,24,27,.14)] ring-1 ring-black/5">
<MenuItem icon={<Home className="size-4"/>} label="主页" onClick={onGoHome}/>
<MenuItem icon={<BookOpenText className="size-4"/>} label="文档" disabled/>
<MenuItem icon={<LayoutGrid className="size-4"/>} label="我的画布"
onClick={onGoCanvasList}/>
<MenuDivider/>
<MenuItem icon={<Plus className="size-4"/>} label="新建画布" onClick={onCreateCanvas}/>
<MenuItem
danger
icon={<Trash2 className="size-4"/>}
label="删除当前画布"
onClick={onDeleteCurrentCanvas}
/>
<MenuDivider/>
<MenuItem icon={<Upload className="size-4"/>} label="导入素材" onClick={onUploadMaterial}/>
<MenuDivider/>
<MenuItem
icon={<Undo2 className="size-4"/>}
label="撤销"
onClick={onUndo}
shortcut="⌘ Z"
disabled={!canUndo}
/>
<MenuItem
icon={<Redo2 className="size-4"/>}
label="重做"
onClick={onRedo}
shortcut="⌘ ⇧ Z / ⌘ Y"
disabled={!canRedo}
/>
</div>
) : null}
</div>
<div
className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-2 shadow-[0_12px_40px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<SaveStatusButton
isGenerating={isGenerating}
isSaving={isSaving}
lastSavedAt={lastSavedAt}
onSave={onSave}
/>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
@@ -169,7 +249,8 @@ export function CanvasToolbar({
className="pointer-events-none absolute bottom-4 left-4 z-[90]"
data-canvas-ui
>
<div className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_14px_42px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<div
className="pointer-events-auto flex items-center gap-1.5 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_14px_42px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<button
type="button"
className="flex size-7 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
@@ -204,7 +285,8 @@ export function CanvasToolbar({
className="pointer-events-none absolute bottom-4 left-1/2 z-[90] -translate-x-1/2"
data-canvas-ui
>
<div className="pointer-events-auto flex items-center gap-1 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_16px_44px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<div
className="pointer-events-auto flex items-center gap-1 rounded-[13px] bg-white/92 px-2.5 py-1.5 shadow-[0_16px_44px_rgba(24,24,27,.08)] ring-1 ring-zinc-200 backdrop-blur-xl">
<DockButton
active={selectionMode}
hideLabel={hideDockLabels}
@@ -303,27 +385,82 @@ export function CanvasToolbar({
);
}
function StatusPill({
function MenuItem({
icon,
label,
shortcut,
danger = false,
disabled = false,
onClick,
}: {
icon: React.ReactNode;
label: string;
shortcut?: string;
danger?: boolean;
disabled?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className={`flex w-full items-center gap-3 rounded-[11px] px-3 py-2.5 text-left text-[14px] transition disabled:cursor-not-allowed disabled:opacity-40 ${
danger
? "text-red-500 hover:bg-red-50"
: "text-zinc-800 hover:bg-zinc-100 hover:text-zinc-950"
}`}
onClick={onClick}
>
<span className={`flex size-5 items-center justify-center ${danger ? "text-red-500" : "text-zinc-700"}`}>
{icon}
</span>
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span className="truncate">{label}</span>
{shortcut ? <span className="shrink-0 text-[12px] text-zinc-400">{shortcut}</span> : null}
</span>
</button>
);
}
function MenuDivider() {
return <div className="my-2 h-px bg-zinc-200"/>;
}
function SaveStatusButton({
isSaving,
isGenerating,
lastSavedAt,
onSave,
}: {
isSaving: boolean;
isGenerating: boolean;
lastSavedAt: Date | null;
onSave: () => void;
}) {
const label = isGenerating ? "生成中" : isSaving ? "保存中" : "已同步";
const tone = isGenerating
? "bg-amber-100 text-amber-800"
: isSaving
? "bg-sky-100 text-sky-800"
: "bg-emerald-100 text-emerald-800";
const label = isGenerating ? "生成中" : isSaving ? "保存中" : "保存";
const savedTime = lastSavedAt
? lastSavedAt.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
: "--:--";
const title = isSaving ? "正在保存画布" : `保存画布,上次保存 ${savedTime}`;
return (
<div
className={`rounded-full px-2.5 py-1 text-xs font-medium ${tone}`}
title={label}
<button
type="button"
className="flex h-8 items-center gap-1.5 rounded-full px-2 text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950 disabled:cursor-wait disabled:opacity-70"
disabled={isSaving}
onClick={onSave}
title={title}
>
{label}
</div>
{isSaving ? <Loader2 className="size-4 animate-spin"/> : <Save className="size-4"/>}
<span className="flex flex-col items-start leading-none">
<span className="text-[11px] font-medium">{label}</span>
<span className="mt-0.5 text-[9px] font-medium text-zinc-400">{savedTime}</span>
</span>
</button>
);
}
+623
View File
@@ -0,0 +1,623 @@
"use client";
import {
Download,
ImageIcon,
Info,
Loader2,
Minimize2,
Minus,
Pencil,
Plus,
Save,
Trash2,
X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { HistoryItem } from "@/lib/image-workflow";
type AssetFilter = "all" | "direct" | "canvas" | "generate" | "edit";
const filters: Array<{ id: AssetFilter; label: string }> = [
{ id: "all", label: "全部" },
{ id: "direct", label: "直接模式" },
{ id: "canvas", label: "无限画布" },
{ id: "generate", label: "文生图" },
{ id: "edit", label: "图像编辑" },
];
export function AssetManager() {
const [items, setItems] = useState<HistoryItem[]>([]);
const [filter, setFilter] = useState<AssetFilter>("all");
const [loading, setLoading] = useState(false);
const [savingIds, setSavingIds] = useState<string[]>([]);
const [deletingIds, setDeletingIds] = useState<string[]>([]);
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({});
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [previewItem, setPreviewItem] = useState<HistoryItem | null>(null);
useEffect(() => {
void loadAssets();
}, []);
const visibleItems = useMemo(
() =>
items.filter((item) => {
if (filter === "all") return true;
if (filter === "direct" || filter === "canvas") {
return (item.source ?? "direct") === filter;
}
return item.mode === filter;
}),
[filter, items],
);
const selectedItem =
items.find((item) => item.id === selectedItemId) ?? null;
async function loadAssets() {
setLoading(true);
try {
const response = await fetch("/api/history?source=all&limit=500", {
cache: "no-store",
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "读取资产失败");
}
const nextItems = Array.isArray(payload.items) ? payload.items : [];
setItems(nextItems);
setDraftNotes(
Object.fromEntries(
nextItems.map((item: HistoryItem) => [item.id, item.note ?? ""]),
),
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "读取资产失败");
} finally {
setLoading(false);
}
}
async function saveNote(item: HistoryItem) {
setSavingIds((current) => [...current, item.id]);
try {
const response = await fetch(`/api/history/${encodeURIComponent(item.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ note: draftNotes[item.id] ?? "" }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "保存备注失败");
}
setItems((current) =>
current.map((entry) =>
entry.id === item.id ? { ...entry, note: payload.note ?? "" } : entry,
),
);
toast.success("备注已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存备注失败");
} finally {
setSavingIds((current) => current.filter((id) => id !== item.id));
}
}
async function deleteAsset(item: HistoryItem) {
setDeletingIds((current) => [...current, item.id]);
try {
const response = await fetch(`/api/history/${encodeURIComponent(item.id)}`, {
method: "DELETE",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.error || "删除素材失败");
}
setItems((current) => current.filter((entry) => entry.id !== item.id));
setSelectedItemId((current) => (current === item.id ? null : current));
toast.success("素材已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除素材失败");
} finally {
setDeletingIds((current) => current.filter((id) => id !== item.id));
}
}
return (
<div
className={
selectedItem
? "grid h-full min-h-0 overflow-hidden lg:grid-cols-[minmax(0,1fr)_360px]"
: "grid h-full min-h-0 overflow-hidden"
}
>
<div className="min-h-0 overflow-auto p-4 lg:p-6">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{filters.map((item) => (
<Button
key={item.id}
size="sm"
type="button"
variant={filter === item.id ? "default" : "outline"}
onClick={() => setFilter(item.id)}
>
{item.label}
</Button>
))}
</div>
<Button size="sm" variant="outline" onClick={() => void loadAssets()}>
{loading ? <Loader2 className="animate-spin" /> : <ImageIcon />}
</Button>
</div>
{loading ? (
<div className="flex min-h-80 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white text-sm text-zinc-500">
...
</div>
) : visibleItems.length ? (
<div className="columns-1 gap-4 sm:columns-2 xl:columns-3 2xl:columns-4">
{visibleItems.map((item) => {
const isDeleting = deletingIds.includes(item.id);
return (
<article
key={item.id}
className="group mb-4 break-inside-avoid overflow-visible rounded-md border border-zinc-200 bg-white shadow-xs"
>
<div className="relative bg-zinc-100">
<AssetToolbar
item={item}
onDelete={() => void deleteAsset(item)}
onEdit={() => setSelectedItemId(item.id)}
onInfo={() => setSelectedItemId(item.id)}
/>
<div className="overflow-hidden rounded-t-md">
<img
alt={item.prompt || "生成图片"}
className="w-full cursor-zoom-in object-cover transition-transform duration-300 ease-out group-hover:scale-[1.035]"
src={item.imageUrl}
onClick={() => setPreviewItem(item)}
/>
</div>
{isDeleting ? (
<div className="absolute inset-0 flex items-center justify-center rounded-t-md bg-white/70">
<Loader2 className="animate-spin" />
</div>
) : null}
<div className="absolute inset-x-0 bottom-0 bg-linear-to-t from-zinc-950/45 via-zinc-950/18 to-transparent px-3 pb-3 pt-14 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="line-clamp-3 text-sm font-medium leading-5 drop-shadow">
{item.prompt || "无提示词"}
</div>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="flex min-h-80 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white text-sm text-zinc-500">
</div>
)}
</div>
{selectedItem ? (
<AssetInfoPanel
draftNote={draftNotes[selectedItem.id] ?? ""}
item={selectedItem}
isSaving={savingIds.includes(selectedItem.id)}
onClose={() => setSelectedItemId(null)}
onDelete={(item) => void deleteAsset(item)}
onNoteChange={(item, note) =>
setDraftNotes((current) => ({
...current,
[item.id]: note,
}))
}
onSaveNote={(item) => void saveNote(item)}
/>
) : null}
{previewItem ? (
<ImageZoomDialog
image={{
title: previewItem.prompt || "生成图片",
url: previewItem.imageUrl,
}}
onClose={() => setPreviewItem(null)}
/>
) : null}
</div>
);
}
function AssetToolbar({
item,
onDelete,
onEdit,
onInfo,
}: {
item: HistoryItem;
onDelete: () => void;
onEdit: () => void;
onInfo: () => void;
}) {
return (
<div className="absolute left-1/2 top-0 z-20 flex -translate-x-1/2 -translate-y-[calc(100%+10px)] items-center gap-1 rounded-full border border-zinc-200 bg-white/95 px-3 py-2 opacity-0 shadow-[0_14px_38px_rgba(24,24,27,.16)] backdrop-blur-xl transition-opacity group-hover:opacity-100">
<AssetToolbarButton
icon={<Info className="size-4" />}
label="信息"
onClick={onInfo}
/>
<AssetToolbarButton
danger
icon={<Trash2 className="size-4" />}
label="删除"
onClick={onDelete}
/>
<AssetToolbarButton
icon={<Pencil className="size-4" />}
label="编辑"
onClick={onEdit}
/>
<AssetToolbarButton
icon={<Download className="size-4" />}
label="下载"
onClick={() => downloadImage(item.imageUrl, getAssetDownloadName(item))}
/>
</div>
);
}
function AssetToolbarButton({
danger = false,
icon,
label,
onClick,
}: {
danger?: boolean;
icon: React.ReactNode;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
className={
danger
? "flex h-9 shrink-0 items-center gap-2 rounded-full px-2.5 text-sm font-medium text-zinc-700 transition hover:bg-red-50 hover:text-red-600"
: "flex h-9 shrink-0 items-center gap-2 rounded-full px-2.5 text-sm font-medium text-zinc-700 transition hover:bg-zinc-100 hover:text-zinc-950"
}
title={label}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
>
{icon}
<span className="whitespace-nowrap">{label}</span>
</button>
);
}
function AssetInfoPanel({
draftNote,
isSaving,
item,
onClose,
onDelete,
onNoteChange,
onSaveNote,
}: {
draftNote: string;
isSaving: boolean;
item: HistoryItem | null;
onClose: () => void;
onDelete: (item: HistoryItem) => void;
onNoteChange: (item: HistoryItem, note: string) => void;
onSaveNote: (item: HistoryItem) => void;
}) {
return (
<aside className="hidden min-h-0 border-l border-zinc-200 bg-white lg:block">
{item ? (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200 px-4">
<div>
<div className="text-sm font-semibold text-zinc-950"></div>
<div className="text-xs text-zinc-500">{formatDate(item.createdAt)}</div>
</div>
<Button size="icon" type="button" variant="ghost" onClick={onClose}>
<X />
</Button>
</div>
<div className="thin-scrollbar min-h-0 flex-1 overflow-auto p-4">
<div className="overflow-hidden rounded-md bg-zinc-100">
<img
alt={item.prompt || "生成图片"}
className="max-h-[520px] w-full object-contain"
src={item.imageUrl}
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Badge>{(item.source ?? "direct") === "canvas" ? "无限画布" : "直接模式"}</Badge>
<Badge>{item.mode === "edit" ? "图像编辑" : "文生图"}</Badge>
<Badge>{item.outputFormat.toUpperCase()}</Badge>
</div>
<div className="mt-4 space-y-4">
<InfoBlock label="提示词">
<p className="text-sm leading-6 text-zinc-950">
{item.prompt || "无提示词"}
</p>
</InfoBlock>
<div className="grid grid-cols-2 gap-2 text-sm">
<InfoCell label="模型" value={item.model} />
<InfoCell label="尺寸" value={item.size} />
<InfoCell label="质量" value={item.quality} />
<InfoCell label="格式" value={item.outputFormat} />
</div>
<InfoBlock label="备注">
<Textarea
className="min-h-28"
placeholder="添加备注"
value={draftNote}
onChange={(event) => onNoteChange(item, event.target.value)}
/>
<div className="mt-2 flex gap-2">
<Button
className="flex-1"
disabled={isSaving}
size="sm"
type="button"
variant="outline"
onClick={() => onSaveNote(item)}
>
{isSaving ? <Loader2 className="animate-spin" /> : <Save />}
</Button>
<Button
size="sm"
type="button"
variant="ghost"
onClick={() => onDelete(item)}
>
<Trash2 />
</Button>
</div>
</InfoBlock>
<Button
className="w-full"
type="button"
variant="outline"
onClick={() => downloadImage(item.imageUrl, getAssetDownloadName(item))}
>
<Download />
</Button>
</div>
</div>
</div>
) : null}
</aside>
);
}
function ImageZoomDialog({
image,
onClose,
}: {
image: { title: string; url: string };
onClose: () => void;
}) {
const [zoom, setZoom] = useState(1);
const [naturalSize, setNaturalSize] = useState<{
url: string;
width: number;
height: number;
} | null>(null);
const [dragState, setDragState] = useState<{
startX: number;
startY: number;
scrollLeft: number;
scrollTop: number;
} | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const zoomIn = () => setZoom((current) => current * 1.25);
const zoomOut = () => setZoom((current) => Math.max(0.05, current / 1.25));
const dialogSize = useMemo(() => {
if (!naturalSize || naturalSize.url !== image.url) {
return { width: "min(92vw, 880px)", height: "min(92vh, 680px)" };
}
return {
width: `min(92vw, ${naturalSize.width}px)`,
height: `min(92vh, ${naturalSize.height + 112}px)`,
};
}, [image.url, naturalSize]);
useEffect(() => {
const img = new Image();
img.onload = () => {
setNaturalSize({
url: image.url,
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.src = image.url;
}, [image.url]);
useEffect(() => {
if (!dragState) return;
const handleMove = (event: MouseEvent) => {
const scroller = scrollRef.current;
if (!scroller) return;
scroller.scrollLeft = dragState.scrollLeft - (event.clientX - dragState.startX);
scroller.scrollTop = dragState.scrollTop - (event.clientY - dragState.startY);
};
const handleUp = () => setDragState(null);
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
return () => {
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
}, [dragState]);
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/42 p-6 backdrop-blur-[1px]"
onMouseDown={(event) => {
event.stopPropagation();
if (event.target === event.currentTarget) onClose();
}}
onWheel={(event) => {
event.preventDefault();
setZoom((current) =>
event.deltaY < 0
? current * 1.18
: Math.max(0.05, current / 1.18),
);
}}
>
<div
className="relative flex flex-col overflow-hidden rounded-lg bg-white p-4 shadow-2xl ring-1 ring-zinc-200"
style={dialogSize}
onDoubleClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-4">
<h2 className="truncate text-base font-semibold text-zinc-950"></h2>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={onClose}
title="关闭"
>
<X className="size-5" />
</button>
</div>
<div
ref={scrollRef}
className={
dragState
? "flex min-h-0 flex-1 cursor-grabbing overflow-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
: "flex min-h-0 flex-1 cursor-grab overflow-auto bg-white [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
}
onMouseDown={(event) => {
event.stopPropagation();
const scroller = scrollRef.current;
if (!scroller) return;
setDragState({
startX: event.clientX,
startY: event.clientY,
scrollLeft: scroller.scrollLeft,
scrollTop: scroller.scrollTop,
});
}}
>
<div className="m-auto">
<img
alt={image.title}
className="block max-w-none select-none"
draggable={false}
src={image.url}
style={{
height: "auto",
width: `${Math.max(
80,
zoom *
(naturalSize?.url === image.url ? naturalSize.width : 960),
)}px`,
}}
/>
</div>
</div>
<div className="absolute bottom-4 right-4 flex items-center gap-2 rounded-2xl border border-zinc-200 bg-white/92 px-3 py-2 shadow-[0_14px_42px_rgba(24,24,27,.14)] backdrop-blur-xl">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={() => setZoom(1)}
title="适配视图"
>
<Minimize2 className="size-4" />
</button>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={zoomOut}
title="缩小"
>
<Minus className="size-4" />
</button>
<div className="min-w-12 text-center text-sm font-medium text-zinc-700">
{Math.round(zoom * 100)}%
</div>
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-zinc-600 transition hover:bg-zinc-100 hover:text-zinc-950"
onClick={zoomIn}
title="放大"
>
<Plus className="size-4" />
</button>
</div>
</div>
</div>,
document.body,
);
}
function InfoBlock({
children,
label,
}: {
children: React.ReactNode;
label: string;
}) {
return (
<div>
<div className="mb-2 text-xs font-medium text-zinc-500">{label}</div>
{children}
</div>
);
}
function InfoCell({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-md bg-zinc-100 px-3 py-2">
<div className="text-xs text-zinc-500">{label}</div>
<div className="mt-1 truncate font-medium text-zinc-900" title={value}>
{value}
</div>
</div>
);
}
function downloadImage(url: string, filename: string) {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
}
function getAssetDownloadName(item: HistoryItem) {
const safePrompt = (item.prompt || "asset-image")
.slice(0, 32)
.replace(/[\\/:*?"<>|]+/g, "-");
return `${safePrompt || "asset-image"}.${item.outputFormat || "png"}`;
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
+199 -25
View File
@@ -52,6 +52,12 @@ import {
resolutionOptions,
sizeOptions,
} from "@/lib/image-workflow";
import {
defaultCanvasSettings,
normalizeCanvasSettings,
type CanvasModelChannel,
type CanvasSettings,
} from "@/lib/canvas/settings";
type ActiveView = "studio" | "history" | "settings";
type PreviewImage = { title: string; subtitle?: string; url: string };
@@ -77,6 +83,7 @@ const promptPresets = [
const defaultPrompt =
"温馨治愈的幼儿园毕业纪实写真,画面干净明亮通透,柔和室内自然光,人物边缘清晰,色彩低饱和且高级,真实摄影质感,无文字水印。";
const defaultDirectModel = "gpt-image-2-2k";
const activeDirectJobStorageKey = "imagegen:direct:active-job";
type StoredDirectJob = {
@@ -88,7 +95,14 @@ type StoredDirectJob = {
export function DirectStudio({ activeView }: { activeView: ActiveView }) {
const [mode, setMode] = useState<ImageMode>("generate");
const [prompt, setPrompt] = useState(defaultPrompt);
const [model, setModel] = useState("gpt-image-2-2k");
const [enableNegativePrompt, setEnableNegativePrompt] = useState(false);
const [negativePrompt, setNegativePrompt] = useState("");
const [model, setModel] = useState(defaultDirectModel);
const [selectedChannelId, setSelectedChannelId] = useState(
defaultCanvasSettings.selectedChannelId,
);
const [canvasSettings, setCanvasSettings] =
useState<CanvasSettings>(defaultCanvasSettings);
const [ratio, setRatio] = useState("1:1");
const [resolution, setResolution] = useState<Resolution>("1K");
const [quality, setQuality] = useState("high");
@@ -133,10 +147,25 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
: size;
const promptLength = useMemo(() => prompt.trim().length, [prompt]);
const activeImage = history[0];
const activeChannel = useMemo(
() =>
canvasSettings.channels.find((channel) => channel.id === selectedChannelId) ??
canvasSettings.channels[0],
[canvasSettings.channels, selectedChannelId],
);
const modelOptions = useMemo(() => {
const channelModels = activeChannel?.models ?? [];
return model && !channelModels.includes(model)
? [model, ...channelModels]
: channelModels;
}, [activeChannel, model]);
useEffect(() => {
void loadHistory();
void loadSettings();
queueMicrotask(() => {
void loadCanvasSettings();
});
const storedJob = readStoredDirectJob();
if (storedJob) {
@@ -189,7 +218,7 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
async function loadHistory() {
setIsHistoryLoading(true);
try {
const response = await fetch("/api/history", { cache: "no-store" });
const response = await fetch("/api/history?source=direct", { cache: "no-store" });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "读取历史失败");
@@ -215,6 +244,31 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
}
}
async function loadCanvasSettings() {
try {
const response = await fetch("/api/canvas/settings", { cache: "no-store" });
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || "读取渠道设置失败");
}
const settings = normalizeCanvasSettings(payload.item);
const channel =
settings.channels.find((item) => item.id === settings.selectedChannelId) ??
settings.channels[0];
const nextModel =
settings.defaultModels.image ||
settings.modelPreferences.image[0] ||
channel?.models[0] ||
defaultDirectModel;
setCanvasSettings(settings);
setSelectedChannelId(channel?.id ?? settings.selectedChannelId);
setModel(nextModel);
} catch (error) {
toast.error(error instanceof Error ? error.message : "读取渠道设置失败");
}
}
async function saveSettings() {
setIsSavingSettings(true);
try {
@@ -249,13 +303,18 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
}
const formData = new FormData();
formData.append("source", "direct");
formData.append("mode", mode);
formData.append("prompt", prompt);
if (enableNegativePrompt && negativePrompt.trim()) {
formData.append("negativePrompt", negativePrompt.trim());
}
formData.append("model", model);
formData.append("size", effectiveSize);
formData.append("quality", quality);
formData.append("outputFormat", outputFormat);
formData.append("preserveIdentity", String(preserveIdentity));
appendChannelConfig(formData, activeChannel);
if (file) {
formData.append("image", file);
}
@@ -428,27 +487,117 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
</TabsList>
<TabsContent className="space-y-4" value="generate">
<PromptEditor
enableNegativePrompt={enableNegativePrompt}
negativePrompt={negativePrompt}
prompt={prompt}
setPrompt={setPrompt}
setEnableNegativePrompt={setEnableNegativePrompt}
setNegativePrompt={setNegativePrompt}
promptLength={promptLength}
/>
</TabsContent>
<TabsContent className="space-y-4" value="edit">
<PromptEditor
enableNegativePrompt={enableNegativePrompt}
negativePrompt={negativePrompt}
prompt={prompt}
setPrompt={setPrompt}
setEnableNegativePrompt={setEnableNegativePrompt}
setNegativePrompt={setNegativePrompt}
promptLength={promptLength}
selectedImage={uploadedImageReference}
onSelectUploadedImage={setReferencedImage}
onClearSelectedImage={() => setReferencedImage(null)}
/>
<ImageUploader
file={file}
maskFile={maskFile}
previewUrl={filePreviewUrl}
maskPreviewUrl={maskPreviewUrl}
setFile={setFile}
setMaskFile={setMaskFile}
onPreview={() =>
filePreviewUrl
? setPreviewImage({
title: file?.name || "上传图片",
subtitle: originalImageSize
? `${originalImageSize.width}x${originalImageSize.height}`
: undefined,
url: filePreviewUrl,
})
: undefined
}
onUseHistoryImage={placeImageUrlForEdit}
/>
</TabsContent>
</Tabs>
<div className="mt-4 space-y-4">
<Field label="模型">
<Input value={model} onChange={(event) => setModel(event.target.value)} />
<div className="rounded-md border border-zinc-200 bg-white p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold text-zinc-900"></div>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<span className="rounded-full bg-zinc-100 px-2 py-1 text-[11px] text-zinc-600">
</span>
</div>
<div className="grid gap-3">
<Field label="渠道配置">
<Select
value={activeChannel?.id ?? ""}
onValueChange={(nextChannelId) => {
const nextChannel =
canvasSettings.channels.find(
(channel) => channel.id === nextChannelId,
) ?? canvasSettings.channels[0];
const nextModels = nextChannel?.models ?? [];
const nextModel = nextModels.includes(model)
? model
: nextModels[0] || model;
setSelectedChannelId(nextChannel?.id ?? nextChannelId);
setModel(nextModel);
}}
>
<SelectTrigger>
<SelectValue placeholder="选择渠道" />
</SelectTrigger>
<SelectContent>
{canvasSettings.channels.map((channel) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="模型名称">
{modelOptions.length ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{modelOptions.map((modelName) => (
<SelectItem key={modelName} value={modelName}>
{modelName}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
placeholder="先在渠道配置中添加模型"
value={model}
onChange={(event) => setModel(event.target.value)}
/>
)}
</Field>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="输出格式">
<Select value={outputFormat} onValueChange={setOutputFormat}>
@@ -507,27 +656,6 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
</Field>
</div>
<ImageUploader
file={file}
maskFile={maskFile}
previewUrl={filePreviewUrl}
maskPreviewUrl={maskPreviewUrl}
setFile={setFile}
setMaskFile={setMaskFile}
onPreview={() =>
filePreviewUrl
? setPreviewImage({
title: file?.name || "上传图片",
subtitle: originalImageSize
? `${originalImageSize.width}x${originalImageSize.height}`
: undefined,
url: filePreviewUrl,
})
: undefined
}
onUseHistoryImage={placeImageUrlForEdit}
/>
<div className="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
<div>
<div className="text-sm font-medium"></div>
@@ -707,6 +835,21 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
);
}
function appendChannelConfig(
formData: FormData,
channel: CanvasModelChannel | undefined,
) {
if (!channel) return;
formData.append("channelId", channel.id);
formData.append("channelName", channel.name);
if (channel.baseUrl.trim()) {
formData.append("channelBaseUrl", channel.baseUrl.trim());
}
if (channel.apiKey.trim()) {
formData.append("channelApiKey", channel.apiKey.trim());
}
}
function ImageUploader({
file,
maskFile,
@@ -835,15 +978,23 @@ function ImageUploader({
}
function PromptEditor({
enableNegativePrompt,
negativePrompt,
prompt,
setPrompt,
setEnableNegativePrompt,
setNegativePrompt,
promptLength,
selectedImage,
onSelectUploadedImage,
onClearSelectedImage,
}: {
enableNegativePrompt: boolean;
negativePrompt: string;
prompt: string;
setPrompt: (value: string) => void;
setEnableNegativePrompt: (value: boolean) => void;
setNegativePrompt: (value: string) => void;
promptLength: number;
selectedImage?: UploadedImageReference | null;
onSelectUploadedImage?: (value: UploadedImageReference | null) => void;
@@ -872,6 +1023,29 @@ function PromptEditor({
}}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2">
<div>
<div className="text-sm font-medium"></div>
<div className="text-xs text-zinc-500">
</div>
</div>
<Switch
checked={enableNegativePrompt}
onCheckedChange={setEnableNegativePrompt}
/>
</div>
{enableNegativePrompt ? (
<div className="space-y-2">
<Label htmlFor="negative-prompt"></Label>
<Textarea
id="negative-prompt"
placeholder="例如:不要文字、水印、畸形手指、低清晰度"
value={negativePrompt}
onChange={(event) => setNegativePrompt(event.target.value)}
/>
</div>
) : null}
{selectedImage ? (
<div className="flex items-center gap-3 rounded-md border border-zinc-200 bg-zinc-50 p-2">
<div className="flex size-14 shrink-0 items-center justify-center overflow-hidden rounded bg-white">
+19 -2
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
Aperture,
Images,
ImageIcon,
PanelLeft,
PanelLeftClose,
@@ -15,10 +16,11 @@ import {
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { AssetManager } from "@/components/direct/asset-manager";
import { CanvasProjectsClient } from "@/components/canvas/canvas-projects-client";
import { DirectStudio } from "@/components/direct/direct-studio";
export type ActiveView = "studio" | "history" | "settings" | "canvas";
export type ActiveView = "studio" | "history" | "assets" | "settings" | "canvas";
export function HomeShell({
initialView = "studio",
@@ -49,7 +51,11 @@ export function HomeShell({
},
history: {
title: "作品记录",
description: "查看本次会话生成的图片与任务参数",
description: "查看直接模式生成的图片与任务参数",
},
assets: {
title: "资产管理",
description: "按分类管理所有生成内容、备注和删除",
},
settings: {
title: "模型设置",
@@ -106,6 +112,15 @@ export function HomeShell({
<ImageIcon />
<span className={sidebarCollapsed ? "sr-only" : ""}></span>
</Button>
<Button
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
variant={routeView === "assets" ? "secondary" : "ghost"}
title="资产管理"
onClick={() => handleViewChange("assets")}
>
<Images />
<span className={sidebarCollapsed ? "sr-only" : ""}></span>
</Button>
<Button
className={sidebarCollapsed ? "w-full px-0" : "w-full justify-start"}
variant={routeView === "settings" ? "secondary" : "ghost"}
@@ -163,6 +178,8 @@ export function HomeShell({
<div className="min-h-0 flex-1 overflow-hidden">
{routeView === "canvas" ? (
<CanvasProjectsClient />
) : routeView === "assets" ? (
<AssetManager />
) : (
<DirectStudio activeView={routeView} />
)}
+130
View File
@@ -0,0 +1,130 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[240] bg-black/45 backdrop-blur-[2px]",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
"fixed left-1/2 top-1/2 z-[250] grid w-[min(420px,calc(100vw-32px))] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-zinc-200 bg-white p-5 shadow-[0_28px_90px_rgba(24,24,27,.24)] outline-none",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
className={cn("text-base font-semibold text-zinc-950", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
className={cn("text-sm leading-6 text-zinc-500", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(
buttonVariants({ className: "bg-red-600 text-white hover:bg-red-700" }),
className,
)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
export const buttonVariants = cva(
"inline-flex h-9 shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md px-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4",
{
variants: {
+27
View File
@@ -18,6 +18,7 @@ export type CanvasGenerationInput = {
imageNode?: CanvasNode;
imageNodes: CanvasNode[];
prompt: string;
negativePrompt: string;
config: CanvasConfigNodeMetadata;
};
@@ -71,6 +72,11 @@ export function resolveCanvasGenerationInput(
: connectedPrompts.length
? formatConnectedPrompts(connectedPrompts)
: (resolvedMentions?.prompt ?? "");
const negativePrompt = resolveNegativePrompt(
promptNode,
resolvedMentions?.referencedNodeIds ? scopedNodes : nodes,
scopedConnections,
);
if (!promptNode || !prompt) {
return { error: "请先添加并填写提示词节点或当前图像节点提示词" };
@@ -90,6 +96,7 @@ export function resolveCanvasGenerationInput(
imageNode: imageNodes[0],
imageNodes: imageNodes.slice(0, 16),
prompt,
negativePrompt,
config: {
...defaultCanvasConfig,
...((configNode?.metadata as Partial<CanvasConfigNodeMetadata>) || {}),
@@ -98,10 +105,30 @@ export function resolveCanvasGenerationInput(
};
}
function resolveNegativePrompt(
promptNode: CanvasNode | undefined,
nodes: CanvasNode[],
connections: CanvasConnection[],
) {
if (promptNode?.type !== "prompt") return "";
const metadata = promptNode.metadata as CanvasPromptNodeMetadata;
if (!metadata.enableNegativePrompt) return "";
const rawNegativePrompt = metadata.negativePrompt?.trim() || "";
if (!rawNegativePrompt) return "";
return (
resolvePromptMentions(
rawNegativePrompt,
getPromptMentionSources(nodes, connections, promptNode.id),
)?.prompt ?? rawNegativePrompt
).trim();
}
function getImageGenerationConfig(node: CanvasNode | undefined) {
if (node?.type !== "image") return {};
const metadata = node.metadata as CanvasImageNodeMetadata;
return {
...(metadata.channelId ? { channelId: metadata.channelId } : {}),
...(metadata.model ? { model: metadata.model } : {}),
...(metadata.size ? { size: metadata.size } : {}),
...(metadata.quality ? { quality: metadata.quality } : {}),
+3
View File
@@ -13,6 +13,7 @@ export const DEFAULT_IMAGE_WIDTH = 320;
export const DEFAULT_IMAGE_HEIGHT = 240;
export const defaultCanvasConfig: CanvasConfigNodeMetadata = {
channelId: "",
model: "gpt-image-2-2k",
size: "1024x1024",
quality: "high",
@@ -89,6 +90,7 @@ export function createImageNodeFromHistory(
size: item.size,
quality: item.quality,
outputFormat: item.outputFormat,
fileSize: item.fileSize,
status: "success",
},
};
@@ -128,6 +130,7 @@ export function createImageResultNode(
size: result.size,
quality: result.quality,
outputFormat: result.outputFormat,
fileSize: result.fileSize,
status: "success",
};
+215
View File
@@ -0,0 +1,215 @@
export type CanvasModelChannel = {
id: string;
name: string;
baseUrl: string;
apiKey: string;
models: string[];
};
export type CanvasModelKind = "image" | "video" | "text" | "audio";
export type CanvasModelPreferences = Record<CanvasModelKind, string[]>;
export type CanvasDefaultModels = Record<CanvasModelKind, string>;
export type CanvasSettings = {
hideToolbarText: boolean;
showImageInfoBadge: boolean;
selectedChannelId: string;
selectedModel: string;
modelPreferences: CanvasModelPreferences;
defaultModels: CanvasDefaultModels;
channels: CanvasModelChannel[];
};
export const defaultCanvasChannel: CanvasModelChannel = {
id: "default",
name: "默认渠道",
baseUrl: "https://api.openai.com",
apiKey: "",
models: ["gpt-image-2"],
};
export const defaultCanvasSettings: CanvasSettings = {
hideToolbarText: false,
showImageInfoBadge: false,
selectedChannelId: defaultCanvasChannel.id,
selectedModel: defaultCanvasChannel.models[0],
modelPreferences: {
image: [defaultCanvasChannel.models[0]],
video: [],
text: [],
audio: [],
},
defaultModels: {
image: defaultCanvasChannel.models[0],
video: "",
text: "",
audio: "",
},
channels: [defaultCanvasChannel],
};
export function normalizeCanvasSettings(value: unknown): CanvasSettings {
if (!value || typeof value !== "object") return defaultCanvasSettings;
const candidate = value as Partial<CanvasSettings> & {
modelName?: string;
baseUrl?: string;
apiKeySource?: string;
};
// 兼容旧版 localStorage 设置:旧结构只有 modelName/baseUrl/apiKeySource
// 新结构统一用 channels JSON 存储,便于未来继续扩展渠道参数。
if (!Array.isArray(candidate.channels)) {
const legacyModel = typeof candidate.modelName === "string"
? candidate.modelName
: defaultCanvasSettings.selectedModel;
const legacyChannel = {
...defaultCanvasChannel,
baseUrl:
typeof candidate.baseUrl === "string" && candidate.baseUrl.startsWith("http")
? candidate.baseUrl
: defaultCanvasChannel.baseUrl,
models: [legacyModel],
};
return {
hideToolbarText: Boolean(candidate.hideToolbarText),
showImageInfoBadge: Boolean(candidate.showImageInfoBadge),
selectedChannelId: legacyChannel.id,
selectedModel: legacyModel,
modelPreferences: {
image: [legacyModel],
video: [],
text: [],
audio: [],
},
defaultModels: {
image: legacyModel,
video: "",
text: "",
audio: "",
},
channels: [legacyChannel],
};
}
const channels = candidate.channels
.map((channel) => normalizeCanvasChannel(channel))
.filter((channel): channel is CanvasModelChannel => Boolean(channel));
const normalizedChannels = channels.length ? channels : [defaultCanvasChannel];
const selectedChannelId =
typeof candidate.selectedChannelId === "string" &&
normalizedChannels.some((channel) => channel.id === candidate.selectedChannelId)
? candidate.selectedChannelId
: normalizedChannels[0].id;
const selectedChannel =
normalizedChannels.find((channel) => channel.id === selectedChannelId) ??
normalizedChannels[0];
const selectedModel =
typeof candidate.selectedModel === "string" &&
candidate.selectedModel.trim() &&
selectedChannel.models.includes(candidate.selectedModel)
? candidate.selectedModel
: selectedChannel.models[0] ?? defaultCanvasSettings.selectedModel;
const modelPreferences = normalizeModelPreferences(
candidate.modelPreferences,
selectedModel,
);
const defaultModels = normalizeDefaultModels(
candidate.defaultModels,
modelPreferences,
selectedModel,
);
return {
hideToolbarText: Boolean(candidate.hideToolbarText),
showImageInfoBadge: Boolean(candidate.showImageInfoBadge),
selectedChannelId,
selectedModel: defaultModels.image || selectedModel,
modelPreferences,
defaultModels,
channels: normalizedChannels,
};
}
function normalizeModelPreferences(
value: unknown,
selectedModel: string,
): CanvasModelPreferences {
const candidate =
value && typeof value === "object"
? (value as Partial<CanvasModelPreferences>)
: {};
return {
image: normalizeModelList(candidate.image, selectedModel ? [selectedModel] : []),
video: normalizeModelList(candidate.video, []),
text: normalizeModelList(candidate.text, []),
audio: normalizeModelList(candidate.audio, []),
};
}
function normalizeDefaultModels(
value: unknown,
preferences: CanvasModelPreferences,
selectedModel: string,
): CanvasDefaultModels {
const candidate =
value && typeof value === "object"
? (value as Partial<CanvasDefaultModels>)
: {};
return {
image: normalizeDefaultModel(candidate.image, preferences.image, selectedModel),
video: normalizeDefaultModel(candidate.video, preferences.video, ""),
text: normalizeDefaultModel(candidate.text, preferences.text, ""),
audio: normalizeDefaultModel(candidate.audio, preferences.audio, ""),
};
}
function normalizeDefaultModel(
value: unknown,
models: string[],
fallback: string,
) {
return typeof value === "string" && models.includes(value)
? value
: models[0] ?? fallback;
}
function normalizeModelList(value: unknown, fallback: string[]) {
const list = Array.isArray(value) ? value : fallback;
return Array.from(
new Set(
list.filter((model): model is string =>
typeof model === "string" && Boolean(model.trim()),
),
),
);
}
function normalizeCanvasChannel(value: unknown): CanvasModelChannel | null {
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<CanvasModelChannel>;
const id = typeof candidate.id === "string" && candidate.id.trim()
? candidate.id
: crypto.randomUUID();
const name = typeof candidate.name === "string" && candidate.name.trim()
? candidate.name.trim()
: "未命名渠道";
const baseUrl = typeof candidate.baseUrl === "string"
? candidate.baseUrl.trim()
: "";
const apiKey = typeof candidate.apiKey === "string" ? candidate.apiKey : "";
const models = Array.isArray(candidate.models)
? candidate.models.filter((model): model is string => typeof model === "string" && Boolean(model.trim()))
: [];
return {
id,
name,
baseUrl,
apiKey,
models,
};
}
+17 -1
View File
@@ -17,9 +17,12 @@ export const CANVAS_PROJECT_DATA_VERSION = 1;
export type CanvasPromptNodeMetadata = {
prompt: string;
negativePrompt?: string;
enableNegativePrompt?: boolean;
};
export type CanvasConfigNodeMetadata = {
channelId?: string;
model: string;
size: string;
quality: string;
@@ -33,6 +36,7 @@ export type CanvasImageNodeMetadata = {
filePath?: string;
mode: "reference" | "result";
prompt?: string;
channelId?: string;
model?: string;
size?: string;
quality?: string;
@@ -41,6 +45,7 @@ export type CanvasImageNodeMetadata = {
errorMessage?: string;
naturalWidth?: number;
naturalHeight?: number;
fileSize?: number;
};
export type CanvasNodeMetadata =
@@ -232,7 +237,15 @@ function normalizePromptMetadata(value: unknown): CanvasPromptNodeMetadata | nul
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
if (typeof candidate.prompt !== "string") return null;
return { prompt: candidate.prompt };
return {
prompt: candidate.prompt,
...(typeof candidate.negativePrompt === "string"
? { negativePrompt: candidate.negativePrompt }
: {}),
...(typeof candidate.enableNegativePrompt === "boolean"
? { enableNegativePrompt: candidate.enableNegativePrompt }
: {}),
};
}
function normalizeConfigMetadata(value: unknown): CanvasConfigNodeMetadata | null {
@@ -248,6 +261,7 @@ function normalizeConfigMetadata(value: unknown): CanvasConfigNodeMetadata | nul
}
return {
...(typeof candidate.channelId === "string" ? { channelId: candidate.channelId } : {}),
model: candidate.model,
size: candidate.size,
quality: candidate.quality,
@@ -275,6 +289,7 @@ function normalizeImageMetadata(value: unknown): CanvasImageNodeMetadata | null
...(typeof candidate.filePath === "string" ? { filePath: candidate.filePath } : {}),
mode: candidate.mode,
...(typeof candidate.prompt === "string" ? { prompt: candidate.prompt } : {}),
...(typeof candidate.channelId === "string" ? { channelId: candidate.channelId } : {}),
...(typeof candidate.model === "string" ? { model: candidate.model } : {}),
...(typeof candidate.size === "string" ? { size: candidate.size } : {}),
...(typeof candidate.quality === "string" ? { quality: candidate.quality } : {}),
@@ -291,6 +306,7 @@ function normalizeImageMetadata(value: unknown): CanvasImageNodeMetadata | null
...(typeof candidate.naturalHeight === "number"
? { naturalHeight: candidate.naturalHeight }
: {}),
...(typeof candidate.fileSize === "number" ? { fileSize: candidate.fileSize } : {}),
};
}
+3
View File
@@ -4,6 +4,7 @@ export type ImageJobStatus = "queued" | "running" | "succeeded" | "failed";
export type HistoryItem = {
id: string;
source?: "direct" | "canvas";
mode: ImageMode;
prompt: string;
model: string;
@@ -11,6 +12,8 @@ export type HistoryItem = {
quality: string;
outputFormat: string;
imageUrl: string;
fileSize?: number;
note?: string;
createdAt: string;
};
+10
View File
@@ -57,6 +57,16 @@ export function getDb() {
db.exec("ALTER TABLE canvas_projects ADD COLUMN description TEXT NOT NULL DEFAULT ''");
}
const historyColumns = db
.prepare("PRAGMA table_info(history)")
.all() as Array<{ name: string }>;
if (!historyColumns.some((column) => column.name === "source")) {
db.exec("ALTER TABLE history ADD COLUMN source TEXT NOT NULL DEFAULT 'direct'");
}
if (!historyColumns.some((column) => column.name === "note")) {
db.exec("ALTER TABLE history ADD COLUMN note TEXT NOT NULL DEFAULT ''");
}
db.prepare(
"INSERT OR IGNORE INTO settings (key, value) VALUES ('retentionDays', '7')",
).run();
@@ -0,0 +1,23 @@
import { getDb } from "@/lib/server/db";
export function getJsonSetting<T>(key: string, fallback: T): T {
const row = getDb()
.prepare("SELECT value FROM settings WHERE key = ?")
.get(key) as { value?: string } | undefined;
if (!row?.value) return fallback;
try {
return JSON.parse(row.value) as T;
} catch {
return fallback;
}
}
export function setJsonSetting<T>(key: string, value: T) {
getDb()
.prepare(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.run(key, JSON.stringify(value));
return value;
}
@@ -8,6 +8,7 @@ import { getImageDir } from "@/lib/server/storage/image-file-storage";
export type StoredImage = {
id: string;
source: HistorySource;
mode: string;
prompt: string;
model: string;
@@ -16,9 +17,17 @@ export type StoredImage = {
outputFormat: string;
imageUrl: string;
filePath: string;
note: string;
createdAt: string;
};
export type HistorySource = "direct" | "canvas";
export type ListHistoryOptions = {
source?: HistorySource | "all";
limit?: number;
};
let cleanupTimerStarted = false;
export function startHistoryCleanupTimer() {
@@ -57,27 +66,64 @@ export function insertHistory(record: StoredImage) {
getDb()
.prepare(
`INSERT INTO history
(id, mode, prompt, model, size, quality, output_format, image_url, file_path, created_at)
(id, source, mode, prompt, model, size, quality, output_format, image_url, file_path, note, created_at)
VALUES
(@id, @mode, @prompt, @model, @size, @quality, @outputFormat, @imageUrl, @filePath, @createdAt)`,
(@id, @source, @mode, @prompt, @model, @size, @quality, @outputFormat, @imageUrl, @filePath, @note, @createdAt)`,
)
.run(record);
}
export function listHistory() {
export function listHistory(options: ListHistoryOptions = {}) {
cleanupExpiredHistory();
const limit = normalizeLimit(options.limit);
if (options.source && options.source !== "all") {
return getDb()
.prepare(
`SELECT id, mode, prompt, model, size, quality,
`SELECT id, source, mode, prompt, model, size, quality,
output_format AS outputFormat,
image_url AS imageUrl,
file_path AS filePath,
note,
created_at AS createdAt
FROM history
WHERE source = @source
ORDER BY created_at DESC
LIMIT @limit`,
)
.all({ source: options.source, limit }) as StoredImage[];
}
return getDb()
.prepare(
`SELECT id, source, mode, prompt, model, size, quality,
output_format AS outputFormat,
image_url AS imageUrl,
file_path AS filePath,
note,
created_at AS createdAt
FROM history
ORDER BY created_at DESC
LIMIT 100`,
LIMIT @limit`,
)
.all() as StoredImage[];
.all({ limit }) as StoredImage[];
}
export function deleteHistoryItem(id: string) {
const row = getDb()
.prepare("SELECT file_path AS filePath FROM history WHERE id = ?")
.get(id) as Pick<StoredImage, "filePath"> | undefined;
if (!row) return false;
getDb().prepare("DELETE FROM history WHERE id = ?").run(id);
cleanupOrphanedImages();
return true;
}
export function updateHistoryNote(id: string, note: string) {
const result = getDb()
.prepare("UPDATE history SET note = ? WHERE id = ?")
.run(note, id);
return result.changes > 0;
}
export function cleanupExpiredHistory() {
@@ -168,3 +214,8 @@ function removeFile(filePath: string) {
// Ignore deletion errors so history cleanup cannot break generation.
}
}
function normalizeLimit(limit: number | undefined) {
if (!Number.isFinite(limit)) return 100;
return Math.max(1, Math.min(500, Math.floor(limit!)));
}
+186 -18
View File
@@ -6,7 +6,11 @@ import {
insertHistory,
type StoredImage,
} from "@/lib/server/repositories/history-repository";
import { persistBase64Image } from "@/lib/server/storage/image-file-storage";
import {
getGeneratedImagePathFromUrl,
persistBase64Image,
readPersistedImageFile,
} from "@/lib/server/storage/image-file-storage";
const identityGuard =
"When editing a person or group photo, strictly preserve all original identities, facial features, expressions, pose, clothing layout, body proportions, and composition. Do not add, remove, crop, replace, redraw, or distort any person. Change only the requested lighting, color, background polish, clarity, and photographic finish.";
@@ -15,6 +19,7 @@ const openAIMaxRetries = 0;
const jobRetentionMs = 60 * 60 * 1000;
type ImageMode = "generate" | "edit";
type ImageSource = "direct" | "canvas";
type OutputFormat = "png" | "jpeg" | "webp";
type Quality = "low" | "medium" | "high" | "auto";
type LogLevel = "info" | "error";
@@ -28,20 +33,31 @@ export type UploadedFile = {
};
type ImageJobInput = {
source: ImageSource;
mode: ImageMode;
prompt: string;
negativePrompt?: string;
model: string;
size: string;
quality: Quality;
outputFormat: OutputFormat;
preserveIdentity: boolean;
channelBaseUrl?: string;
channelApiKey?: string;
images: UploadedFile[];
mask?: UploadedFile;
};
type ImageReference = {
imageUrl?: string;
filePath?: string;
filename?: string;
};
type ImageJobResult = {
id: string;
imageUrl: string;
fileSize?: number;
model: string;
size: string;
quality: Quality;
@@ -101,15 +117,18 @@ export async function submitImageJobRequest(request: Request) {
phase = "cleanup";
cleanupExpiredHistory();
const apiKey = process.env.OPENAI_API_KEY;
const baseURL = process.env.OPENAI_BASE_URL;
phase = "parse-form";
const formData = await request.formData();
const input = await parseImageJobInput(formData);
const apiKey = input.channelApiKey || process.env.OPENAI_API_KEY;
const baseURL = input.channelBaseUrl || process.env.OPENAI_BASE_URL;
if (!apiKey) {
phase = "validate-env";
log("error", "missing OPENAI_API_KEY");
log("error", "missing API key");
return jsonWithRequestId(
{
error: "Missing OPENAI_API_KEY in .env.local",
error: "请在渠道配置中填写 API Key,或在 .env.local 中配置 OPENAI_API_KEY",
requestId,
phase,
durationMs: elapsedMs(startedAt),
@@ -119,10 +138,6 @@ export async function submitImageJobRequest(request: Request) {
);
}
phase = "parse-form";
const formData = await request.formData();
const input = await parseImageJobInput(formData);
log("info", "form parsed", {
mode: input.mode,
model: input.model,
@@ -132,6 +147,10 @@ export async function submitImageJobRequest(request: Request) {
preserveIdentity: input.preserveIdentity,
promptChars: input.prompt.length,
promptPreview: preview(input.prompt),
negativePromptChars: input.negativePrompt?.length ?? 0,
negativePromptPreview: input.negativePrompt
? preview(input.negativePrompt)
: undefined,
imageCount: input.images.length,
imageNames: input.images.map((image) => image.name),
imageTypes: input.images.map((image) => image.type),
@@ -141,6 +160,7 @@ export async function submitImageJobRequest(request: Request) {
maskType: input.mask?.type,
maskBytes: input.mask?.size,
baseURL: baseURL ? redactUrl(baseURL) : "default",
channelConfigured: Boolean(input.channelApiKey || input.channelBaseUrl),
});
phase = "validate-input";
@@ -391,10 +411,7 @@ async function runImageJob(
maxRetries: openAIMaxRetries,
});
const finalPrompt =
input.preserveIdentity && input.mode === "edit"
? `${input.prompt.trim()}\n\nConstraints: ${identityGuard}`
: input.prompt.trim();
const finalPrompt = buildFinalPrompt(input);
const common = {
model: input.model,
@@ -460,6 +477,7 @@ async function runImageJob(
const resultPayload = {
id,
imageUrl: persisted.imageUrl,
fileSize: persisted.fileSize,
model: input.model,
size: input.size,
quality: input.quality,
@@ -475,9 +493,11 @@ async function runImageJob(
const historyRecord: StoredImage = {
...resultPayload,
source: input.source,
mode: input.mode,
prompt: input.prompt,
filePath: persisted.filePath,
note: "",
};
insertHistory(historyRecord);
@@ -558,11 +578,29 @@ function serializeSseEvent(event: string, data: unknown) {
async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
const images = formData.getAll("image");
const imageRefs = formData.getAll("imageRef");
const mask = formData.get("mask");
// 兼容两种图片来源:
// 1. image:前端直接上传的 File,例如用户本地上传、遮罩工具生成的新文件。
// 2. imageRef:画布里已经持久化过的图片引用。后端根据 filePath/imageUrl 直读,
// 避免浏览器因对象存储 CORS 无法 fetch,也避免大图在前后端之间重复传输。
const uploadedImages = await Promise.all(
images
.filter((image): image is File => image instanceof File)
.map((image) => readUploadedFile(image)),
);
const referencedImages = await Promise.all(
imageRefs
.filter((imageRef): imageRef is string => typeof imageRef === "string")
.map((imageRef, index) => readReferencedImageFile(imageRef, index)),
);
return {
source: normalizeSource(getString(formData, "source", "direct")),
mode: getString(formData, "mode", "generate") as ImageMode,
prompt: getString(formData, "prompt"),
negativePrompt: getString(formData, "negativePrompt").trim() || undefined,
model: getString(formData, "model", "gpt-image-2-2k"),
size: getString(formData, "size", "auto"),
quality: normalizeQuality(getString(formData, "quality", "high")),
@@ -570,15 +608,17 @@ async function parseImageJobInput(formData: FormData): Promise<ImageJobInput> {
getString(formData, "outputFormat", "png"),
),
preserveIdentity: getString(formData, "preserveIdentity", "true") === "true",
images: await Promise.all(
images
.filter((image): image is File => image instanceof File)
.map((image) => readUploadedFile(image)),
),
channelBaseUrl: normalizeOptionalBaseUrl(getString(formData, "channelBaseUrl")),
channelApiKey: getString(formData, "channelApiKey"),
images: [...uploadedImages, ...referencedImages],
mask: mask instanceof File ? await readUploadedFile(mask) : undefined,
};
}
function normalizeSource(value: string): ImageSource {
return value === "canvas" ? "canvas" : "direct";
}
function validateImageJobInput(input: ImageJobInput) {
if (!input.prompt.trim()) {
return "Prompt is required";
@@ -591,6 +631,21 @@ function validateImageJobInput(input: ImageJobInput) {
return null;
}
function buildFinalPrompt(input: ImageJobInput) {
const sections = [input.prompt.trim()];
const negativePrompt = input.negativePrompt?.trim();
if (negativePrompt) {
sections.push(`Negative prompt: ${negativePrompt}`);
}
if (input.preserveIdentity && input.mode === "edit") {
sections.push(`Constraints: ${identityGuard}`);
}
return sections.join("\n\n");
}
async function readUploadedFile(file: File): Promise<UploadedFile> {
return {
buffer: Buffer.from(await file.arrayBuffer()),
@@ -600,6 +655,114 @@ async function readUploadedFile(file: File): Promise<UploadedFile> {
};
}
async function readReferencedImageFile(rawReference: string, index: number) {
const reference = parseImageReference(rawReference);
if (!reference) {
throw Object.assign(new Error("图片引用格式无效"), { status: 400 });
}
const filePath = resolveReadableImagePath(reference.imageUrl, reference.filePath);
if (filePath) {
// 优先使用服务端可信路径读取图片:
// - s3://... 由服务端 SDK 走对象存储私有读,不依赖浏览器 CORS。
// - /generated/... 反解到本地 public/generated 文件,省掉一次 HTTP 下载。
const buffer = await readPersistedImageFile(filePath);
if (!buffer) {
throw Object.assign(new Error("图片文件不存在或无法读取"), { status: 404 });
}
return {
buffer,
name: reference.filename || getFallbackImageName(filePath, index),
type: getImageContentType(filePath),
size: buffer.length,
} satisfies UploadedFile;
}
if (!reference.imageUrl) {
throw Object.assign(new Error("图片引用缺少 imageUrl 或 filePath"), { status: 400 });
}
// 有些历史数据可能没有 filePath,只保留了公开 imageUrl。
// 这种情况才回退到服务端 fetch,并且下面会做严格域名白名单限制。
const remoteImage = await readAllowedRemoteImage(reference.imageUrl);
if (!remoteImage) {
throw Object.assign(new Error("远程图片不可访问"), { status: 502 });
}
return {
buffer: remoteImage.buffer,
name: reference.filename || getFallbackImageName(reference.imageUrl, index),
type: remoteImage.contentType,
size: remoteImage.buffer.length,
} satisfies UploadedFile;
}
function parseImageReference(rawReference: string): ImageReference | null {
try {
const parsed = JSON.parse(rawReference) as Partial<ImageReference>;
return {
imageUrl: typeof parsed.imageUrl === "string" ? parsed.imageUrl.trim() : undefined,
filePath: typeof parsed.filePath === "string" ? parsed.filePath.trim() : undefined,
filename: typeof parsed.filename === "string" ? parsed.filename.trim() : undefined,
};
} catch {
return null;
}
}
function resolveReadableImagePath(imageUrl?: string, filePath?: string) {
// 画布节点里保存的 filePath 是服务端可信路径。对象存储图片会是 s3://...,
// 本地生成图片则可以从 /generated/... 反推出 public/generated 下的实际文件。
if (filePath?.startsWith("s3://")) return filePath;
if (imageUrl) return getGeneratedImagePathFromUrl(imageUrl);
return null;
}
async function readAllowedRemoteImage(imageUrl: string) {
// 只有当节点没有可读 filePath 时才回退到远程 URL,并且只允许读取当前对象存储公开域名。
// 这样既避开浏览器 CORS,也避免 /api/images 变成任意 URL 的服务端代理。
if (!isAllowedRemoteImageUrl(imageUrl)) return null;
const response = await fetch(imageUrl, { cache: "no-store" });
if (!response.ok) return null;
const contentType = response.headers.get("content-type") || "image/png";
if (!contentType.startsWith("image/")) return null;
return {
buffer: Buffer.from(await response.arrayBuffer()),
contentType,
};
}
function isAllowedRemoteImageUrl(imageUrl: string) {
try {
const url = new URL(imageUrl);
return (
url.protocol === "https:" &&
url.hostname === "file.hanhan.ltd" &&
url.pathname.startsWith("/imagegen-tools/generated/")
);
} catch {
return false;
}
}
function getFallbackImageName(value: string, index: number) {
const cleanName = value.split(/[\\/]/).at(-1)?.split("?")[0];
return cleanName && cleanName.includes(".")
? cleanName
: `canvas-reference-${index + 1}.png`;
}
function getImageContentType(filePath: string) {
const extension = filePath.split(".").pop()?.toLowerCase();
if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
if (extension === "webp") return "image/webp";
return "image/png";
}
function cleanupOldJobs() {
const cutoff = Date.now() - jobRetentionMs;
for (const [jobId, job] of imageJobs.entries()) {
@@ -654,6 +817,11 @@ function getString(formData: FormData, key: string, fallback = "") {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function normalizeOptionalBaseUrl(value: string) {
const trimmed = value.trim();
return trimmed ? trimmed.replace(/\/+$/, "") : undefined;
}
function normalizeQuality(value: string): Quality {
return ["low", "medium", "high", "auto"].includes(value)
? (value as Quality)
+3 -1
View File
@@ -13,6 +13,7 @@ export type PersistedImageFile = {
fileName: string;
filePath: string;
imageUrl: string;
fileSize: number;
};
type S3StorageConfig = {
@@ -92,7 +93,7 @@ function persistImageBufferLocally(
const imageUrl = `/generated/${fileName}`;
writeFileSync(filePath, buffer);
return { fileName, filePath, imageUrl };
return { fileName, filePath, imageUrl, fileSize: buffer.length };
}
async function persistImageBufferWithFileName(
@@ -121,6 +122,7 @@ async function persistImageBufferWithFileName(
fileName,
filePath: `s3://${config.bucket}/${key}`,
imageUrl: createPublicImageUrl(config, key),
fileSize: buffer.length,
};
}