diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..433ceb8 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +OPENAI_API_KEY=sk-your-key +OPENAI_BASE_URL=https://api.kkrich.ltd/v1 diff --git a/data/imagegen.sqlite-shm b/data/imagegen.sqlite-shm index e4a96c2..bcb9cb2 100644 Binary files a/data/imagegen.sqlite-shm and b/data/imagegen.sqlite-shm differ diff --git a/data/imagegen.sqlite-wal b/data/imagegen.sqlite-wal index a0cfc12..9b10692 100644 Binary files a/data/imagegen.sqlite-wal and b/data/imagegen.sqlite-wal differ diff --git a/package.json b/package.json index 75afecc..a6c029e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7496d2..edfe09f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 84b784b..de7c592 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/src/app/api/canvas/image-file/route.ts b/src/app/api/canvas/image-file/route.ts deleted file mode 100644 index a4014a1..0000000 --- a/src/app/api/canvas/image-file/route.ts +++ /dev/null @@ -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"; -} diff --git a/src/app/api/canvas/models/route.ts b/src/app/api/canvas/models/route.ts new file mode 100644 index 0000000..d3a9a33 --- /dev/null +++ b/src/app/api/canvas/models/route.ts @@ -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)[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)[key]; + } + return typeof current === "string" ? current : ""; +} diff --git a/src/app/api/canvas/settings/route.ts b/src/app/api/canvas/settings/route.ts new file mode 100644 index 0000000..7121bbf --- /dev/null +++ b/src/app/api/canvas/settings/route.ts @@ -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 }); +} diff --git a/src/app/api/history/[id]/route.ts b/src/app/api/history/[id]/route.ts new file mode 100644 index 0000000..9fe220e --- /dev/null +++ b/src/app/api/history/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/history/route.ts b/src/app/api/history/route.ts index f52f32b..05e6a8e 100644 --- a/src/app/api/history/route.ts +++ b/src/app/api/history/route.ts @@ -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; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 22895c1..8ba7785 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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"; diff --git a/src/components/canvas/canvas-editor-client.tsx b/src/components/canvas/canvas-editor-client.tsx index eff08ca..2953d24 100644 --- a/src/components/canvas/canvas-editor-client.tsx +++ b/src/components/canvas/canvas-editor-client.tsx @@ -6,17 +6,34 @@ import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, } from "react"; -import { FileText, ImageIcon, X } from "lucide-react"; +import { Eye, EyeOff, FileText, ImageIcon, Loader2, Plus, RefreshCw, Trash2, X } from "lucide-react"; import { toast, Toaster } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { CanvasConnections } from "@/components/canvas/canvas-connections"; -import { CanvasNode } from "@/components/canvas/canvas-node"; +import { CanvasNode, type LocalImageEditPayload } from "@/components/canvas/canvas-node"; import { CanvasNodeInspector } from "@/components/canvas/canvas-node-inspector"; import { CanvasToolbar } from "@/components/canvas/canvas-toolbar"; import { InfiniteCanvas } from "@/components/canvas/infinite-canvas"; +import { + defaultCanvasSettings, + normalizeCanvasSettings, + type CanvasModelChannel, + type CanvasModelKind, + type CanvasSettings, +} from "@/lib/canvas/settings"; import { clampScale, placeResultNode, @@ -29,6 +46,7 @@ import { } from "@/lib/canvas/prompt-mentions"; import { createCanvasNode, + defaultCanvasConfig, createImageLoadingNode, createImageNodeFromHistory, createImageNodeFromUpload, @@ -99,24 +117,19 @@ type StoredCanvasJob = { loadingNodeId: string; clientRequestId: string; requestStartedAt: number; + targetMode?: "new-node" | "replace-node"; }; -type CanvasSettingsTab = "model" | "interface"; +type CanvasSettingsTab = "channels" | "model" | "interface"; -type CanvasSettings = { - hideToolbarText: boolean; - modelName: string; - baseUrl: string; - apiKeySource: string; +type ConfirmDialogState = { + title: string; + description: string; + actionLabel: string; + onConfirm: () => void | Promise; }; const canvasSettingsStorageKey = "imagegen:canvas:settings"; -const defaultCanvasSettings: CanvasSettings = { - hideToolbarText: false, - modelName: "gpt-image-2-2k", - baseUrl: "OPENAI_BASE_URL", - apiKeySource: "OPENAI_API_KEY", -}; function getInitialCanvasSettings() { if (typeof window === "undefined") return defaultCanvasSettings; @@ -124,12 +137,7 @@ function getInitialCanvasSettings() { try { const raw = window.localStorage.getItem(canvasSettingsStorageKey); if (!raw) return defaultCanvasSettings; - const stored = JSON.parse(raw) as Partial; - return { - ...defaultCanvasSettings, - ...stored, - hideToolbarText: Boolean(stored.hideToolbarText), - }; + return normalizeCanvasSettings(JSON.parse(raw)); } catch { return defaultCanvasSettings; } @@ -140,16 +148,23 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { const containerRef = useRef(null); const uploadInputRef = useRef(null); const saveTimerRef = useRef | null>(null); + const settingsSaveTimerRef = useRef | null>(null); + const settingsLoadedRef = useRef(false); const projectRef = useRef(null); const undoStackRef = useRef([]); const redoStackRef = useRef([]); const jobWatcherCleanupRef = useRef<(() => void) | null>(null); const nodeClipboardRef = useRef(null); const pasteCountRef = useRef(0); + const dragUndoSnapshotRef = useRef(null); + const dragMovedRef = useRef(false); + const dragFrameRef = useRef(null); + const pendingDragPointRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isGenerating, setIsGenerating] = useState(false); + const [lastSavedAt, setLastSavedAt] = useState(null); const [isHistoryLoading, setIsHistoryLoading] = useState(true); const [project, setProject] = useState(null); const [history, setHistory] = useState([]); @@ -176,20 +191,48 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { canUndo: false, canRedo: false, }); + const [confirmDialog, setConfirmDialog] = useState(null); useEffect(() => { projectRef.current = project; }, [project]); useEffect(() => { - try { - window.localStorage.setItem( - canvasSettingsStorageKey, - JSON.stringify(canvasSettings), - ); - } catch { - // Local persistence is a convenience; the canvas should keep working. + let cancelled = false; + + async function loadCanvasSettings() { + try { + const response = await fetch("/api/canvas/settings", { cache: "no-store" }); + const payload = await response.json().catch(() => ({})); + if (!cancelled && response.ok) { + setCanvasSettings(normalizeCanvasSettings(payload.item)); + } + } catch { + // 设置读取失败时沿用默认值或旧 localStorage 迁移值,画布主流程不能被配置面板阻断。 + } finally { + if (!cancelled) settingsLoadedRef.current = true; + } } + + void loadCanvasSettings(); + return () => { + cancelled = true; + if (settingsSaveTimerRef.current) clearTimeout(settingsSaveTimerRef.current); + }; + }, []); + + useEffect(() => { + if (!settingsLoadedRef.current) return; + if (settingsSaveTimerRef.current) clearTimeout(settingsSaveTimerRef.current); + settingsSaveTimerRef.current = setTimeout(() => { + void fetch("/api/canvas/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(canvasSettings), + }).catch(() => { + toast.error("保存画布设置失败"); + }); + }, 300); }, [canvasSettings]); useEffect(() => { @@ -202,7 +245,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { }); if (!response.ok) throw new Error("画布不存在"); const payload = await response.json(); - if (!cancelled) setProject(payload.item as CanvasProjectRecord); + if (!cancelled) { + const loadedProject = payload.item as CanvasProjectRecord; + setProject(loadedProject); + setLastSavedAt(new Date(loadedProject.updatedAt)); + } } catch (err) { if (!cancelled) { toast.error(err instanceof Error ? err.message : "加载画布失败"); @@ -217,6 +264,9 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { return () => { cancelled = true; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + if (dragFrameRef.current !== null) cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + pendingDragPointRef.current = null; jobWatcherCleanupRef.current?.(); jobWatcherCleanupRef.current = null; }; @@ -300,7 +350,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { if (!nextProject) return; setIsSaving(true); try { - await fetch(`/api/canvas/projects/${projectId}`, { + const response = await fetch(`/api/canvas/projects/${projectId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -308,6 +358,12 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { data: nextProject.data, }), }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || "保存画布失败"); + } + const savedProject = payload.item as CanvasProjectRecord | undefined; + setLastSavedAt(new Date(savedProject?.updatedAt ?? Date.now())); } finally { setIsSaving(false); } @@ -320,30 +376,88 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { projectRef.current = nextProject; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { - void saveProject(nextProject); + void saveProject(nextProject).catch((err) => { + toast.error(err instanceof Error ? err.message : "保存画布失败"); + }); }, SAVE_DEBOUNCE_MS); }, [saveProject]); + const saveProjectNow = useCallback(() => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + void saveProject().catch((err) => { + toast.error(err instanceof Error ? err.message : "保存画布失败"); + }); + }, [saveProject]); + const patchProjectData = useCallback( - (updater: (project: CanvasProjectRecord) => CanvasProjectRecord) => { + ( + updater: (project: CanvasProjectRecord) => CanvasProjectRecord, + options?: { recordHistory?: boolean }, + ) => { const current = projectRef.current; if (!current) return; const nextProject = updater(current); if (nextProject === current) return; - undoStackRef.current = [...undoStackRef.current.slice(-49), cloneCanvasData(current.data)]; - redoStackRef.current = []; - setHistoryAvailability(getHistoryAvailability(undoStackRef.current, redoStackRef.current)); + if (options?.recordHistory !== false) { + undoStackRef.current = [ + ...undoStackRef.current.slice(-49), + cloneCanvasData(current.data), + ]; + redoStackRef.current = []; + setHistoryAvailability( + getHistoryAvailability(undoStackRef.current, redoStackRef.current), + ); + } scheduleSave(nextProject); }, [scheduleSave], ); + const pushHistorySnapshot = useCallback((snapshot: CanvasProjectData | null) => { + if (!snapshot) return; + undoStackRef.current = [...undoStackRef.current.slice(-49), cloneCanvasData(snapshot)]; + redoStackRef.current = []; + setHistoryAvailability(getHistoryAvailability(undoStackRef.current, redoStackRef.current)); + }, []); + + const applyDragPosition = useCallback((point: CanvasPosition) => { + const state = dragState; + const current = projectRef.current; + if (!state || !current) return; + + const dx = point.x - state.startPointerWorld.x; + const dy = point.y - state.startPointerWorld.y; + dragMovedRef.current = dragMovedRef.current || dx !== 0 || dy !== 0; + + const nextProject = { + ...current, + data: { + ...current.data, + nodes: current.data.nodes.map((node) => { + if (!state.nodeIds.includes(node.id)) return node; + const startPosition = state.startPositions[node.id] ?? node.position; + return { + ...node, + position: { + x: startPosition.x + dx, + y: startPosition.y + dy, + }, + }; + }), + }, + }; + + setProject(nextProject); + projectRef.current = nextProject; + }, [dragState]); + const updateViewport = useCallback( (nextViewport: CanvasViewport) => { patchProjectData((current) => ({ ...current, data: { ...current.data, viewport: nextViewport }, - })); + }), { recordHistory: false }); }, [patchProjectData], ); @@ -433,8 +547,15 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { function clearCanvas() { if (!nodes.length && !connections.length) return; - const confirmed = window.confirm("清空画布上的所有节点和连接?"); - if (!confirmed) return; + setConfirmDialog({ + title: "清空当前画布?", + description: "这会删除画布上的所有节点和连接。此操作可以通过撤回恢复。", + actionLabel: "清空画布", + onConfirm: performClearCanvas, + }); + } + + function performClearCanvas() { patchProjectData((current) => ({ ...current, data: { @@ -615,6 +736,8 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { setSelectedNodeId(node.id); setSelectedNodeIds(nextNodeIds); setSelectedConnectionId(null); + dragUndoSnapshotRef.current = projectRef.current ? cloneCanvasData(projectRef.current.data) : null; + dragMovedRef.current = false; const rect = containerRef.current?.getBoundingClientRect(); const startPointerWorld = rect ? screenToWorld( @@ -645,27 +768,14 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { } if (!dragState) return; - const dx = point.x - dragState.startPointerWorld.x; - const dy = point.y - dragState.startPointerWorld.y; - patchProjectData((current) => ({ - ...current, - data: { - ...current.data, - nodes: current.data.nodes.map((node) => { - if (!dragState.nodeIds.includes(node.id)) { - return node; - } - const startPosition = dragState.startPositions[node.id] ?? node.position; - return { - ...node, - position: { - x: startPosition.x + dx, - y: startPosition.y + dy, - }, - }; - }), - }, - })); + pendingDragPointRef.current = point; + if (dragFrameRef.current !== null) return; + dragFrameRef.current = requestAnimationFrame(() => { + dragFrameRef.current = null; + const pendingPoint = pendingDragPointRef.current; + pendingDragPointRef.current = null; + if (pendingPoint) applyDragPosition(pendingPoint); + }); } function startConnection(fromNodeId: string) { @@ -794,6 +904,22 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { insertLibraryImageNode(node); } + async function deleteHistoryImage(item: HistoryItem) { + 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 || "删除素材失败"); + } + setHistory((items) => items.filter((historyItem) => historyItem.id !== item.id)); + toast.success("素材已删除"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除素材失败"); + } + } + async function uploadImage(file: File) { return uploadImageAt(file, mouseWorld); } @@ -820,6 +946,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { filePath: payload.item.filePath, naturalWidth: naturalSize.width, naturalHeight: naturalSize.height, + fileSize: file.size, }, }; insertLibraryImageNode(node); @@ -897,6 +1024,48 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { } } + async function createNewCanvas() { + try { + const response = await fetch("/api/canvas/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "未命名画布" }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || !payload.item?.id) { + throw new Error(payload.error || "新建画布失败"); + } + router.push(`/canvas/${payload.item.id}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : "新建画布失败"); + } + } + + async function deleteCurrentCanvas(currentProjectId: string) { + setConfirmDialog({ + title: "删除当前画布?", + description: "此操作不可撤销,画布项目和其中的节点数据都会被删除。", + actionLabel: "删除画布", + onConfirm: () => performDeleteCurrentCanvas(currentProjectId), + }); + } + + async function performDeleteCurrentCanvas(currentProjectId: string) { + try { + const response = await fetch(`/api/canvas/projects/${currentProjectId}`, { + method: "DELETE", + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload.error || "删除画布失败"); + } + toast.success("画布已删除"); + router.push("/canvas", { scroll: false }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "删除画布失败"); + } + } + async function replaceNodeImage(nodeId: string, file: File) { try { const formData = new FormData(); @@ -927,6 +1096,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { status: "success", naturalWidth: naturalSize.width, naturalHeight: naturalSize.height, + fileSize: file.size, } as CanvasNodeType["metadata"], }); toast.success("图片已替换"); @@ -953,8 +1123,21 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { current.data.nodes.find((node) => node.id === anchorNodeId) ?? resolved.imageNode ?? resolved.promptNode; + const requestRoute = resolveCanvasRequestRoute( + canvasSettings, + resolved.config.channelId, + resolved.config.model, + ); const loadingPosition = placeResultNode(anchor, current.data.nodes, mouseWorld); const loadingNode = createImageLoadingNode(loadingPosition); + loadingNode.metadata = { + ...loadingNode.metadata, + channelId: requestRoute.channel?.id, + model: requestRoute.model, + size: resolved.config.size, + quality: resolved.config.quality, + outputFormat: resolved.config.outputFormat, + } satisfies CanvasImageNodeMetadata; const loadingConnection: CanvasConnection = { id: crypto.randomUUID(), type: "generated", @@ -982,13 +1165,18 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { setJob(null); try { const formData = new FormData(); + formData.append("source", "canvas"); formData.append("mode", resolved.imageNodes.length ? "edit" : "generate"); formData.append("prompt", resolved.prompt); - formData.append("model", resolved.config.model); + if (resolved.negativePrompt) { + formData.append("negativePrompt", resolved.negativePrompt); + } + formData.append("model", requestRoute.model); formData.append("size", resolved.config.size); formData.append("quality", resolved.config.quality); formData.append("outputFormat", resolved.config.outputFormat); formData.append("preserveIdentity", String(resolved.config.preserveIdentity)); + appendChannelConfig(formData, requestRoute.channel); for (const [index, imageNode] of resolved.imageNodes.entries()) { const imageMeta = imageNode.metadata as { @@ -996,12 +1184,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { imageUrl?: string; }; if (imageMeta.imageUrl) { - const file = await imageUrlToFile( - imageMeta.imageUrl, - `canvas-reference-${index + 1}.png`, - imageMeta.filePath, - ); - formData.append("image", file); + appendImageReference(formData, { + imageUrl: imageMeta.imageUrl, + filePath: imageMeta.filePath, + filename: `canvas-reference-${index + 1}.png`, + }); } } @@ -1074,6 +1261,151 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { } } + async function editImageWithMask({ + nodeId, + prompt, + mask, + }: LocalImageEditPayload) { + const current = projectRef.current; + if (!current || isGenerating) return; + + const sourceNode = current.data.nodes.find( + (node) => node.id === nodeId && node.type === "image", + ); + const sourceMetadata = sourceNode?.metadata as CanvasImageNodeMetadata | undefined; + if (!sourceNode || !sourceMetadata?.imageUrl) { + toast.error("请先选择一张图片"); + return; + } + + const projectWithEditingNode = { + ...current, + data: { + ...current.data, + nodes: current.data.nodes.map((node) => + node.id === sourceNode.id + ? { + ...node, + title: "局部修改中", + metadata: { + ...sourceMetadata, + status: "loading", + errorMessage: undefined, + } satisfies CanvasImageNodeMetadata, + } + : node, + ), + }, + }; + + setProject(projectWithEditingNode); + projectRef.current = projectWithEditingNode; + setSelectedNodeId(sourceNode.id); + setSelectedNodeIds([sourceNode.id]); + setIsGenerating(true); + await saveProject(projectWithEditingNode); + + const clientRequestId = createClientRequestId(); + const requestStartedAt = getNowMs(); + const requestRoute = resolveCanvasRequestRoute( + canvasSettings, + sourceMetadata.channelId, + sourceMetadata.model || defaultCanvasConfig.model, + ); + setJob(null); + + try { + const formData = new FormData(); + formData.append("mode", "edit"); + formData.append("prompt", prompt); + formData.append( + "model", + requestRoute.model, + ); + formData.append("size", sourceMetadata.size || defaultCanvasConfig.size); + formData.append("quality", sourceMetadata.quality || defaultCanvasConfig.quality); + formData.append( + "outputFormat", + sourceMetadata.outputFormat || defaultCanvasConfig.outputFormat, + ); + formData.append("preserveIdentity", "true"); + appendChannelConfig(formData, requestRoute.channel); + appendImageReference(formData, { + imageUrl: sourceMetadata.imageUrl, + filePath: sourceMetadata.filePath, + filename: "local-edit-source.png", + }); + formData.append("mask", mask); + + const response = await fetch("/api/images", { + method: "POST", + headers: { "x-client-request-id": clientRequestId }, + body: formData, + }); + const payload = await parseJsonResponse(response); + if (!response.ok) { + throw buildGenerationError(response, payload, requestStartedAt); + } + + const submittedJob = normalizeImageJobPayload(payload); + setJob(submittedJob); + toast.success("局部修改任务已提交", { + description: submittedJob.jobId + ? `任务 ID:${submittedJob.jobId}` + : undefined, + }); + if (!submittedJob.jobId) { + throw buildGenerationError( + response, + { ...payload, error: "服务端没有返回 jobId" }, + requestStartedAt, + ); + } + writeStoredCanvasJob(projectId, { + jobId: submittedJob.jobId, + loadingNodeId: sourceNode.id, + clientRequestId, + requestStartedAt, + targetMode: "replace-node", + }); + + const completedJob = await watchCanvasJob( + submittedJob.jobId, + requestStartedAt, + clientRequestId, + ); + if (completedJob.status === "failed") { + throw buildGenerationErrorFromJob(completedJob, requestStartedAt); + } + if (completedJob.result) { + await replaceCanvasNodeWithCompletedJob(completedJob, sourceNode.id); + } + } catch (err) { + const normalizedError = normalizeGenerationError( + err, + requestStartedAt, + clientRequestId, + ); + const latest = projectRef.current; + if (latest) { + const nextProject = markCanvasJobFailed( + latest, + sourceNode.id, + normalizedError.message, + ); + setProject(nextProject); + projectRef.current = nextProject; + void saveProject(nextProject); + } + toast.error(normalizedError.title, { + description: normalizedError.message, + }); + } finally { + setIsGenerating(false); + clearStoredCanvasJob(projectId); + } + } + function watchCanvasJob( jobId: string, requestStartedAt: number, @@ -1111,10 +1443,15 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { setHistory((items) => [completedJob.result!, ...items]); return; } + const loadingMetadata = loadingNode.metadata as CanvasImageNodeMetadata; const resultNode = { ...createImageResultNode(completedJob.result, loadingNode.position), id: loadingNodeId, }; + resultNode.metadata = { + ...(resultNode.metadata as CanvasImageNodeMetadata), + ...(loadingMetadata.channelId ? { channelId: loadingMetadata.channelId } : {}), + } satisfies CanvasImageNodeMetadata; const nextProject = { ...latest, data: { @@ -1132,6 +1469,69 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { await saveProject(nextProject); } + async function replaceCanvasNodeWithCompletedJob( + completedJob: ImageJobPayload, + nodeId: string, + ) { + if (!completedJob.result) return; + const latest = projectRef.current; + if (!latest) return; + const targetNode = latest.data.nodes.find( + (node) => node.id === nodeId && node.type === "image", + ); + if (!targetNode) { + setHistory((items) => [completedJob.result!, ...items]); + return; + } + + const previousMetadata = targetNode.metadata as CanvasImageNodeMetadata; + const naturalSize = await readImageNaturalSize(completedJob.result.imageUrl); + const dimensions = getImageNodeDimensions(naturalSize.width, naturalSize.height); + const nextProject = { + ...latest, + data: { + ...latest.data, + nodes: latest.data.nodes.map((node) => + node.id === nodeId + ? { + ...node, + title: + targetNode.title === "局部修改中" + ? "局部修改结果" + : targetNode.title, + width: dimensions.width, + height: dimensions.height, + metadata: { + ...previousMetadata, + imageUrl: completedJob.result!.imageUrl, + filePath: undefined, + mode: "result", + historyId: completedJob.result!.id, + prompt: completedJob.result!.prompt, + channelId: previousMetadata.channelId, + model: completedJob.result!.model, + size: completedJob.result!.size, + quality: completedJob.result!.quality, + outputFormat: completedJob.result!.outputFormat, + status: "success", + errorMessage: undefined, + naturalWidth: naturalSize.width, + naturalHeight: naturalSize.height, + fileSize: completedJob.result!.fileSize, + } satisfies CanvasImageNodeMetadata, + } + : node, + ), + }, + }; + setProject(nextProject); + projectRef.current = nextProject; + setSelectedNodeId(nodeId); + setSelectedNodeIds([nodeId]); + setHistory((items) => [completedJob.result!, ...items]); + await saveProject(nextProject); + } + function resumeCanvasJob(storedJob: StoredCanvasJob) { setIsGenerating(true); void watchCanvasJob( @@ -1146,7 +1546,11 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { storedJob.requestStartedAt, ); } - await applyCompletedCanvasJob(completedJob, storedJob.loadingNodeId); + if (storedJob.targetMode === "replace-node") { + await replaceCanvasNodeWithCompletedJob(completedJob, storedJob.loadingNodeId); + } else { + await applyCompletedCanvasJob(completedJob, storedJob.loadingNodeId); + } }) .catch((err) => { const normalizedError = normalizeGenerationError( @@ -1336,12 +1740,15 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { hideDockLabels={canvasSettings.hideToolbarText} isGenerating={isGenerating} isSaving={isSaving} + lastSavedAt={lastSavedAt} scale={viewport.k} selectionMode={selectionMode} showLibrary={showLibrary} title={project.title} onAddNode={addNode} - onBack={() => router.push("/canvas")} + onSave={saveProjectNow} + onGoHome={() => router.push("/", { scroll: false })} + onGoCanvasList={() => router.push("/canvas", { scroll: false })} onBackgroundModeChange={(backgroundMode) => patchProjectData((current) => ({ ...current, @@ -1353,6 +1760,8 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { onGenerate={() => void generateFromCanvas()} onRedo={redoCanvasChange} onResetView={() => updateViewport({ x: 0, y: 0, k: 1 })} + onCreateCanvas={() => void createNewCanvas()} + onDeleteCurrentCanvas={() => void deleteCurrentCanvas(projectId)} onToggleLibrary={() => setShowLibrary((current) => !current)} onToggleSelectionMode={() => setSelectionMode((current) => !current)} onOpenSettings={() => setShowSettingsDialog(true)} @@ -1383,6 +1792,20 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { }} onCanvasPointerMove={handleCanvasPointerMove} onCanvasPointerUp={() => { + if (dragFrameRef.current !== null) { + cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + } + if (pendingDragPointRef.current) { + applyDragPosition(pendingDragPointRef.current); + pendingDragPointRef.current = null; + } + if (dragState && dragMovedRef.current) { + pushHistorySnapshot(dragUndoSnapshotRef.current); + void saveProject(projectRef.current); + } + dragUndoSnapshotRef.current = null; + dragMovedRef.current = false; setDragState(null); setActiveConnection((current) => { if (!current) return current; @@ -1443,11 +1866,13 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { related={relatedNodeIds.has(node.id)} scale={viewport.k} selected={selectedNodeIds.includes(node.id)} + showImageInfoBadge={canvasSettings.showImageInfoBadge} onConnectEnd={finishConnection} onConnectStart={startConnection} onDelete={deleteNode} onDragStart={handleNodeDragStart} onGenerate={(id) => void generateFromCanvas(id)} + onLocalEdit={(payload) => void editImageWithMask(payload)} onReplaceImage={(id, file) => void replaceNodeImage(id, file)} onResize={(id, width, height) => patchNode(id, { width, height })} onSelect={(id, additive) => { @@ -1486,6 +1911,7 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { setShowLibrary(false)} onDeleteNode={deleteNode} + onDeleteHistory={(item) => void deleteHistoryImage(item)} onGenerateNode={(id) => void generateFromCanvas(id)} onInsertHistory={insertHistoryImage} onPatchNode={patchNode} @@ -1523,10 +1950,45 @@ export function CanvasEditorClient({ projectId }: { projectId: string }) { onClose={() => setShowSettingsDialog(false)} onTabChange={setSettingsTab} /> + setConfirmDialog(null)} + /> ); } +function ConfirmActionDialog({ + state, + onClose, +}: { + state: ConfirmDialogState | null; + onClose: () => void; +}) { + return ( + !open && onClose()}> + + + {state?.title} + {state?.description} + + + 取消 + { + const action = state?.onConfirm; + onClose(); + void action?.(); + }} + > + {state?.actionLabel || "确认"} + + + + + ); +} + function cloneCanvasData(data: CanvasProjectData): CanvasProjectData { return structuredClone(data); } @@ -1799,10 +2261,15 @@ function ShortcutDialog({ } const SETTINGS_TABS = [ + { + id: "channels", + title: "渠道配置", + description: "Base URL、密钥和模型", + }, { id: "model", - title: "大模型配置", - description: "模型、接口和密钥来源", + title: "模型选择", + description: "当前生图模型", }, { id: "interface", @@ -1833,8 +2300,23 @@ function CanvasSettingsDialog({ if (!open) return null; const patchSettings = (patch: Partial) => { - onChange({ ...settings, ...patch }); + onChange(normalizeCanvasSettings({ ...settings, ...patch })); }; + const activeChannel = + settings.channels.find((channel) => channel.id === settings.selectedChannelId) ?? + settings.channels[0]; + const title = + activeTab === "channels" + ? "渠道配置" + : activeTab === "model" + ? "模型选择" + : "界面设置"; + const description = + activeTab === "channels" + ? "渠道配置会以 JSON 存入数据库 settings 表,可直接拉取 OpenAI 兼容模型列表。" + : activeTab === "model" + ? "选择当前画布提交任务时使用的渠道和模型。" + : "调整画布工具栏在不同窗口尺寸下的展示方式。"; return (
-
-
- - patch({ model: event.target.value })} - /> - + patch(next)} + /> setBrushSize(Number(event.target.value))} + type="range" + value={brushSize} + /> + + +