From 9be4b16bdc2f8fbe162b5f1614e43606cd26d17a Mon Sep 17 00:00:00 2001 From: wonder Date: Sun, 24 May 2026 13:21:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=20Eino=20=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=9B=9B=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E7=94=9F=E6=88=90=E7=AE=A1=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 compose.Graph 编排 PromptBuilder → AssetGenerator → QualitySupervisor → FormatAdapter, 含质检重试分支(最多 3 次)和降级输出。推理层提供 mock 模式,可替换为真实 API。 --- backend/go.mod | 20 ++- backend/go.sum | 92 +++++++++++ backend/internal/service/inference.go | 102 ++++++++++++ backend/internal/service/nodes.go | 188 ++++++++++++++++++++++ backend/internal/service/pipeline.go | 102 ++++++++++++ backend/internal/service/pipeline_test.go | 129 +++++++++++++++ backend/internal/service/types.go | 64 ++++++++ 7 files changed, 696 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/inference.go create mode 100644 backend/internal/service/nodes.go create mode 100644 backend/internal/service/pipeline.go create mode 100644 backend/internal/service/pipeline_test.go create mode 100644 backend/internal/service/types.go diff --git a/backend/go.mod b/backend/go.mod index 37f12d7..a9befe9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,13 +2,20 @@ module gen2d go 1.26.3 -require github.com/gin-gonic/gin v1.12.0 +require ( + github.com/cloudwego/eino v0.8.13 + github.com/gin-gonic/gin v1.12.0 +) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eino-contrib/jsonschema v1.0.3 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -16,22 +23,33 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/goph/emperror v0.17.2 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nikolalohinski/gonja v1.5.3 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yargevad/filepathx v1.0.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 3a33231..0cab42f 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,20 +1,40 @@ +github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/eino v0.8.13 h1:z5dhaZNN8TWZbP/lgKxGmF26Ii8fPeUlQCGV/NTtms0= +github.com/cloudwego/eino v0.8.13/go.mod h1:+2N4nsMPxA6kGBHpH+75JuTfEcGprAMTdsZESrShKpU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= +github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -27,35 +47,85 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= +github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= +github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= @@ -66,24 +136,46 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/service/inference.go b/backend/internal/service/inference.go new file mode 100644 index 0000000..9574486 --- /dev/null +++ b/backend/internal/service/inference.go @@ -0,0 +1,102 @@ +package service + +import ( + "context" + "crypto/rand" + "fmt" + "image" + "image/color" + "image/png" + "bytes" +) + +// GenerateImages 调用 AI 推理 API 生成图片。 +// MVP 阶段返回 mock 占位图。 +func GenerateImages(ctx context.Context, prompt string, params AssetParams) ([]GeneratedImage, error) { + size := params.Resolution + if size <= 0 { + size = 64 + } + + count := 1 + if params.Frames.Directions > 0 && params.Frames.FramesPerDirection > 0 { + count = params.Frames.Directions * params.Frames.FramesPerDirection + } + + images := make([]GeneratedImage, count) + for i := 0; i < count; i++ { + data, err := generateMockImage(size, i) + if err != nil { + return nil, fmt.Errorf("generate mock image %d: %w", i, err) + } + images[i] = GeneratedImage{ + Data: data, + Width: size, + Height: size, + Format: "png", + } + } + return images, nil +} + +// QualityChecker 质检函数,可替换用于测试。 +// 签名:(ctx, images, style) → (pass, reason, error) +var QualityChecker = defaultCheckQuality + +// CheckQuality 调用当前 QualityChecker。 +func CheckQuality(ctx context.Context, images []GeneratedImage, style map[string]string) (bool, string, error) { + return QualityChecker(ctx, images, style) +} + +// defaultCheckQuality 默认 mock 质检,始终返回 pass。 +func defaultCheckQuality(ctx context.Context, images []GeneratedImage, style map[string]string) (bool, string, error) { + return true, "", nil +} + +// NewCountedQualityChecker 创建一个在第 passOnRetry 次调用时返回 pass 的质检函数。 +func NewCountedQualityChecker(passOnRetry int) func(context.Context, []GeneratedImage, map[string]string) (bool, string, error) { + var callCount int + return func(_ context.Context, _ []GeneratedImage, _ map[string]string) (bool, string, error) { + callCount++ + if callCount >= passOnRetry { + return true, "", nil + } + return false, fmt.Sprintf("风格不一致(第 %d 次质检)", callCount), nil + } +} + +// AlwaysFailQualityChecker 始终返回 fail 的质检函数。 +func AlwaysFailQualityChecker() func(context.Context, []GeneratedImage, map[string]string) (bool, string, error) { + return func(_ context.Context, _ []GeneratedImage, _ map[string]string) (bool, string, error) { + return false, "风格不一致", nil + } +} + +// generateMockImage 生成一张带随机色块的 PNG 占位图 +func generateMockImage(size int, seed int) ([]byte, error) { + img := image.NewRGBA(image.Rect(0, 0, size, size)) + + // 用 seed 生成不同颜色 + r := uint8((seed*47 + 13) % 256) + g := uint8((seed*83 + 37) % 256) + b := uint8((seed*61 + 71) % 256) + + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255}) + } + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// generateRandomBytes 用于生成随机数据(备用) +func generateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + return b, err +} diff --git a/backend/internal/service/nodes.go b/backend/internal/service/nodes.go new file mode 100644 index 0000000..3f1dfff --- /dev/null +++ b/backend/internal/service/nodes.go @@ -0,0 +1,188 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "github.com/cloudwego/eino/compose" +) + +// PromptBuilder 节点:接收输入,输出三段式提示词 +var promptBuilderNode = compose.InvokableLambda(func(ctx context.Context, in PipelineInput) (string, error) { + return buildPrompt(in), nil +}) + +// promptBuilderPreHandler 首次运行时保存输入到 state;重试时注入 RejectReason +func promptBuilderPreHandler(ctx context.Context, in PipelineInput, state *PipelineState) (PipelineInput, error) { + if state.RetryCount == 0 { + state.Input = in + } else if state.RejectReason != "" { + in.RejectReason = state.RejectReason + } + return in, nil +} + +// promptBuilderPostHandler 将提示词写入全局状态 +func promptBuilderPostHandler(ctx context.Context, out string, state *PipelineState) (string, error) { + state.FinalPrompt = out + return out, nil +} + +// AssetGenerator 节点:调用 AI 推理 API 出图 +var assetGeneratorNode = compose.InvokableLambda(func(ctx context.Context, prompt string) ([]GeneratedImage, error) { + var params AssetParams + _ = compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error { + params = state.Input.Params + return nil + }) + return GenerateImages(ctx, prompt, params) +}) + +// assetGeneratorPostHandler 将原始图片写入全局状态 +func assetGeneratorPostHandler(ctx context.Context, out []GeneratedImage, state *PipelineState) ([]GeneratedImage, error) { + state.RawImages = out + return out, nil +} + +// QualitySupervisor 节点:质检,输出 PipelineInput 供下游节点消费。 +// 将图片存入 state,设置路由目标 NextNode。 +var qualitySupervisorNode = compose.InvokableLambda(func(ctx context.Context, images []GeneratedImage) (PipelineInput, error) { + var input PipelineInput + err := compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error { + // 保存图片到状态 + state.RawImages = images + + // 质检 + style := mergeStyle(state.Input.ProjectStyle, state.Input.TaskStyle) + pass, reason, checkErr := CheckQuality(ctx, images, style) + if checkErr != nil { + return fmt.Errorf("quality check: %w", checkErr) + } + + state.PassQuality = pass + if !pass { + state.RejectReason = reason + } + + // 决定路由 + if pass { + state.NextNode = nodeFormatAdapter + } else if state.RetryCount >= 3 { + state.NextNode = nodeFormatAdapter // 超过重试次数,降级输出 + } else { + state.RetryCount++ + state.NextNode = nodePromptBuilder // 重生成 + } + + input = state.Input + return nil + }) + if err != nil { + return PipelineInput{}, err + } + return input, nil +}) + +// formatAdapterNode 节点:从 state 读取图片,格式转换,组装输出 +var formatAdapterNode = compose.InvokableLambda(func(ctx context.Context, input PipelineInput) (PipelineOutput, error) { + var images []GeneratedImage + _ = compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error { + images = state.RawImages + return nil + }) + + assets := make([]Asset, len(images)) + for i, img := range images { + assets[i] = Asset{ + Data: img.Data, + Format: img.Format, + URL: fmt.Sprintf("output/%d.%s", i, img.Format), + } + } + + resolution := input.Params.Resolution + if resolution <= 0 { + resolution = 64 + } + + metadata := AssetMetadata{ + FrameWidth: resolution, + FrameHeight: resolution, + FrameCount: len(images), + Directions: input.Params.Frames.Directions, + } + + return PipelineOutput{ + Assets: assets, + Metadata: metadata, + }, nil +}) + +// buildPrompt 构建三段式提示词 +func buildPrompt(in PipelineInput) string { + var parts []string + + // 【主题】 + parts = append(parts, fmt.Sprintf("【主题】%s", in.Prompt)) + + // 【约束】 + constraints := buildConstraints(in) + parts = append(parts, fmt.Sprintf("【约束】%s", constraints)) + + // 【内容】 + content := buildContent(in) + parts = append(parts, fmt.Sprintf("【内容】%s", content)) + + return strings.Join(parts, "\n") +} + +// buildConstraints 合并风格 + 负面提示词 + 重试原因 +func buildConstraints(in PipelineInput) string { + style := mergeStyle(in.ProjectStyle, in.TaskStyle) + + var parts []string + for k, v := range style { + parts = append(parts, fmt.Sprintf("%s: %s", k, v)) + } + + if in.RejectReason != "" { + parts = append(parts, fmt.Sprintf("上次质检问题:%s", in.RejectReason)) + } + + if len(parts) == 0 { + return "无特殊约束" + } + return strings.Join(parts, "; ") +} + +// buildContent 构建技术参数段 +func buildContent(in PipelineInput) string { + var parts []string + parts = append(parts, fmt.Sprintf("素材类型: %s", in.AssetType)) + if in.Params.Resolution > 0 { + parts = append(parts, fmt.Sprintf("分辨率: %d", in.Params.Resolution)) + } + if in.Params.Frames.Directions > 0 { + parts = append(parts, fmt.Sprintf("方向数: %d", in.Params.Frames.Directions)) + } + if in.Params.Frames.FramesPerDirection > 0 { + parts = append(parts, fmt.Sprintf("每方向帧数: %d", in.Params.Frames.FramesPerDirection)) + } + if in.Params.Format != "" { + parts = append(parts, fmt.Sprintf("输出格式: %s", in.Params.Format)) + } + return strings.Join(parts, "; ") +} + +// mergeStyle 合并工程风格与任务风格覆盖,任务同名键覆盖工程 +func mergeStyle(projectStyle, taskStyle map[string]string) map[string]string { + result := make(map[string]string) + for k, v := range projectStyle { + result[k] = v + } + for k, v := range taskStyle { + result[k] = v + } + return result +} diff --git a/backend/internal/service/pipeline.go b/backend/internal/service/pipeline.go new file mode 100644 index 0000000..18db114 --- /dev/null +++ b/backend/internal/service/pipeline.go @@ -0,0 +1,102 @@ +package service + +import ( + "context" + "fmt" + + "github.com/cloudwego/eino/compose" +) + +const ( + nodePromptBuilder = "prompt_builder" + nodeAssetGenerator = "asset_generator" + nodeQualitySupervisor = "quality_supervisor" + nodeFormatAdapter = "format_adapter" +) + +// NewGenerateGraph 创建四阶段生成管线 Graph。 +// +// START → PromptBuilder → AssetGenerator → QualitySupervisor +// ├── pass → FormatAdapter → END +// └── fail, retry<3 → PromptBuilder +// └── fail, retry>=3 → FormatAdapter (降级) +func NewGenerateGraph() (*compose.Graph[PipelineInput, PipelineOutput], error) { + g := compose.NewGraph[PipelineInput, PipelineOutput]( + compose.WithGenLocalState(func(ctx context.Context) *PipelineState { + return &PipelineState{} + }), + ) + + // 添加节点 + if err := g.AddLambdaNode(nodePromptBuilder, promptBuilderNode, + compose.WithStatePreHandler(promptBuilderPreHandler), + compose.WithStatePostHandler(promptBuilderPostHandler), + ); err != nil { + return nil, fmt.Errorf("add %s node: %w", nodePromptBuilder, err) + } + + if err := g.AddLambdaNode(nodeAssetGenerator, assetGeneratorNode, + compose.WithStatePostHandler(assetGeneratorPostHandler), + ); err != nil { + return nil, fmt.Errorf("add %s node: %w", nodeAssetGenerator, err) + } + + if err := g.AddLambdaNode(nodeQualitySupervisor, qualitySupervisorNode); err != nil { + return nil, fmt.Errorf("add %s node: %w", nodeQualitySupervisor, err) + } + + if err := g.AddLambdaNode(nodeFormatAdapter, formatAdapterNode); err != nil { + return nil, fmt.Errorf("add %s node: %w", nodeFormatAdapter, err) + } + + // 连线:正常路径 + if err := g.AddEdge(compose.START, nodePromptBuilder); err != nil { + return nil, fmt.Errorf("add edge START->%s: %w", nodePromptBuilder, err) + } + if err := g.AddEdge(nodePromptBuilder, nodeAssetGenerator); err != nil { + return nil, fmt.Errorf("add edge %s->%s: %w", nodePromptBuilder, nodeAssetGenerator, err) + } + if err := g.AddEdge(nodeAssetGenerator, nodeQualitySupervisor); err != nil { + return nil, fmt.Errorf("add edge %s->%s: %w", nodeAssetGenerator, nodeQualitySupervisor, err) + } + if err := g.AddEdge(nodeFormatAdapter, compose.END); err != nil { + return nil, fmt.Errorf("add edge %s->END: %w", nodeFormatAdapter, err) + } + + // 连线:质检分支(从 state.NextNode 读取路由目标) + if err := g.AddBranch(nodeQualitySupervisor, compose.NewGraphBranch( + func(ctx context.Context, _ PipelineInput) (string, error) { + var next string + _ = compose.ProcessState[*PipelineState](ctx, func(_ context.Context, state *PipelineState) error { + next = state.NextNode + return nil + }) + return next, nil + }, + map[string]bool{nodePromptBuilder: true, nodeFormatAdapter: true}, + )); err != nil { + return nil, fmt.Errorf("add branch at %s: %w", nodeQualitySupervisor, err) + } + + return g, nil +} + +// RunPipeline 编译并执行生成管线 +func RunPipeline(ctx context.Context, in PipelineInput) (*PipelineOutput, error) { + g, err := NewGenerateGraph() + if err != nil { + return nil, fmt.Errorf("create graph: %w", err) + } + + r, err := g.Compile(ctx, compose.WithMaxRunSteps(20)) + if err != nil { + return nil, fmt.Errorf("compile graph: %w", err) + } + + output, err := r.Invoke(ctx, in) + if err != nil { + return nil, fmt.Errorf("invoke pipeline: %w", err) + } + + return &output, nil +} diff --git a/backend/internal/service/pipeline_test.go b/backend/internal/service/pipeline_test.go new file mode 100644 index 0000000..b7234dd --- /dev/null +++ b/backend/internal/service/pipeline_test.go @@ -0,0 +1,129 @@ +package service + +import ( + "context" + "testing" +) + +func TestPipeline_HappyPath(t *testing.T) { + // 质检一次通过 + QualityChecker = func(_ context.Context, _ []GeneratedImage, _ map[string]string) (bool, string, error) { + return true, "", nil + } + defer func() { QualityChecker = defaultCheckQuality }() + + output, err := RunPipeline(context.Background(), PipelineInput{ + Prompt: "一个拿剑的小人", + AssetType: "sprite", + ProjectStyle: map[string]string{ + "artStyle": "pixel", + "palette": "warm", + }, + Params: AssetParams{ + Resolution: 64, + Format: "spritesheet", + }, + }) + if err != nil { + t.Fatalf("RunPipeline failed: %v", err) + } + + if len(output.Assets) == 0 { + t.Fatal("expected non-empty assets") + } + if output.Metadata.FrameWidth != 64 { + t.Errorf("expected FrameWidth=64, got %d", output.Metadata.FrameWidth) + } + if output.Metadata.FrameHeight != 64 { + t.Errorf("expected FrameHeight=64, got %d", output.Metadata.FrameHeight) + } +} + +func TestPipeline_RetryThenPass(t *testing.T) { + // 质检前 2 次 fail,第 3 次 pass + QualityChecker = NewCountedQualityChecker(3) + defer func() { QualityChecker = defaultCheckQuality }() + + output, err := RunPipeline(context.Background(), PipelineInput{ + Prompt: "一把火焰剑", + AssetType: "sprite", + Params: AssetParams{ + Resolution: 32, + Frames: FrameParams{ + Directions: 4, + FramesPerDirection: 2, + }, + Format: "spritesheet", + }, + }) + if err != nil { + t.Fatalf("RunPipeline failed: %v", err) + } + + // 4 directions × 2 frames = 8 张图 + if len(output.Assets) != 8 { + t.Errorf("expected 8 assets, got %d", len(output.Assets)) + } + if output.Metadata.FrameCount != 8 { + t.Errorf("expected FrameCount=8, got %d", output.Metadata.FrameCount) + } + if output.Metadata.Directions != 4 { + t.Errorf("expected Directions=4, got %d", output.Metadata.Directions) + } +} + +func TestPipeline_MaxRetryDegrade(t *testing.T) { + // 质检始终 fail,超过 3 次后降级输出 + QualityChecker = AlwaysFailQualityChecker() + defer func() { QualityChecker = defaultCheckQuality }() + + output, err := RunPipeline(context.Background(), PipelineInput{ + Prompt: "一只飞龙", + AssetType: "sprite", + Params: AssetParams{ + Resolution: 48, + }, + }) + if err != nil { + t.Fatalf("RunPipeline failed: %v", err) + } + + // 降级也应该有输出 + if len(output.Assets) == 0 { + t.Fatal("expected non-empty assets even on degrade") + } + if output.Metadata.FrameWidth != 48 { + t.Errorf("expected FrameWidth=48, got %d", output.Metadata.FrameWidth) + } +} + +func TestPipeline_StyleMerge(t *testing.T) { + // 验证风格合并:task 覆盖 project + QualityChecker = func(_ context.Context, _ []GeneratedImage, style map[string]string) (bool, string, error) { + // 验证合并结果 + if style["artStyle"] != "realistic" { + t.Errorf("expected artStyle=realistic (task override), got %s", style["artStyle"]) + } + if style["palette"] != "warm" { + t.Errorf("expected palette=warm (from project), got %s", style["palette"]) + } + return true, "", nil + } + defer func() { QualityChecker = defaultCheckQuality }() + + _, err := RunPipeline(context.Background(), PipelineInput{ + Prompt: "测试风格合并", + AssetType: "sprite", + ProjectStyle: map[string]string{ + "artStyle": "pixel", + "palette": "warm", + }, + TaskStyle: map[string]string{ + "artStyle": "realistic", // 覆盖 project + }, + Params: AssetParams{Resolution: 64}, + }) + if err != nil { + t.Fatalf("RunPipeline failed: %v", err) + } +} diff --git a/backend/internal/service/types.go b/backend/internal/service/types.go new file mode 100644 index 0000000..5b64bfb --- /dev/null +++ b/backend/internal/service/types.go @@ -0,0 +1,64 @@ +package service + +// PipelineInput 管线入口输入 +type PipelineInput struct { + Prompt string // 用户原始文本 + AssetType string // 素材类型:sprite / background / ui / animation + ProjectStyle map[string]string // 工程风格键值对 + TaskStyle map[string]string // 任务风格覆盖 + Params AssetParams // 技术参数 + RejectReason string // 重试时由 state 注入 +} + +// PipelineState Graph 全局状态,通过 WithGenLocalState 注入 +type PipelineState struct { + Input PipelineInput + FinalPrompt string // PromptBuilder 输出的三段式提示词 + RawImages []GeneratedImage // AssetGenerator 输出的原始图片 + PassQuality bool // QualitySupervisor 质检结果 + RejectReason string // 质检不通过原因 + RetryCount int // 重试次数 + NextNode string // QualitySupervisor 设置的路由目标 +} + +// PipelineOutput 管线最终输出 +type PipelineOutput struct { + Assets []Asset // 生成结果素材列表 + Metadata AssetMetadata // 元数据 +} + +// AssetParams 技术参数 +type AssetParams struct { + Resolution int + Frames FrameParams + Format string // "spritesheet" / "individual" +} + +// FrameParams 帧参数 +type FrameParams struct { + Directions int + FramesPerDirection int +} + +// GeneratedImage AI 生成的原始图片 +type GeneratedImage struct { + Data []byte + Width int + Height int + Format string +} + +// Asset 最终输出素材 +type Asset struct { + Data []byte + Format string + URL string +} + +// AssetMetadata 素材元数据 +type AssetMetadata struct { + FrameWidth int + FrameHeight int + FrameCount int + Directions int +}