579
server-node/package-lock.json
generated
579
server-node/package-lock.json
generated
@@ -20,6 +20,7 @@
|
||||
"pino-http": "^10.5.0",
|
||||
"pino-roll": "^3.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -703,6 +704,519 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/colour": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -1328,6 +1842,15 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/discontinuous-range": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
|
||||
@@ -2473,6 +2996,18 @@
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
@@ -2541,6 +3076,50 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"pino-http": "^10.5.0",
|
||||
"pino-roll": "^3.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3148,9 +3148,9 @@ test('custom world agent generate_landmarks action appends landmark cards over h
|
||||
baseUrl,
|
||||
token: entry.token,
|
||||
});
|
||||
const baselineLandmarkCount = session.draftCards.filter(
|
||||
(card) => card.kind === 'landmark',
|
||||
).length;
|
||||
const baselineLandmarkCount =
|
||||
session.draftProfile?.landmarks?.length ??
|
||||
session.draftCards.filter((card) => card.kind === 'landmark').length;
|
||||
const anchorCardId =
|
||||
session.draftCards.find((card) => card.kind === 'character')?.id ??
|
||||
session.draftCards.find((card) => card.kind === 'thread')?.id;
|
||||
@@ -3211,7 +3211,10 @@ test('custom world agent generate_landmarks action appends landmark cards over h
|
||||
};
|
||||
|
||||
assert.equal(sessionResponse.status, 200);
|
||||
assert.ok((sessionPayload.draftProfile?.landmarks?.length ?? 0) >= 6);
|
||||
assert.ok(
|
||||
(sessionPayload.draftProfile?.landmarks?.length ?? 0) >=
|
||||
baselineLandmarkCount + 2,
|
||||
);
|
||||
assert.ok(
|
||||
sessionPayload.draftCards.filter((card) => card.kind === 'landmark')
|
||||
.length >=
|
||||
|
||||
@@ -578,6 +578,14 @@ test('chat orchestrator force closes the fifth hostile primary-npc turn with for
|
||||
monsters: [],
|
||||
history: [],
|
||||
context: createStoryContext(),
|
||||
combatContext: {
|
||||
summary: '你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口。',
|
||||
logLines: [
|
||||
'你先一步抢进桥心,逼开了对方的起手。',
|
||||
'断桥客被逼退到桥栏边,终于没有再出下一刀。',
|
||||
],
|
||||
battleOutcome: 'victory',
|
||||
},
|
||||
conversationHistory: [
|
||||
{ speaker: 'player', text: '你一直躲着不说完。' },
|
||||
{ speaker: 'npc', text: '有些话说完了,人也就该死了。' },
|
||||
@@ -658,6 +666,15 @@ test('chat orchestrator force closes the fifth hostile primary-npc turn with for
|
||||
assert.equal(requestMessageCount, 0);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /最后一轮/u);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /推动后续剧情/u);
|
||||
assert.match(capturedReplyPrompts[0] ?? '', /刚刚结束的交锋/u);
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/你刚在断桥口压住了断桥客的刀势,逼得他不得不重新开口/u,
|
||||
);
|
||||
assert.match(
|
||||
capturedReplyPrompts[0] ?? '',
|
||||
/你先一步抢进桥心,逼开了对方的起手/u,
|
||||
);
|
||||
|
||||
const eventText = responseChunks.join('');
|
||||
const completeBlock = eventText
|
||||
|
||||
@@ -375,59 +375,6 @@ test('character visual generation converts public reference images into data url
|
||||
);
|
||||
});
|
||||
|
||||
test('character prompt bundle generation falls back to local defaults when llm client is unavailable', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-character-prompt-bundle-'),
|
||||
);
|
||||
|
||||
await withAssetRouteServer(
|
||||
createTestConfig(tempRoot, 'http://127.0.0.1:9/api/v1'),
|
||||
async (assetBaseUrl) => {
|
||||
const response = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-prompts/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roleKind: 'story',
|
||||
characterName: '港口向导',
|
||||
roleTitle: '潮灯守望者',
|
||||
roleLabel: '旧港引路人',
|
||||
description: '熟悉黑潮与暗礁,身上带着潮雾气息。',
|
||||
backstory: '常年守在废弃灯塔附近,为误入者指路。',
|
||||
personality: '冷静克制,但会在关键时刻出手。',
|
||||
motivation: '想守住最后一段仍能靠岸的航道。',
|
||||
combatStyle: '短刀与信号灯配合,动作利落。',
|
||||
tags: ['潮雾', '守望', '引路'],
|
||||
characterBriefText:
|
||||
'角色名称:港口向导\n角色头衔:潮灯守望者\n世界身份:旧港引路人',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
source: string;
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
assert.equal(payload.source, 'fallback');
|
||||
assert.match(payload.visualPromptText, /港口向导/u);
|
||||
assert.match(payload.visualPromptText, /右向斜侧身/u);
|
||||
assert.match(payload.visualPromptText, /纯绿色绿幕/u);
|
||||
assert.match(payload.visualPromptText, /2 到 2\.5 头身/u);
|
||||
assert.match(payload.visualPromptText, /躯干与四肢短而紧凑/u);
|
||||
assert.match(payload.visualPromptText, /深色粗轮廓配合清晰大色块/u);
|
||||
assert.match(payload.animationPromptText, /动作/u);
|
||||
assert.match(payload.scenePromptText, /场景/u);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character workflow cache persists unsaved studio state', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-character-workflow-cache-'),
|
||||
|
||||
@@ -18,25 +18,17 @@ import { PNG } from 'pngjs';
|
||||
|
||||
import { removeBackgroundFromRgba } from '../../../../packages/shared/src/assets/chromaKey.js';
|
||||
import { parseApiErrorMessage } from '../../../../packages/shared/src/http.js';
|
||||
import { parseJsonResponseText } from '../../../../packages/shared/src/llm/parsers.js';
|
||||
import type { AppConfig } from '../../config.js';
|
||||
import {
|
||||
buildArkCharacterAnimationPrompt,
|
||||
buildCharacterPromptBundleUserPrompt,
|
||||
buildFallbackCharacterPromptBundle,
|
||||
buildFallbackModerationSafeAnimationPrompt,
|
||||
buildImageSequencePrompt,
|
||||
buildNpcAnimationPrompt,
|
||||
buildNpcVisualNegativePrompt,
|
||||
buildNpcVisualPrompt,
|
||||
CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
|
||||
type CharacterPromptBundle,
|
||||
sanitizeCharacterPromptBundle,
|
||||
} from '../../prompts/characterAssetPrompts.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
|
||||
const CHARACTER_PROMPT_BUNDLE_GENERATE_PATH =
|
||||
'/api/assets/character-prompts/generate';
|
||||
const CHARACTER_WORKFLOW_CACHE_PATH = '/api/assets/character-workflow-cache';
|
||||
const CHARACTER_VISUAL_GENERATE_PATH = '/api/assets/character-visual/generate';
|
||||
const CHARACTER_VISUAL_PUBLISH_PATH = '/api/assets/character-visual/publish';
|
||||
@@ -1050,106 +1042,6 @@ function getLowestSupportedVideoResolution(model: string, fallback: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateCharacterPromptBundle(
|
||||
config: AppConfig,
|
||||
req: IncomingMessage & { body?: unknown },
|
||||
res: ServerResponse,
|
||||
llmClient?: UpstreamLlmClient | null,
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req);
|
||||
const roleKind =
|
||||
typeof body.roleKind === 'string' && body.roleKind.trim()
|
||||
? body.roleKind.trim()
|
||||
: 'story';
|
||||
const characterBriefText = clampPromptSeedText(body.characterBriefText, 2400);
|
||||
const characterName = clampPromptSeedText(body.characterName, 40);
|
||||
const roleTitle = clampPromptSeedText(body.roleTitle, 60);
|
||||
const roleLabel = clampPromptSeedText(body.roleLabel, 60);
|
||||
const description = clampPromptSeedText(body.description, 240);
|
||||
const backstory = clampPromptSeedText(body.backstory, 320);
|
||||
const personality = clampPromptSeedText(body.personality, 180);
|
||||
const motivation = clampPromptSeedText(body.motivation, 180);
|
||||
const combatStyle = clampPromptSeedText(body.combatStyle, 180);
|
||||
const tags = isStringArray(body.tags)
|
||||
? body.tags
|
||||
.map((item) => clampPromptSeedText(item, 24))
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
: [];
|
||||
|
||||
if (!characterBriefText) {
|
||||
sendJson(res, 400, {
|
||||
error: { message: '生成默认提示词前需要提供角色设定摘要。' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackBundle = buildFallbackCharacterPromptBundle({
|
||||
characterName,
|
||||
roleKind,
|
||||
roleTitle,
|
||||
roleLabel,
|
||||
description,
|
||||
backstory,
|
||||
personality,
|
||||
motivation,
|
||||
combatStyle,
|
||||
tags,
|
||||
});
|
||||
const llmApiKey =
|
||||
typeof config.llm?.apiKey === 'string' ? config.llm.apiKey.trim() : '';
|
||||
const llmModel =
|
||||
typeof config.llm?.model === 'string' ? config.llm.model : '';
|
||||
|
||||
if (!llmClient || !llmApiKey) {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...fallbackBundle,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const responseText = await llmClient.requestMessageContent({
|
||||
systemPrompt: CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT,
|
||||
userPrompt: buildCharacterPromptBundleUserPrompt({
|
||||
roleKind,
|
||||
characterBriefText,
|
||||
characterName,
|
||||
roleTitle,
|
||||
roleLabel,
|
||||
description,
|
||||
backstory,
|
||||
personality,
|
||||
motivation,
|
||||
combatStyle,
|
||||
tags,
|
||||
}),
|
||||
debugLabel: 'character-prompt-bundle',
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...sanitizeCharacterPromptBundle(
|
||||
parseJsonResponseText(responseText),
|
||||
fallbackBundle,
|
||||
llmModel,
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
sendJson(res, 200, {
|
||||
ok: true,
|
||||
...fallbackBundle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDraftBinaryFile(
|
||||
rootDir: string,
|
||||
relativePath: string,
|
||||
@@ -3107,12 +2999,6 @@ export function createCharacterAssetRoutes(
|
||||
return handleSaveCharacterWorkflowCache(config, request, response);
|
||||
}),
|
||||
);
|
||||
router.use(
|
||||
CHARACTER_PROMPT_BUNDLE_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
handleGenerateCharacterPromptBundle(config, request, response, llmClient),
|
||||
),
|
||||
);
|
||||
router.use(
|
||||
CHARACTER_VISUAL_GENERATE_PATH,
|
||||
toExpressHandler((request, response) =>
|
||||
|
||||
@@ -572,9 +572,13 @@ function buildFallbackCustomWorldCampScene(profile: {
|
||||
} as const;
|
||||
|
||||
return {
|
||||
id: 'custom-scene-camp',
|
||||
name: fallbackName,
|
||||
description: descriptionByMode[themeMode],
|
||||
dangerLevel: 'low',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1034,9 +1038,27 @@ function normalizeCampOutline(
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: toText(item.id) || fallback.id,
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkName:
|
||||
toText(connection.targetLandmarkName) ||
|
||||
toText(connection.target) ||
|
||||
toText(connection.sceneName),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) ||
|
||||
toText(connection.position) ||
|
||||
'forward',
|
||||
summary:
|
||||
toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkName),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1502,10 +1524,22 @@ function normalizeCampScene(
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: toText(item.id) || fallback.id,
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkId: toText(connection.targetLandmarkId),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) || toText(connection.position) || 'forward',
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkId),
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ export interface SceneActBlueprint {
|
||||
summary: string;
|
||||
stageCoverage: SceneActStage[];
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundAssetId?: string | null;
|
||||
encounterNpcIds: string[];
|
||||
primaryNpcId: string;
|
||||
linkedThreadIds: string[];
|
||||
@@ -263,10 +264,21 @@ export interface SceneChapterBlueprint {
|
||||
}
|
||||
|
||||
export interface CustomWorldCampScene {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
visualDescription?: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: CustomWorldSceneConnection[];
|
||||
narrativeResidues?:
|
||||
| Array<{
|
||||
summary?: string;
|
||||
changeHint?: string;
|
||||
hiddenTruth?: string;
|
||||
}>
|
||||
| null;
|
||||
}
|
||||
|
||||
export interface CustomWorldLandmark {
|
||||
|
||||
@@ -7,20 +7,17 @@ import {
|
||||
/**
|
||||
* 角色资产正式 prompt 主源。
|
||||
*
|
||||
* 这份脚本同时承担两层职责:
|
||||
* 1. 角色卡 -> 默认资产描述文本
|
||||
* - 产出 visualPromptText / animationPromptText / scenePromptText
|
||||
* - 这层本质上是在“编译默认描述文本”,不是最终直接发给图像模型的完整 prompt
|
||||
* 2. 默认描述文本 -> 正式模型 prompt
|
||||
* - buildNpcVisualPrompt / buildNpcAnimationPrompt / buildArkCharacterAnimationPrompt
|
||||
* - 这层才是正式发给图像 / 动作模型的 prompt 组装入口
|
||||
* 这份脚本当前只承担“正式模型 prompt 层”职责:
|
||||
* - buildNpcVisualPrompt
|
||||
* - buildNpcAnimationPrompt
|
||||
* - buildArkCharacterAnimationPrompt
|
||||
* - buildImageSequencePrompt
|
||||
*
|
||||
* 当前仓库状态需要特别区分:
|
||||
* - 当前自定义世界角色资产工坊默认输入框,实际直接使用前端
|
||||
* src/prompts/customWorldRolePromptDefaults.ts
|
||||
* - 本文件里的 CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT 及其生成接口
|
||||
* /api/assets/character-prompts/generate 目前仍保留、可用、且有测试覆盖,
|
||||
* 但不是当前资产工坊初始默认值的主链来源
|
||||
* - 默认描述文本的唯一主源已经统一为前端本地映射,
|
||||
* 不再保留后端独立 bundle 编译接口
|
||||
* - 当前正式角色主图与动作生成,仍然走本文件里的正式 prompt builder
|
||||
*/
|
||||
function clampPromptSeedText(value: unknown, maxLength: number) {
|
||||
@@ -31,147 +28,6 @@ function clampPromptSeedText(value: unknown, maxLength: number) {
|
||||
return value.replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
export const CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT = `你是 RPG 角色资产提示词编译器。
|
||||
你会收到一个角色设定摘要,请为当前项目生成 3 段可直接交给资产生成模型的中文提示词。
|
||||
你必须只输出一个 JSON 对象,不要输出 Markdown、代码块、注释或解释。
|
||||
输出格式必须严格为:
|
||||
{
|
||||
"visualPromptText": "角色主图提示词",
|
||||
"animationPromptText": "角色动作提示词",
|
||||
"scenePromptText": "角色关联场景提示词"
|
||||
}
|
||||
|
||||
硬性约束:
|
||||
- 所有字段都必须是自然中文。
|
||||
- visualPromptText 用于角色主图候选,必须是角色标准设定图而不是场景海报,突出单人全身、右向斜侧身站姿、脚底完整可见、服装武器轮廓稳定、纯绿色绿幕背景、1:1 画幅。
|
||||
- visualPromptText 里的主题词只能落在角色自身的服装、发型、材质、纹样、饰品、武器和发光细节上,不要自动补出建筑、风景、漂浮物、烟雾或其他角色以外的场景元素。
|
||||
- visualPromptText 要明确“身体整体朝右,但保留少量正面信息”,避免生成完全 90 度纯右视图。
|
||||
- animationPromptText 用于角色动作试片,必须突出发力方式、动作气质、连贯性、同一角色一致性,不要写镜头切换。
|
||||
- scenePromptText 用于该角色关联的场景背景,必须突出角色首次登场或主活动区域的环境气质与空间结构,适配横版 RPG 场景。
|
||||
- 三段提示词都要可直接使用,不要编号,不要加字段名解释,不要输出负面提示词。`;
|
||||
|
||||
export type CharacterPromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
source: 'llm' | 'fallback';
|
||||
model: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 当默认描述文本编译接口不可用,或当前环境不走 LLM 编译时,
|
||||
* 用角色卡字段本地拼出一份可直接使用的默认文本 bundle。
|
||||
*
|
||||
* 这份返回值属于“默认描述文本层”:
|
||||
* - visualPromptText: 给角色主图用的默认描述
|
||||
* - animationPromptText: 给动作试片用的默认描述
|
||||
* - scenePromptText: 给角色关联场景用的默认描述
|
||||
*
|
||||
* 它不是最终发给正式图像 / 动作模型的完整 prompt。
|
||||
*/
|
||||
export function buildFallbackCharacterPromptBundle(params: {
|
||||
characterName: string;
|
||||
roleKind: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
const roleAnchor =
|
||||
[params.roleTitle, params.roleLabel].filter(Boolean).join(' / ') ||
|
||||
(params.roleKind === 'playable' ? '可扮演角色' : '场景角色');
|
||||
const characterAnchor = params.characterName || '该角色';
|
||||
const descriptionAnchor =
|
||||
params.description || params.backstory || params.personality || '气质鲜明';
|
||||
const combatAnchor =
|
||||
params.combatStyle || params.motivation || '动作发力清晰';
|
||||
const tagAnchor =
|
||||
params.tags.length > 0 ? `保留 ${params.tags.join('、')} 的识别点。` : '';
|
||||
|
||||
return {
|
||||
visualPromptText: [
|
||||
`${characterAnchor},${roleAnchor}。`,
|
||||
'单人全身,2D 横版 RPG 角色标准设定图,1:1 正方形画幅,头身比控制在 2 到 2.5 头身,偏大头身,靠头部、发型、服装、配饰表现角色记忆点,躯干与四肢短而紧凑,五官简化,深色粗轮廓配合清晰大色块,右向斜侧身站立,身体整体朝右但保留少量正面信息,服装、发型、轮廓稳定清楚。',
|
||||
`外观气质围绕:${descriptionAnchor}。`,
|
||||
combatAnchor ? `战斗识别点:${combatAnchor}。` : '',
|
||||
tagAnchor,
|
||||
'背景固定为纯绿色绿幕,不带建筑、风景、漂浮物和其他场景元素,方便自动抠像,不做正面立绘,不做完全 90 度纯右视图,不做夸张透视。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
animationPromptText: [
|
||||
`${characterAnchor}的核心动作试片。`,
|
||||
'保持同一角色的服装、发型、体型一致,镜头稳定,侧身朝右,动作连贯。',
|
||||
combatAnchor ? `动作气质参考:${combatAnchor}。` : '',
|
||||
params.personality ? `角色气质补充:${params.personality}。` : '',
|
||||
'发力起手明确,过程干净,收招利落,避免漂移和变形。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
scenePromptText: [
|
||||
`${characterAnchor}关联主场景,适合作为首次登场区域或常驻活动空间。`,
|
||||
'16:9 横版 RPG 场景背景,上下分区清楚,上半部分表现中远景氛围,下半部分是可站立地面。',
|
||||
`场景叙事气质围绕:${descriptionAnchor}。`,
|
||||
params.backstory ? `背景线索可参考:${params.backstory}。` : '',
|
||||
params.motivation
|
||||
? `环境中可埋入与当前目标相关的暗示:${params.motivation}。`
|
||||
: '',
|
||||
'整体风格克制统一,适合剧情探索与战斗底图。',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
source: 'fallback' as const,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizePromptBundleValue(
|
||||
value: unknown,
|
||||
fallback: string,
|
||||
maxLength: number,
|
||||
) {
|
||||
const normalized = clampPromptSeedText(value, maxLength);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 LLM 返回的默认文本 bundle 规整成稳定结构。
|
||||
*
|
||||
* 这里只负责兜底、限长和字段补齐,不负责把 bundle 进一步编译成
|
||||
* 正式图像 / 动作生成 prompt。
|
||||
*/
|
||||
export function sanitizeCharacterPromptBundle(
|
||||
value: unknown,
|
||||
fallback: CharacterPromptBundle,
|
||||
model: string,
|
||||
) {
|
||||
const record = isRecordValue(value) ? value : {};
|
||||
|
||||
return {
|
||||
visualPromptText: sanitizePromptBundleValue(
|
||||
record.visualPromptText,
|
||||
fallback.visualPromptText,
|
||||
280,
|
||||
),
|
||||
animationPromptText: sanitizePromptBundleValue(
|
||||
record.animationPromptText,
|
||||
fallback.animationPromptText,
|
||||
280,
|
||||
),
|
||||
scenePromptText: sanitizePromptBundleValue(
|
||||
record.scenePromptText,
|
||||
fallback.scenePromptText,
|
||||
320,
|
||||
),
|
||||
source: 'llm' as const,
|
||||
model: model.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeAnimationPromptText(value: string, maxLength: number) {
|
||||
return value
|
||||
.replace(/\s+/gu, ' ')
|
||||
@@ -197,48 +53,6 @@ function buildCompactAnimationCharacterBrief(value: string) {
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认文本 bundle 的 user prompt。
|
||||
*
|
||||
* 这段文本只用于让 LLM 从角色卡摘要里提炼出
|
||||
* visualPromptText / animationPromptText / scenePromptText 三段默认描述,
|
||||
* 不是正式图像模型或动作模型的 system prompt。
|
||||
*/
|
||||
export function buildCharacterPromptBundleUserPrompt(params: {
|
||||
roleKind: string;
|
||||
characterBriefText: string;
|
||||
characterName: string;
|
||||
roleTitle: string;
|
||||
roleLabel: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
tags: string[];
|
||||
}) {
|
||||
return [
|
||||
'请根据下面的角色卡摘要,编译一组默认资产提示词。',
|
||||
'提示词用于当前项目的角色主图、动作试片和角色关联场景背景。',
|
||||
'请保留该角色的身份识别点、气质、战斗方式与世界感,不要空泛套模板。',
|
||||
'',
|
||||
`角色类型:${params.roleKind === 'playable' ? '可扮演角色' : '场景角色'}`,
|
||||
params.characterName ? `角色名称:${params.characterName}` : '',
|
||||
params.roleTitle ? `角色头衔:${params.roleTitle}` : '',
|
||||
params.roleLabel ? `世界身份:${params.roleLabel}` : '',
|
||||
params.description ? `角色描述:${params.description}` : '',
|
||||
params.backstory ? `角色背景:${params.backstory}` : '',
|
||||
params.personality ? `角色性格:${params.personality}` : '',
|
||||
params.motivation ? `角色动机:${params.motivation}` : '',
|
||||
params.combatStyle ? `战斗风格:${params.combatStyle}` : '',
|
||||
params.tags.length > 0 ? `角色标签:${params.tags.join('、')}` : '',
|
||||
'',
|
||||
'角色卡全文:',
|
||||
params.characterBriefText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 正式角色主图 prompt 编译入口。
|
||||
|
||||
@@ -212,6 +212,34 @@ function describeNpcConversationHistory(history: unknown, npcName: string) {
|
||||
: '当前聊天记录:暂无。';
|
||||
}
|
||||
|
||||
function describeNpcCombatContext(combatContext: unknown) {
|
||||
const record = asRecord(combatContext);
|
||||
const summary = readString(record?.summary);
|
||||
const battleOutcome = readString(record?.battleOutcome);
|
||||
const logLines = readStringArray(record?.logLines).slice(0, 6);
|
||||
if (!summary && logLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outcomeText =
|
||||
battleOutcome === 'spar_complete'
|
||||
? '切磋刚刚结束。'
|
||||
: battleOutcome === 'victory'
|
||||
? '战斗刚刚分出胜负。'
|
||||
: null;
|
||||
|
||||
return [
|
||||
'刚刚结束的交锋:',
|
||||
outcomeText,
|
||||
summary ? `- 结果摘要:${summary}` : null,
|
||||
...(logLines.length > 0
|
||||
? ['- 战斗日志:', ...logLines.map((line) => ` - ${line}`)]
|
||||
: []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function describeSceneContext(context: unknown) {
|
||||
const record = asRecord(context);
|
||||
const sceneName = readString(record?.sceneName) ?? '当前区域';
|
||||
@@ -510,10 +538,12 @@ export function buildNpcChatTurnReplyPrompt(
|
||||
context?.firstContactRelationStance,
|
||||
);
|
||||
const playerMessage = payload.playerMessage.trim();
|
||||
const combatContextBlock = describeNpcCombatContext(payload.combatContext);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
combatContextBlock,
|
||||
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
|
||||
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
|
||||
`当前关系值:${affinity}`,
|
||||
@@ -574,10 +604,12 @@ export function buildNpcChatTurnSuggestionPrompt(
|
||||
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
|
||||
? payload.conversationHistory
|
||||
: payload.dialogue ?? payload.conversationHistory ?? [];
|
||||
const combatContextBlock = describeNpcCombatContext(payload.combatContext);
|
||||
|
||||
return [
|
||||
buildNpcDialoguePromptBase(payload),
|
||||
describeNpcConversationHistory(conversationHistory, encounter.npcName),
|
||||
combatContextBlock,
|
||||
`玩家刚刚说:${payload.playerMessage}`,
|
||||
`NPC 刚刚回复:${npcReply}`,
|
||||
`请围绕刚刚这轮对话,为玩家生成 3 条下一轮可以直接说出口的中文接话短句。`,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildCustomWorldCoverImageSrc, resolveCustomWorldCoverPresentation } from './customWorldLibraryMetadata.js';
|
||||
|
||||
function createProfile() {
|
||||
return {
|
||||
id: 'profile-cover-test',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '封面规则测试',
|
||||
summary: '验证作品库封面优先级。',
|
||||
tone: '潮湿、压抑',
|
||||
playerGoal: '查明旧航道真相。',
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '林潮',
|
||||
imageSrc: '/images/roles/linchao.webp',
|
||||
},
|
||||
],
|
||||
camp: {
|
||||
imageSrc: '/images/camp/camp.webp',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
imageSrc: '/images/landmark/docks.webp',
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
backgroundImageSrc: '/images/scene/act-1.webp',
|
||||
backgroundAssetId: 'asset-scene-act-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('resolveCustomWorldCoverPresentation 优先使用开局场景第一幕图片', () => {
|
||||
const profile = createProfile();
|
||||
|
||||
const result = resolveCustomWorldCoverPresentation(profile);
|
||||
|
||||
assert.equal(result.imageSrc, '/images/scene/act-1.webp');
|
||||
assert.equal(result.renderMode, 'scene_with_roles');
|
||||
assert.deepEqual(result.characterImageSrcs, ['/images/roles/linchao.webp']);
|
||||
});
|
||||
|
||||
test('buildCustomWorldCoverImageSrc 在第一幕图片缺失时按营地图与地标图回退', () => {
|
||||
const profile = createProfile();
|
||||
profile.sceneChapterBlueprints = [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
backgroundImageSrc: '',
|
||||
backgroundAssetId: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(buildCustomWorldCoverImageSrc(profile), '/images/camp/camp.webp');
|
||||
|
||||
profile.camp = {
|
||||
imageSrc: '',
|
||||
};
|
||||
|
||||
assert.equal(buildCustomWorldCoverImageSrc(profile), '/images/landmark/docks.webp');
|
||||
});
|
||||
@@ -39,7 +39,23 @@ function normalizeCoverCharacterRoleIds(
|
||||
return [...availableIds].slice(0, 3);
|
||||
}
|
||||
|
||||
function resolveOpeningSceneFirstActImageSrc(profile: CustomWorldProfileRecord) {
|
||||
const sceneChapters = readArray(profile.sceneChapterBlueprints);
|
||||
const firstSceneChapter = sceneChapters.find(isRecord) ?? null;
|
||||
const firstAct = firstSceneChapter
|
||||
? readArray(firstSceneChapter.acts).find(isRecord) ?? null
|
||||
: null;
|
||||
|
||||
return firstAct ? readImageSrc(firstAct.backgroundImageSrc) : null;
|
||||
}
|
||||
|
||||
function resolveOpeningSceneImageSrc(profile: CustomWorldProfileRecord) {
|
||||
// 默认封面优先取开局场景第一幕图,保证创作草稿、作品库和正式结果页看到的是同一张“开场镜头”。
|
||||
const firstActImage = resolveOpeningSceneFirstActImageSrc(profile);
|
||||
if (firstActImage) {
|
||||
return firstActImage;
|
||||
}
|
||||
|
||||
const campImage = isRecord(profile.camp)
|
||||
? readImageSrc(profile.camp.imageSrc)
|
||||
: null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js
|
||||
import { UserRepository } from './repositories/userRepository.js';
|
||||
import { UserSessionRepository } from './repositories/userSessionRepository.js';
|
||||
import { CaptchaChallengeStore } from './services/captchaChallengeStore.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js';
|
||||
import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js';
|
||||
import { CustomWorldSessionStore } from './services/customWorldSessionStore.js';
|
||||
@@ -83,6 +84,23 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
|
||||
const customWorldAgentSessions = new CustomWorldAgentSessionStore(
|
||||
runtimeRepository,
|
||||
);
|
||||
const autoAssetService = new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
config.dashScope.apiKey.trim()
|
||||
? CustomWorldAgentAutoAssetService.createDashScopeCharacterVisualGenerator(
|
||||
config,
|
||||
)
|
||||
: CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(
|
||||
config,
|
||||
),
|
||||
config.dashScope.apiKey.trim()
|
||||
? CustomWorldAgentAutoAssetService.createDashScopeSceneActBackgroundGenerator(
|
||||
config,
|
||||
)
|
||||
: CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(
|
||||
config,
|
||||
),
|
||||
);
|
||||
const context: AppContext = {
|
||||
config,
|
||||
logger,
|
||||
@@ -102,6 +120,9 @@ export async function createAppContext(config: AppConfig = loadConfig()) {
|
||||
config.llm.apiKey.trim()
|
||||
? new UpstreamLlmClient(config, logger)
|
||||
: null,
|
||||
{
|
||||
autoAssetService,
|
||||
},
|
||||
),
|
||||
smsVerificationService: createSmsVerificationService(config, logger),
|
||||
wechatAuthService: createWechatAuthService(config, logger),
|
||||
|
||||
@@ -25,6 +25,14 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
|
||||
text: '你刚才那句话是什么意思?',
|
||||
},
|
||||
],
|
||||
combatContext: {
|
||||
summary: '你刚和柳无声短兵相接,胜负已分,但话还没有说完。',
|
||||
logLines: [
|
||||
'你侧身避开他的第一刀,反手逼退一步。',
|
||||
'柳无声被逼到桌角,终于没有继续出手。',
|
||||
],
|
||||
battleOutcome: 'victory',
|
||||
},
|
||||
playerMessage: '你能说得再明白一点吗?',
|
||||
npcState: {
|
||||
affinity: 4,
|
||||
@@ -60,6 +68,14 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
|
||||
text: '你刚才那句话是什么意思?',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
payload.combatContext?.summary,
|
||||
'你刚和柳无声短兵相接,胜负已分,但话还没有说完。',
|
||||
);
|
||||
assert.deepEqual(payload.combatContext?.logLines, [
|
||||
'你侧身避开他的第一刀,反手逼退一步。',
|
||||
'柳无声被逼到桌角,终于没有继续出手。',
|
||||
]);
|
||||
assert.equal(payload.questOfferContext?.turnCount, 2);
|
||||
assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1');
|
||||
assert.equal(payload.chatDirective?.remainingTurns, 3);
|
||||
|
||||
@@ -46,6 +46,12 @@ const npcChatQuestOfferContextSchema = z.object({
|
||||
turnCount: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const npcChatCombatContextSchema = z.object({
|
||||
summary: z.string().trim().min(1),
|
||||
logLines: z.array(z.string().trim().min(1)).default([]),
|
||||
battleOutcome: z.enum(['victory', 'spar_complete']),
|
||||
});
|
||||
|
||||
export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
|
||||
conversationSummary: z.string().optional().default(''),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
@@ -73,6 +79,7 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema
|
||||
.extend({
|
||||
conversationHistory: z.array(jsonObjectSchema).optional(),
|
||||
dialogue: z.array(jsonObjectSchema).optional(),
|
||||
combatContext: npcChatCombatContextSchema.nullable().optional(),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
npcState: jsonObjectSchema,
|
||||
npcInitiatesConversation: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
|
||||
function createTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-auto-assets-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir: path.join(projectRoot, 'logs'),
|
||||
dataDir: path.join(projectRoot, 'data'),
|
||||
rawEnv: {},
|
||||
databaseUrl: 'pg-mem://auto-assets',
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
editorApiEnabled: true,
|
||||
assetsApiEnabled: true,
|
||||
jwtSecret: 'test',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
endpoint: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: '',
|
||||
templateCode: '',
|
||||
templateParamKey: '',
|
||||
countryCode: '86',
|
||||
schemeName: '',
|
||||
codeLength: 6,
|
||||
codeType: 1,
|
||||
validTimeSeconds: 300,
|
||||
intervalSeconds: 60,
|
||||
duplicatePolicy: 1,
|
||||
caseAuthPolicy: 1,
|
||||
returnVerifyCode: false,
|
||||
mockVerifyCode: '123456',
|
||||
maxSendPerPhonePerDay: 20,
|
||||
maxSendPerIpPerHour: 30,
|
||||
maxVerifyFailuresPerPhonePerHour: 12,
|
||||
maxVerifyFailuresPerIpPerHour: 24,
|
||||
captchaTtlSeconds: 180,
|
||||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||
captchaTriggerVerifyFailuresPerIp: 5,
|
||||
blockPhoneFailureThreshold: 6,
|
||||
blockIpFailureThreshold: 10,
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
authorizeEndpoint: '',
|
||||
accessTokenEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
callbackPath: '',
|
||||
defaultRedirectPath: '/',
|
||||
mockUserId: '',
|
||||
mockUnionId: '',
|
||||
mockDisplayName: '',
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('auto asset service populates role visuals and scene act backgrounds', async () => {
|
||||
const config = createTestConfig('populate');
|
||||
const service = new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
|
||||
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
|
||||
);
|
||||
|
||||
const result = await service.populateDraftAssets({
|
||||
draftProfile: {
|
||||
name: '雾港列岛',
|
||||
subtitle: '守灯人与失序航道',
|
||||
summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。',
|
||||
tone: '冷峻、克制、海风里带着锈味',
|
||||
playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。',
|
||||
majorFactions: [],
|
||||
coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'role-playable',
|
||||
name: '沈砺',
|
||||
title: '失职守灯人',
|
||||
role: '可扮演角色',
|
||||
publicIdentity: '曾经的守灯人,如今回到失序海域前线。',
|
||||
currentPressure: '必须在旧友和旧职责之间重新站位。',
|
||||
relationToPlayer: '玩家本人',
|
||||
threadIds: ['thread-main'],
|
||||
summary: '他是玩家在这次风暴里的第一视角。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'role-story-1',
|
||||
name: '林潮',
|
||||
title: '码头引路人',
|
||||
role: '场景角色',
|
||||
publicIdentity: '码头上最懂回潮时间的人。',
|
||||
currentPressure: '决定今晚要不要让人进港。',
|
||||
relationToPlayer: '先帮一把,再继续试探。',
|
||||
threadIds: ['thread-main'],
|
||||
summary: '他是第一幕的引路人。',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'scene-dock',
|
||||
name: '潮汐码头',
|
||||
purpose: '承接第一章的主要碰撞。',
|
||||
mood: '潮声压低,封锁正在加重。',
|
||||
importance: '这里是玩家开局必须接住的门槛。',
|
||||
characterIds: ['role-story-1'],
|
||||
threadIds: ['thread-main'],
|
||||
summary: '码头上的第一次碰撞会直接决定后续节奏。',
|
||||
},
|
||||
],
|
||||
factions: [],
|
||||
threads: [
|
||||
{
|
||||
id: 'thread-main',
|
||||
title: '旧航道争夺',
|
||||
type: 'main',
|
||||
conflict: '守灯会与沉船商盟正在争夺旧航道解释权',
|
||||
characterIds: ['role-playable', 'role-story-1'],
|
||||
landmarkIds: ['scene-dock'],
|
||||
summary: '整条主线都围绕旧航道解释权改写展开。',
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-dock',
|
||||
sceneId: 'scene-dock',
|
||||
sceneName: '潮汐码头',
|
||||
title: '潮汐码头章节',
|
||||
summary: '三幕推进码头章节。',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
linkedLandmarkIds: ['scene-dock'],
|
||||
acts: [
|
||||
{
|
||||
id: 'dock-act-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '先由林潮把玩家带进港口节拍。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-story-1', 'role-playable'],
|
||||
primaryNpcId: 'role-story-1',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '接住第一幕入口压力',
|
||||
transitionHook: '下一幕开始会有人继续封锁码头。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
{
|
||||
id: 'dock-act-2',
|
||||
title: '封锁加压',
|
||||
summary: '第二幕把封锁真正抬上台面。',
|
||||
stageCoverage: ['expansion', 'turning_point'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-story-1', 'role-playable'],
|
||||
primaryNpcId: 'role-story-1',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '把冲突推高',
|
||||
transitionHook: '第三幕要把下一跳抛给玩家。',
|
||||
advanceRule: 'after_active_step_complete',
|
||||
},
|
||||
{
|
||||
id: 'dock-act-3',
|
||||
title: '潮线收束',
|
||||
summary: '第三幕负责把这章收住。',
|
||||
stageCoverage: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-story-1', 'role-playable'],
|
||||
primaryNpcId: 'role-story-1',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '完成章节收束',
|
||||
transitionHook: '把下一跳交给玩家。',
|
||||
advanceRule: 'after_chapter_resolution',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
worldHook: '雾港列岛',
|
||||
playerPremise: '被迫返乡的失职守灯人',
|
||||
openingSituation: '玩家正站在即将熄灭的旧灯塔上。',
|
||||
iconicElements: ['潮雾钟声', '盐火灯塔'],
|
||||
sourceAnchorSummary: '海岛悬疑,冷峻克制。',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.assetCoverage.allRoleAssetsReady, true);
|
||||
assert.equal(result.assetCoverage.allSceneAssetsReady, true);
|
||||
assert.equal(result.assetCoverage.sceneAssets.length, 3);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.ok(
|
||||
result.draftProfile.playableNpcs.every(
|
||||
(role) => typeof role.imageSrc === 'string' && typeof role.generatedVisualAssetId === 'string',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
result.draftProfile.playableNpcs.every((role) =>
|
||||
role.imageSrc?.endsWith('.png') ?? false,
|
||||
),
|
||||
);
|
||||
const playableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc;
|
||||
assert.ok(playableImageSrc);
|
||||
const playableImageMetadata = await sharp(
|
||||
path.join(config.publicDir, playableImageSrc.replace(/^\/+/u, '')),
|
||||
).metadata();
|
||||
assert.equal(playableImageMetadata.width, 1024);
|
||||
assert.equal(playableImageMetadata.height, 1024);
|
||||
assert.ok(
|
||||
result.draftProfile.sceneChapters.every((chapter) =>
|
||||
chapter.acts.every(
|
||||
(act) =>
|
||||
typeof act.backgroundImageSrc === 'string' &&
|
||||
typeof act.backgroundAssetId === 'string',
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
result.draftProfile.sceneChapters.every((chapter) =>
|
||||
chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('auto asset service degrades gracefully when asset generators fail', async () => {
|
||||
const config = createTestConfig('degrade');
|
||||
const service = new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
async () => {
|
||||
throw new Error('visual generator unavailable');
|
||||
},
|
||||
async () => {
|
||||
throw new Error('scene generator unavailable');
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.populateDraftAssets({
|
||||
draftProfile: {
|
||||
name: '雾港列岛',
|
||||
subtitle: '守灯人与失序航道',
|
||||
summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。',
|
||||
tone: '冷峻、克制、海风里带着锈味',
|
||||
playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。',
|
||||
majorFactions: [],
|
||||
coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'role-playable',
|
||||
name: '沈砺',
|
||||
title: '失职守灯人',
|
||||
role: '可扮演角色',
|
||||
publicIdentity: '曾经的守灯人,如今回到失序海域前线。',
|
||||
currentPressure: '必须在旧友和旧职责之间重新站位。',
|
||||
relationToPlayer: '玩家本人',
|
||||
threadIds: ['thread-main'],
|
||||
summary: '他是玩家在这次风暴里的第一视角。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'scene-dock',
|
||||
name: '潮汐码头',
|
||||
purpose: '承接第一章的主要碰撞。',
|
||||
mood: '潮声压低,封锁正在加重。',
|
||||
importance: '这里是玩家开局必须接住的门槛。',
|
||||
characterIds: ['role-playable'],
|
||||
threadIds: ['thread-main'],
|
||||
summary: '码头上的第一次碰撞会直接决定后续节奏。',
|
||||
},
|
||||
],
|
||||
factions: [],
|
||||
threads: [
|
||||
{
|
||||
id: 'thread-main',
|
||||
title: '旧航道争夺',
|
||||
type: 'main',
|
||||
conflict: '守灯会与沉船商盟正在争夺旧航道解释权',
|
||||
characterIds: ['role-playable'],
|
||||
landmarkIds: ['scene-dock'],
|
||||
summary: '整条主线都围绕旧航道解释权改写展开。',
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-dock',
|
||||
sceneId: 'scene-dock',
|
||||
sceneName: '潮汐码头',
|
||||
title: '潮汐码头章节',
|
||||
summary: '单章测试。',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
linkedLandmarkIds: ['scene-dock'],
|
||||
acts: [
|
||||
{
|
||||
id: 'dock-act-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '先接住入口。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-playable'],
|
||||
primaryNpcId: 'role-playable',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '接住入口压力',
|
||||
transitionHook: '继续推进。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
{
|
||||
id: 'dock-act-2',
|
||||
title: '封锁加压',
|
||||
summary: '继续抬高冲突。',
|
||||
stageCoverage: ['turning_point'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-playable'],
|
||||
primaryNpcId: 'role-playable',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '继续推进',
|
||||
transitionHook: '继续推进。',
|
||||
advanceRule: 'after_active_step_complete',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
worldHook: '雾港列岛',
|
||||
playerPremise: '被迫返乡的失职守灯人',
|
||||
openingSituation: '玩家正站在即将熄灭的旧灯塔上。',
|
||||
iconicElements: ['潮雾钟声'],
|
||||
sourceAnchorSummary: '海岛悬疑,冷峻克制。',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.assetCoverage.allRoleAssetsReady, true);
|
||||
assert.equal(result.assetCoverage.allSceneAssetsReady, true);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.ok(
|
||||
result.draftProfile.playableNpcs.every((role) =>
|
||||
role.imageSrc?.endsWith('.png') ?? false,
|
||||
),
|
||||
);
|
||||
const fallbackPlayableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc;
|
||||
assert.ok(fallbackPlayableImageSrc);
|
||||
const fallbackPlayableImageMetadata = await sharp(
|
||||
path.join(config.publicDir, fallbackPlayableImageSrc.replace(/^\/+/u, '')),
|
||||
).metadata();
|
||||
assert.equal(fallbackPlayableImageMetadata.width, 1024);
|
||||
assert.equal(fallbackPlayableImageMetadata.height, 1024);
|
||||
assert.ok(
|
||||
result.draftProfile.sceneChapters.every((chapter) =>
|
||||
chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false),
|
||||
),
|
||||
);
|
||||
});
|
||||
771
server-node/src/services/customWorldAgentAutoAssetService.ts
Normal file
771
server-node/src/services/customWorldAgentAutoAssetService.ts
Normal file
@@ -0,0 +1,771 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import type {
|
||||
CustomWorldAssetCoverageSummary,
|
||||
CustomWorldFoundationDraftCharacter,
|
||||
CustomWorldFoundationDraftProfile,
|
||||
CustomWorldFoundationDraftSceneAct,
|
||||
CustomWorldSceneAssetSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
buildNpcVisualNegativePrompt,
|
||||
buildNpcVisualPrompt,
|
||||
} from '../prompts/characterAssetPrompts.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
|
||||
type DraftProgressPayload = {
|
||||
phaseLabel: string;
|
||||
phaseDetail: string;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
type DraftProgressCallback = (
|
||||
payload: DraftProgressPayload,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type CharacterVisualGenerator = (params: {
|
||||
role: CustomWorldFoundationDraftCharacter;
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
}) => Promise<{
|
||||
imageSrc: string;
|
||||
generatedVisualAssetId: string;
|
||||
}>;
|
||||
|
||||
export type SceneActBackgroundGenerator = (params: {
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
sceneName: string;
|
||||
act: CustomWorldFoundationDraftSceneAct;
|
||||
primaryRoleName: string;
|
||||
supportRoleNames: string[];
|
||||
}) => Promise<{
|
||||
imageSrc: string;
|
||||
assetId: string;
|
||||
}>;
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string, fallback: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '')
|
||||
.slice(0, 48);
|
||||
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function normalizeDashScopeBaseUrl(value: string) {
|
||||
return value.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function createGeneratedAssetId(prefix: string) {
|
||||
return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
||||
}
|
||||
|
||||
async function writePlaceholderPng(params: {
|
||||
outputPath: string;
|
||||
width: number;
|
||||
height: number;
|
||||
rgb: [number, number, number];
|
||||
}) {
|
||||
const [r, g, b] = params.rgb;
|
||||
await sharp({
|
||||
create: {
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
channels: 3,
|
||||
background: { r, g, b },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toFile(params.outputPath);
|
||||
}
|
||||
|
||||
function collectStringsByKey(
|
||||
value: unknown,
|
||||
targetKey: string,
|
||||
results: string[],
|
||||
) {
|
||||
if (typeof value === 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectStringsByKey(entry, targetKey, results));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([key, nestedValue]) => {
|
||||
if (
|
||||
key === targetKey &&
|
||||
typeof nestedValue === 'string' &&
|
||||
nestedValue.trim()
|
||||
) {
|
||||
results.push(nestedValue.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
collectStringsByKey(nestedValue, targetKey, results);
|
||||
});
|
||||
}
|
||||
|
||||
function findFirstStringByKey(value: unknown, targetKey: string) {
|
||||
const results: string[] = [];
|
||||
collectStringsByKey(value, targetKey, results);
|
||||
return results[0] ?? '';
|
||||
}
|
||||
|
||||
function extractTaskId(payload: Record<string, unknown>) {
|
||||
return findFirstStringByKey(payload, 'task_id');
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: Record<string, unknown>) {
|
||||
const urls: string[] = [];
|
||||
collectStringsByKey(payload, 'image', urls);
|
||||
collectStringsByKey(payload, 'url', urls);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function buildRoleVisualSeedText(
|
||||
role: CustomWorldFoundationDraftCharacter,
|
||||
draftProfile: CustomWorldFoundationDraftProfile,
|
||||
) {
|
||||
return [
|
||||
`世界:${draftProfile.name}`,
|
||||
`世界摘要:${draftProfile.summary}`,
|
||||
`角色名:${role.name}`,
|
||||
`称号:${role.title}`,
|
||||
`身份:${role.role}`,
|
||||
`公开身份:${role.publicIdentity}`,
|
||||
role.publicMask ? `第一印象:${role.publicMask}` : '',
|
||||
`当前压力:${role.currentPressure}`,
|
||||
role.hiddenHook ? `隐藏钩子:${role.hiddenHook}` : '',
|
||||
`与玩家关系:${role.relationToPlayer}`,
|
||||
`角色摘要:${role.summary}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createFallbackCharacterVisual(params: {
|
||||
config: AppConfig;
|
||||
role: CustomWorldFoundationDraftCharacter;
|
||||
}) {
|
||||
const assetId = createGeneratedAssetId('draft-role-visual');
|
||||
const roleSegment = sanitizeSegment(params.role.id || params.role.name, 'role');
|
||||
const relativeDir = path.join(
|
||||
'generated-characters',
|
||||
roleSegment,
|
||||
'visual',
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(params.config.publicDir, relativeDir);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = 'master.png';
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
await writePlaceholderPng({
|
||||
outputPath: filePath,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
rgb: [78, 134, 220],
|
||||
});
|
||||
|
||||
return {
|
||||
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
|
||||
generatedVisualAssetId: assetId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSceneActPrompt(params: {
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
sceneName: string;
|
||||
act: CustomWorldFoundationDraftSceneAct;
|
||||
primaryRoleName: string;
|
||||
supportRoleNames: string[];
|
||||
}) {
|
||||
return [
|
||||
`这是世界《${params.draftProfile.name}》中的场景幕背景图。`,
|
||||
`场景:${params.sceneName}`,
|
||||
`幕标题:${params.act.title}`,
|
||||
`幕摘要:${params.act.summary}`,
|
||||
`幕目标:${params.act.actGoal}`,
|
||||
`过渡钩子:${params.act.transitionHook}`,
|
||||
`主角色:${params.primaryRoleName || '待补主角色'}`,
|
||||
params.supportRoleNames.length > 0
|
||||
? `辅助角色:${params.supportRoleNames.join('、')}`
|
||||
: '',
|
||||
`世界气质:${params.draftProfile.tone}`,
|
||||
`要求:只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createDashScopeTextToImageTask(params: {
|
||||
config: AppConfig;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
size: string;
|
||||
model: string;
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`${normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl)}/services/aigc/text2image/image-synthesis`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.config.dashScope.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
input: {
|
||||
prompt: params.prompt,
|
||||
...(params.negativePrompt
|
||||
? { negative_prompt: params.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: params.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseText || '创建图像生成任务失败。');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(responseText) as Record<string, unknown>;
|
||||
const taskId = extractTaskId(payload);
|
||||
if (!taskId) {
|
||||
throw new Error('图像生成任务未返回 task_id。');
|
||||
}
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
async function waitForDashScopeImage(params: {
|
||||
config: AppConfig;
|
||||
taskId: string;
|
||||
}) {
|
||||
const deadline = Date.now() + params.config.dashScope.requestTimeoutMs;
|
||||
const baseUrl = normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${params.taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw new Error(pollText || '查询图像生成任务失败。');
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
const imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
const actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
if (!imageUrl) {
|
||||
throw new Error('图像生成任务成功,但未返回图片地址。');
|
||||
}
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
actualPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw new Error(pollText || '图像生成任务失败。');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
throw new Error('图像生成任务超时。');
|
||||
}
|
||||
|
||||
async function saveRemoteImage(params: {
|
||||
config: AppConfig;
|
||||
imageUrl: string;
|
||||
relativeDir: string;
|
||||
fileBaseName: string;
|
||||
manifest: Record<string, unknown>;
|
||||
}) {
|
||||
const response = await fetch(params.imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('下载生成图片失败。');
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const extension = contentType.includes('png')
|
||||
? 'png'
|
||||
: contentType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const outputDir = path.join(params.config.publicDir, params.relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `${params.fileBaseName}.${extension}`;
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(params.manifest, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return `/${path.join(params.relativeDir, fileName).replace(/\\/gu, '/')}`;
|
||||
}
|
||||
|
||||
function findRoleById(
|
||||
draftProfile: CustomWorldFoundationDraftProfile,
|
||||
roleId: string,
|
||||
) {
|
||||
return [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find(
|
||||
(role) => role.id === roleId,
|
||||
);
|
||||
}
|
||||
|
||||
export class CustomWorldAgentAutoAssetService {
|
||||
constructor(
|
||||
private readonly config: AppConfig | null = null,
|
||||
private readonly characterVisualGenerator?: CharacterVisualGenerator | null,
|
||||
private readonly sceneActBackgroundGenerator?: SceneActBackgroundGenerator | null,
|
||||
) {}
|
||||
|
||||
async populateDraftAssets(params: {
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
onProgress?: DraftProgressCallback;
|
||||
}): Promise<{
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
assetCoverage: CustomWorldAssetCoverageSummary;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const nextDraftProfile: CustomWorldFoundationDraftProfile = JSON.parse(
|
||||
JSON.stringify(params.draftProfile),
|
||||
) as CustomWorldFoundationDraftProfile;
|
||||
const roles = [...nextDraftProfile.playableNpcs, ...nextDraftProfile.storyNpcs];
|
||||
const sceneAssetSummaries: CustomWorldSceneAssetSummary[] = [];
|
||||
const warnings: string[] = [];
|
||||
const totalRoleCount = roles.length;
|
||||
const totalActCount = nextDraftProfile.sceneChapters.reduce(
|
||||
(sum, chapter) => sum + chapter.acts.length,
|
||||
0,
|
||||
);
|
||||
let completedRoleCount = 0;
|
||||
let completedActCount = 0;
|
||||
|
||||
for (const role of roles) {
|
||||
if (!role.imageSrc || !role.generatedVisualAssetId) {
|
||||
try {
|
||||
const generatedVisual = this.characterVisualGenerator
|
||||
? await this.characterVisualGenerator({
|
||||
role,
|
||||
draftProfile: nextDraftProfile,
|
||||
})
|
||||
: this.config
|
||||
? await createFallbackCharacterVisual({
|
||||
config: this.config,
|
||||
role,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (generatedVisual) {
|
||||
role.imageSrc = generatedVisual.imageSrc;
|
||||
role.generatedVisualAssetId = generatedVisual.generatedVisualAssetId;
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
const fallbackVisual = this.config
|
||||
? await createFallbackCharacterVisual({
|
||||
config: this.config,
|
||||
role,
|
||||
})
|
||||
: null;
|
||||
if (fallbackVisual) {
|
||||
role.imageSrc = fallbackVisual.imageSrc;
|
||||
role.generatedVisualAssetId =
|
||||
fallbackVisual.generatedVisualAssetId;
|
||||
} else {
|
||||
warnings.push(
|
||||
`角色主形象生成失败:${role.name}(${error instanceof Error ? error.message : 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
// 角色主形象属于增强链路,主生成与回退都失败时仅记录告警,不阻断世界底稿主链。
|
||||
warnings.push(
|
||||
`角色主形象生成失败:${role.name}(${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completedRoleCount += 1;
|
||||
if (params.onProgress) {
|
||||
await params.onProgress({
|
||||
phaseLabel: '生成角色主形象',
|
||||
phaseDetail: `正在生成角色主形象 ${completedRoleCount}/${totalRoleCount}:${role.name}。`,
|
||||
progress:
|
||||
97 +
|
||||
Math.min(
|
||||
1,
|
||||
Math.round((completedRoleCount / Math.max(1, totalRoleCount)) * 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const sceneChapter of nextDraftProfile.sceneChapters) {
|
||||
for (const act of sceneChapter.acts) {
|
||||
let imageSrc = toText(act.backgroundImageSrc) || null;
|
||||
let assetId = toText(act.backgroundAssetId) || null;
|
||||
const primaryRole = findRoleById(
|
||||
nextDraftProfile,
|
||||
act.primaryNpcId || act.encounterNpcIds[0] || '',
|
||||
);
|
||||
const supportRoleNames = act.encounterNpcIds
|
||||
.slice(1)
|
||||
.map((roleId) => findRoleById(nextDraftProfile, roleId)?.name || '')
|
||||
.filter(Boolean);
|
||||
if (!imageSrc && this.sceneActBackgroundGenerator) {
|
||||
try {
|
||||
const result = await this.sceneActBackgroundGenerator({
|
||||
draftProfile: nextDraftProfile,
|
||||
sceneName: sceneChapter.sceneName,
|
||||
act,
|
||||
primaryRoleName: primaryRole?.name || '',
|
||||
supportRoleNames,
|
||||
});
|
||||
imageSrc = result.imageSrc;
|
||||
assetId = result.assetId;
|
||||
act.backgroundImageSrc = result.imageSrc;
|
||||
act.backgroundAssetId = result.assetId;
|
||||
} catch (error) {
|
||||
try {
|
||||
const fallbackScene = this.config
|
||||
? await CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(
|
||||
this.config,
|
||||
)({
|
||||
draftProfile: nextDraftProfile,
|
||||
sceneName: sceneChapter.sceneName,
|
||||
act,
|
||||
primaryRoleName: primaryRole?.name || '',
|
||||
supportRoleNames,
|
||||
})
|
||||
: null;
|
||||
if (fallbackScene) {
|
||||
imageSrc = fallbackScene.imageSrc;
|
||||
assetId = fallbackScene.assetId;
|
||||
act.backgroundImageSrc = fallbackScene.imageSrc;
|
||||
act.backgroundAssetId = fallbackScene.assetId;
|
||||
} else {
|
||||
warnings.push(
|
||||
`幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}(${error instanceof Error ? error.message : 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
// 幕图失败允许草稿继续生成;只有主生成与回退都失败时才保留缺口告警。
|
||||
warnings.push(
|
||||
`幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}(${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sceneAssetSummaries.push({
|
||||
sceneId: sceneChapter.sceneId,
|
||||
sceneName: sceneChapter.sceneName,
|
||||
actId: act.id,
|
||||
actTitle: act.title,
|
||||
imageSrc,
|
||||
assetId,
|
||||
status: imageSrc ? 'ready' : 'missing',
|
||||
nextPointCost: imageSrc ? 0 : 12,
|
||||
});
|
||||
|
||||
completedActCount += 1;
|
||||
if (params.onProgress) {
|
||||
await params.onProgress({
|
||||
phaseLabel: '生成幕背景图',
|
||||
phaseDetail: `正在生成幕背景图 ${completedActCount}/${totalActCount}:${sceneChapter.sceneName} · ${act.title}。`,
|
||||
progress:
|
||||
98 +
|
||||
Math.min(
|
||||
1,
|
||||
Math.round((completedActCount / Math.max(1, totalActCount)) * 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roleAssets = roles.map((role) => ({
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
roleKind: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id)
|
||||
? ('playable' as const)
|
||||
: ('story' as const),
|
||||
priorityTier: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id)
|
||||
? ('hero' as const)
|
||||
: ('featured' as const),
|
||||
portraitPath: role.imageSrc || null,
|
||||
generatedVisualAssetId: role.generatedVisualAssetId || null,
|
||||
generatedAnimationSetId: role.generatedAnimationSetId || null,
|
||||
status: role.imageSrc && role.generatedVisualAssetId ? 'visual_ready' : 'missing',
|
||||
missingAnimations: [],
|
||||
nextPointCost: role.imageSrc && role.generatedVisualAssetId ? 0 : 20,
|
||||
}));
|
||||
|
||||
return {
|
||||
draftProfile: nextDraftProfile,
|
||||
assetCoverage: {
|
||||
roleAssets,
|
||||
sceneAssets: sceneAssetSummaries,
|
||||
allRoleAssetsReady:
|
||||
roleAssets.length > 0 &&
|
||||
roleAssets.every((entry) => entry.status !== 'missing'),
|
||||
allSceneAssetsReady:
|
||||
sceneAssetSummaries.length > 0 &&
|
||||
sceneAssetSummaries.every((entry) => entry.status === 'ready'),
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
static createFallbackCharacterVisualGenerator(config: AppConfig): CharacterVisualGenerator {
|
||||
return async ({ role, draftProfile }) => {
|
||||
const assetId = createGeneratedAssetId('draft-role-visual');
|
||||
const roleSegment = sanitizeSegment(role.id || role.name, 'role');
|
||||
const relativeDir = path.join(
|
||||
'generated-characters',
|
||||
roleSegment,
|
||||
'visual',
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(config.publicDir, relativeDir);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = 'master.png';
|
||||
await writePlaceholderPng({
|
||||
outputPath: path.join(outputDir, fileName),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
rgb: [78, 134, 220],
|
||||
});
|
||||
const finalPrompt = buildNpcVisualPrompt(
|
||||
buildRoleVisualSeedText(role, draftProfile),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
prompt: finalPrompt,
|
||||
fallback: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
|
||||
generatedVisualAssetId: assetId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
static createDashScopeCharacterVisualGenerator(
|
||||
config: AppConfig,
|
||||
): CharacterVisualGenerator {
|
||||
return async ({ role, draftProfile }) => {
|
||||
const prompt = buildNpcVisualPrompt(
|
||||
buildRoleVisualSeedText(role, draftProfile),
|
||||
);
|
||||
const assetId = `draft-role-visual-${Date.now().toString(36)}`;
|
||||
const roleSegment = sanitizeSegment(role.id || role.name, 'role');
|
||||
const taskId = await createDashScopeTextToImageTask({
|
||||
config,
|
||||
prompt,
|
||||
negativePrompt: buildNpcVisualNegativePrompt(),
|
||||
size: '1024*1024',
|
||||
model: config.dashScope.imageModel || 'qwen-image-2.0',
|
||||
});
|
||||
const { imageUrl, actualPrompt } = await waitForDashScopeImage({
|
||||
config,
|
||||
taskId,
|
||||
});
|
||||
const relativeDir = path.join(
|
||||
'generated-characters',
|
||||
roleSegment,
|
||||
'visual',
|
||||
assetId,
|
||||
);
|
||||
const imageSrc = await saveRemoteImage({
|
||||
config,
|
||||
imageUrl,
|
||||
relativeDir,
|
||||
fileBaseName: 'master',
|
||||
manifest: {
|
||||
assetId,
|
||||
taskId,
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
prompt,
|
||||
actualPrompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
imageSrc,
|
||||
generatedVisualAssetId: assetId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
static createFallbackSceneActBackgroundGenerator(
|
||||
config: AppConfig,
|
||||
): SceneActBackgroundGenerator {
|
||||
return async ({
|
||||
draftProfile,
|
||||
sceneName,
|
||||
act,
|
||||
primaryRoleName,
|
||||
supportRoleNames,
|
||||
}) => {
|
||||
const finalPrompt = buildSceneActPrompt({
|
||||
draftProfile,
|
||||
sceneName,
|
||||
act,
|
||||
primaryRoleName,
|
||||
supportRoleNames,
|
||||
});
|
||||
const assetId = createGeneratedAssetId('draft-scene-act');
|
||||
const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene');
|
||||
const actSegment = sanitizeSegment(act.id || act.title, 'act');
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-scenes',
|
||||
sceneSegment,
|
||||
actSegment,
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(config.publicDir, relativeDir);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = 'scene.png';
|
||||
await writePlaceholderPng({
|
||||
outputPath: path.join(outputDir, fileName),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
rgb: [34, 52, 88],
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
sceneName,
|
||||
actId: act.id,
|
||||
actTitle: act.title,
|
||||
prompt: finalPrompt,
|
||||
fallback: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
|
||||
assetId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
static createDashScopeSceneActBackgroundGenerator(
|
||||
config: AppConfig,
|
||||
): SceneActBackgroundGenerator {
|
||||
return async ({
|
||||
draftProfile,
|
||||
sceneName,
|
||||
act,
|
||||
primaryRoleName,
|
||||
supportRoleNames,
|
||||
}) => {
|
||||
const prompt = buildSceneActPrompt({
|
||||
draftProfile,
|
||||
sceneName,
|
||||
act,
|
||||
primaryRoleName,
|
||||
supportRoleNames,
|
||||
});
|
||||
const assetId = createGeneratedAssetId('draft-scene-act');
|
||||
const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene');
|
||||
const actSegment = sanitizeSegment(act.id || act.title, 'act');
|
||||
const taskId = await createDashScopeTextToImageTask({
|
||||
config,
|
||||
prompt,
|
||||
size: '1280*720',
|
||||
model: config.dashScope.imageModel || 'wan2.2-t2i-flash',
|
||||
});
|
||||
const { imageUrl, actualPrompt } = await waitForDashScopeImage({
|
||||
config,
|
||||
taskId,
|
||||
});
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-scenes',
|
||||
sceneSegment,
|
||||
actSegment,
|
||||
assetId,
|
||||
);
|
||||
const imageSrc = await saveRemoteImage({
|
||||
config,
|
||||
imageUrl,
|
||||
relativeDir,
|
||||
fileBaseName: 'scene',
|
||||
manifest: {
|
||||
assetId,
|
||||
taskId,
|
||||
sceneName,
|
||||
actId: act.id,
|
||||
actTitle: act.title,
|
||||
prompt,
|
||||
actualPrompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
imageSrc,
|
||||
assetId,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1024,8 +1024,8 @@ function buildWorldWarnings(profile: CustomWorldFoundationDraftProfile) {
|
||||
if (totalCharacters < 3) {
|
||||
warnings.push('关键角色数量还偏少,建议继续补角色关系网。');
|
||||
}
|
||||
if (profile.landmarks.length < 4) {
|
||||
warnings.push('关键地点仍然偏少,第一版游历路径还不够饱满。');
|
||||
if (profile.landmarks.length < 2) {
|
||||
warnings.push('关键地点仍然偏少,第一版场景章节还不够饱满。');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CustomWorldFoundationDraftFaction,
|
||||
CustomWorldFoundationDraftLandmark,
|
||||
CustomWorldFoundationDraftProfile,
|
||||
CustomWorldFoundationDraftSceneChapter,
|
||||
CustomWorldFoundationDraftThread,
|
||||
EightAnchorContent,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
@@ -575,10 +576,25 @@ function buildCharacters(params: {
|
||||
|
||||
return dedupeStrings(
|
||||
characters.map((entry) => entry.name),
|
||||
5,
|
||||
FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT,
|
||||
).map((name) => characters.find((entry) => entry.name === name)!);
|
||||
}
|
||||
|
||||
function splitDraftCharacters(params: {
|
||||
characters: CustomWorldFoundationDraftCharacter[];
|
||||
playableCount: number;
|
||||
storyCount: number;
|
||||
}) {
|
||||
const playableNpcs = params.characters.slice(0, params.playableCount);
|
||||
const storyNpcs = params.characters
|
||||
.slice(params.playableCount, params.playableCount + params.storyCount);
|
||||
|
||||
return {
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCamp(params: {
|
||||
openingSituation: string;
|
||||
worldHook: string;
|
||||
@@ -776,9 +792,9 @@ function buildChapter(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3;
|
||||
const FOUNDATION_DRAFT_STORY_COUNT = 6;
|
||||
const FOUNDATION_DRAFT_LANDMARK_COUNT = 4;
|
||||
const FOUNDATION_DRAFT_PLAYABLE_COUNT = 1;
|
||||
const FOUNDATION_DRAFT_STORY_COUNT = 8;
|
||||
const FOUNDATION_DRAFT_LANDMARK_COUNT = 2;
|
||||
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2;
|
||||
const FOUNDATION_LANDMARK_BATCH_SIZE = 2;
|
||||
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2;
|
||||
@@ -798,6 +814,153 @@ type MergeableNamedRecord = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
function buildFallbackSceneActStageCoverage(index: number, actCount: number) {
|
||||
if (actCount <= 2) {
|
||||
return index === 0
|
||||
? (['opening', 'expansion'] as const)
|
||||
: (['turning_point', 'climax', 'aftermath'] as const);
|
||||
}
|
||||
|
||||
if (actCount === 3) {
|
||||
return index === 0
|
||||
? (['opening'] as const)
|
||||
: index === 1
|
||||
? (['expansion', 'turning_point'] as const)
|
||||
: (['climax', 'aftermath'] as const);
|
||||
}
|
||||
|
||||
if (actCount === 4) {
|
||||
return index === 0
|
||||
? (['opening'] as const)
|
||||
: index === 1
|
||||
? (['expansion'] as const)
|
||||
: index === 2
|
||||
? (['turning_point'] as const)
|
||||
: (['climax', 'aftermath'] as const);
|
||||
}
|
||||
|
||||
return (
|
||||
[
|
||||
['opening'],
|
||||
['expansion'],
|
||||
['turning_point'],
|
||||
['climax'],
|
||||
['aftermath'],
|
||||
][index] ?? ['aftermath']
|
||||
) as readonly string[];
|
||||
}
|
||||
|
||||
function buildSceneChaptersFromDraft(params: {
|
||||
landmarks: CustomWorldFoundationDraftLandmark[];
|
||||
playableNpcs: CustomWorldFoundationDraftCharacter[];
|
||||
storyNpcs: CustomWorldFoundationDraftCharacter[];
|
||||
threads: CustomWorldFoundationDraftThread[];
|
||||
}): CustomWorldFoundationDraftSceneChapter[] {
|
||||
const leadPlayable = params.playableNpcs[0] ?? null;
|
||||
const sceneRoles = params.storyNpcs;
|
||||
|
||||
return params.landmarks.slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT).map((landmark, index) => {
|
||||
const linkedThreadIds =
|
||||
landmark.threadIds.length > 0
|
||||
? landmark.threadIds.slice(0, 3)
|
||||
: params.threads
|
||||
.filter((thread) => thread.landmarkIds.includes(landmark.id))
|
||||
.map((thread) => thread.id)
|
||||
.slice(0, 3);
|
||||
const baseNpcIds = landmark.characterIds.length > 0
|
||||
? landmark.characterIds
|
||||
: sceneRoles.slice(index * 3, index * 3 + 3).map((role) => role.id);
|
||||
const uniqueNpcIds = [...new Set(baseNpcIds)].filter(Boolean);
|
||||
const primaryIds = uniqueNpcIds.slice(0, 3);
|
||||
const fallbackPrimaryIds = sceneRoles
|
||||
.filter((role) => !primaryIds.includes(role.id))
|
||||
.slice(0, 3 - primaryIds.length)
|
||||
.map((role) => role.id);
|
||||
const actPrimaryIds = [...primaryIds, ...fallbackPrimaryIds].slice(0, 3);
|
||||
const supportPool = [
|
||||
...uniqueNpcIds,
|
||||
...sceneRoles.map((role) => role.id),
|
||||
...(leadPlayable ? [leadPlayable.id] : []),
|
||||
].filter(Boolean);
|
||||
|
||||
const acts = actPrimaryIds.map((primaryNpcId, actIndex) => {
|
||||
const supportIds = supportPool.filter((roleId) => roleId !== primaryNpcId);
|
||||
const orderedEncounterNpcIds = [
|
||||
primaryNpcId,
|
||||
...supportIds.slice(0, 2),
|
||||
];
|
||||
const primaryRole =
|
||||
sceneRoles.find((role) => role.id === primaryNpcId) ?? leadPlayable;
|
||||
const supportRoles = orderedEncounterNpcIds
|
||||
.slice(1)
|
||||
.map((roleId) =>
|
||||
sceneRoles.find((role) => role.id === roleId) ??
|
||||
(leadPlayable?.id === roleId ? leadPlayable : null),
|
||||
)
|
||||
.filter((role): role is CustomWorldFoundationDraftCharacter => Boolean(role));
|
||||
|
||||
return {
|
||||
id: `${landmark.id}-act-${actIndex + 1}`,
|
||||
title:
|
||||
actIndex === 0
|
||||
? `${landmark.name}起势`
|
||||
: actIndex === 1
|
||||
? `${landmark.name}承压`
|
||||
: `${landmark.name}收束`,
|
||||
summary: clampText(
|
||||
[
|
||||
actIndex === 0
|
||||
? `这一幕先由${primaryRole?.name || '主角色'}把玩家带进${landmark.name}的当前压力。`
|
||||
: actIndex === 1
|
||||
? `${primaryRole?.name || '主角色'}会把${landmark.name}的冲突真正抬上台面。`
|
||||
: `${primaryRole?.name || '主角色'}会负责把这一章收束并抛出下一跳。`,
|
||||
landmark.summary,
|
||||
].join(' '),
|
||||
120,
|
||||
),
|
||||
stageCoverage: buildFallbackSceneActStageCoverage(actIndex, 3),
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: orderedEncounterNpcIds,
|
||||
primaryNpcId,
|
||||
linkedThreadIds,
|
||||
actGoal:
|
||||
actIndex === 0
|
||||
? `让玩家先接住${landmark.name}的入口压力`
|
||||
: actIndex === 1
|
||||
? `把${landmark.name}的冲突推到不可回避`
|
||||
: `把${landmark.name}这一章收住并抛向下一跳`,
|
||||
transitionHook:
|
||||
actIndex === 0
|
||||
? `${supportRoles[0]?.name || '另一名角色'}会在这一幕后继续加压。`
|
||||
: actIndex === 1
|
||||
? `这一幕结束后,${primaryRole?.name || '主角色'}会逼玩家接住最终选择。`
|
||||
: '这一幕结束后要把下一步去向和关系压力一起抛给玩家。',
|
||||
advanceRule:
|
||||
actIndex === 0
|
||||
? 'after_primary_contact'
|
||||
: actIndex === 2
|
||||
? 'after_chapter_resolution'
|
||||
: 'after_active_step_complete',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `scene-chapter-${landmark.id}`,
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
title: `${landmark.name}章节`,
|
||||
summary: clampText(
|
||||
`${landmark.name}会按三幕推进:先起势、再承压、最后收束。`,
|
||||
120,
|
||||
),
|
||||
linkedThreadIds,
|
||||
linkedLandmarkIds: [landmark.id],
|
||||
acts,
|
||||
} satisfies CustomWorldFoundationDraftSceneChapter;
|
||||
});
|
||||
}
|
||||
|
||||
function getNamedRecordKey(value: unknown) {
|
||||
return toText(value).replace(/\s+/gu, '');
|
||||
}
|
||||
@@ -1533,6 +1696,12 @@ function convertRuntimeProfileToFoundationDraft(params: {
|
||||
landmarks,
|
||||
threads,
|
||||
});
|
||||
const sceneChapters = buildSceneChaptersFromDraft({
|
||||
landmarks,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
threads,
|
||||
});
|
||||
const anchorRecord = toRecord(params.anchorPack);
|
||||
|
||||
return {
|
||||
@@ -1571,6 +1740,7 @@ function convertRuntimeProfileToFoundationDraft(params: {
|
||||
factions,
|
||||
threads,
|
||||
chapters: [chapter],
|
||||
sceneChapters,
|
||||
worldHook:
|
||||
clampText(params.intent.worldHook || params.profile.summary, 72) ||
|
||||
params.profile.summary,
|
||||
@@ -1793,7 +1963,12 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
threads: baseThreads,
|
||||
coreConflicts,
|
||||
iconicElements,
|
||||
}).slice(0, 5);
|
||||
}).slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT);
|
||||
const { playableNpcs, storyNpcs } = splitDraftCharacters({
|
||||
characters,
|
||||
playableCount: FOUNDATION_DRAFT_PLAYABLE_COUNT,
|
||||
storyCount: FOUNDATION_DRAFT_STORY_COUNT,
|
||||
});
|
||||
const camp = buildCamp({
|
||||
openingSituation,
|
||||
worldHook,
|
||||
@@ -1803,12 +1978,12 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
intent,
|
||||
camp,
|
||||
factions,
|
||||
characters,
|
||||
characters: [...playableNpcs, ...storyNpcs],
|
||||
threads: baseThreads,
|
||||
coreConflicts,
|
||||
iconicElements,
|
||||
openingSituation,
|
||||
}).slice(0, 6);
|
||||
}).slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT);
|
||||
const threads = finalizeThreads({
|
||||
threads: baseThreads.slice(0, 4),
|
||||
characters,
|
||||
@@ -1818,7 +1993,7 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
worldName,
|
||||
openingSituation,
|
||||
playerGoal,
|
||||
characters,
|
||||
characters: [...playableNpcs, ...storyNpcs],
|
||||
landmarks,
|
||||
threads,
|
||||
});
|
||||
@@ -1851,8 +2026,8 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
playerGoal,
|
||||
majorFactions: factions.map((entry) => entry.name),
|
||||
coreConflicts,
|
||||
playableNpcs: characters,
|
||||
storyNpcs: [],
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
camp,
|
||||
themePack: null,
|
||||
@@ -1860,6 +2035,12 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
factions,
|
||||
threads,
|
||||
chapters: [chapter],
|
||||
sceneChapters: buildSceneChaptersFromDraft({
|
||||
landmarks,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
threads,
|
||||
}),
|
||||
worldHook,
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import { badRequest, notFound } from '../errors.js';
|
||||
import { prepareEventStreamResponse } from '../http.js';
|
||||
import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
|
||||
import {
|
||||
buildPendingClarifications,
|
||||
@@ -274,10 +275,12 @@ function buildWelcomeMessage(params: {
|
||||
function buildFoundationDraftAssistantMessage(params: {
|
||||
relatedOperationId: string;
|
||||
draftProfile: unknown;
|
||||
warnings?: string[];
|
||||
}) {
|
||||
const profile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
const leadCharacter = profile?.playableNpcs[0];
|
||||
const leadLandmark = profile?.landmarks[0];
|
||||
const warnings = (params.warnings ?? []).filter(Boolean);
|
||||
|
||||
return {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
@@ -288,6 +291,12 @@ function buildFoundationDraftAssistantMessage(params: {
|
||||
'',
|
||||
`当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`,
|
||||
`建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`,
|
||||
...(warnings.length > 0
|
||||
? [
|
||||
'',
|
||||
`这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`,
|
||||
]
|
||||
: []),
|
||||
].join('\n'),
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: params.relatedOperationId,
|
||||
@@ -332,6 +341,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
|
||||
private readonly assetBridgeService: CustomWorldAgentAssetBridgeService;
|
||||
|
||||
private readonly autoAssetService: CustomWorldAgentAutoAssetService | null;
|
||||
|
||||
private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService;
|
||||
|
||||
constructor(
|
||||
@@ -339,6 +350,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
llmClient: UpstreamLlmClient | null = null,
|
||||
options: {
|
||||
singleTurnLlmClient?: UpstreamLlmClient | null;
|
||||
autoAssetService?: CustomWorldAgentAutoAssetService | null;
|
||||
} = {},
|
||||
) {
|
||||
this.foundationDraftService = new CustomWorldAgentFoundationDraftService(
|
||||
@@ -350,6 +362,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
);
|
||||
this.changeSummaryService = new CustomWorldAgentChangeSummaryService();
|
||||
this.assetBridgeService = new CustomWorldAgentAssetBridgeService();
|
||||
this.autoAssetService =
|
||||
options.autoAssetService ?? null;
|
||||
this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService(
|
||||
(options.singleTurnLlmClient ?? llmClient) ?? undefined,
|
||||
);
|
||||
@@ -844,9 +858,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
try {
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'running',
|
||||
phaseLabel: '生成世界底稿',
|
||||
phaseDetail: '正在根据已确认设定编译第一版世界结构。',
|
||||
progress: 38,
|
||||
phaseLabel: '整理世界骨架',
|
||||
phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。',
|
||||
progress: 12,
|
||||
});
|
||||
|
||||
await sleep(30);
|
||||
@@ -890,19 +904,44 @@ export class CustomWorldAgentOrchestrator {
|
||||
},
|
||||
});
|
||||
|
||||
const draftWithAssets = this.autoAssetService
|
||||
? await this.autoAssetService.populateDraftAssets({
|
||||
draftProfile,
|
||||
onProgress: async (progress) => {
|
||||
await this.sessionStore.updateOperation(
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
{
|
||||
status: 'running',
|
||||
phaseLabel: progress.phaseLabel,
|
||||
phaseDetail: progress.phaseDetail,
|
||||
progress: progress.progress,
|
||||
},
|
||||
);
|
||||
},
|
||||
})
|
||||
: {
|
||||
draftProfile,
|
||||
assetCoverage: rebuildRoleAssetCoverage(draftProfile),
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
phaseLabel: '编译草稿卡',
|
||||
phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。',
|
||||
progress: 98,
|
||||
});
|
||||
|
||||
const draftCards = this.draftCompiler.compileDraftCards(draftProfile);
|
||||
const assetCoverage = rebuildRoleAssetCoverage(draftProfile);
|
||||
const draftCards = this.draftCompiler.compileDraftCards(
|
||||
draftWithAssets.draftProfile,
|
||||
);
|
||||
const assetCoverage = draftWithAssets.assetCoverage;
|
||||
const nextStage = 'object_refining' as const;
|
||||
const nextSuggestedActions = buildSuggestedActions({
|
||||
stage: nextStage,
|
||||
isReady: true,
|
||||
draftProfile,
|
||||
draftProfile: draftWithAssets.draftProfile,
|
||||
draftCards,
|
||||
});
|
||||
|
||||
@@ -910,7 +949,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
stage: nextStage,
|
||||
creatorIntent,
|
||||
anchorPack,
|
||||
draftProfile: draftProfile as unknown as Record<string, unknown>,
|
||||
draftProfile:
|
||||
draftWithAssets.draftProfile as unknown as Record<string, unknown>,
|
||||
draftCards,
|
||||
assetCoverage,
|
||||
pendingClarifications: [],
|
||||
@@ -925,22 +965,34 @@ export class CustomWorldAgentOrchestrator {
|
||||
sessionId,
|
||||
buildFoundationDraftAssistantMessage({
|
||||
relatedOperationId: operationId,
|
||||
draftProfile,
|
||||
draftProfile: draftWithAssets.draftProfile,
|
||||
warnings: draftWithAssets.warnings,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail: `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`,
|
||||
phaseDetail:
|
||||
draftWithAssets.warnings.length > 0
|
||||
? `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。`
|
||||
: `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const currentOperation = await this.sessionStore.getOperation(
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
);
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'failed',
|
||||
phaseLabel: '底稿生成失败',
|
||||
phaseDetail: '这一轮没有成功把设定编成世界底稿。',
|
||||
phaseLabel:
|
||||
currentOperation?.phaseLabel?.trim() || '底稿生成失败',
|
||||
phaseDetail:
|
||||
currentOperation?.phaseDetail?.trim() ||
|
||||
'这一轮没有成功把设定编成世界底稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'draft foundation failed',
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
@@ -88,6 +94,102 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
};
|
||||
}
|
||||
|
||||
function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-agent-phase3-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir: path.join(projectRoot, 'logs'),
|
||||
dataDir: path.join(projectRoot, 'data'),
|
||||
rawEnv: {},
|
||||
databaseUrl: `pg-mem://${testName}`,
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
editorApiEnabled: true,
|
||||
assetsApiEnabled: true,
|
||||
jwtSecret: 'test',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
endpoint: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: '',
|
||||
templateCode: '',
|
||||
templateParamKey: '',
|
||||
countryCode: '86',
|
||||
schemeName: '',
|
||||
codeLength: 6,
|
||||
codeType: 1,
|
||||
validTimeSeconds: 300,
|
||||
intervalSeconds: 60,
|
||||
duplicatePolicy: 1,
|
||||
caseAuthPolicy: 1,
|
||||
returnVerifyCode: false,
|
||||
mockVerifyCode: '123456',
|
||||
maxSendPerPhonePerDay: 20,
|
||||
maxSendPerIpPerHour: 30,
|
||||
maxVerifyFailuresPerPhonePerHour: 12,
|
||||
maxVerifyFailuresPerIpPerHour: 24,
|
||||
captchaTtlSeconds: 180,
|
||||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||
captchaTriggerVerifyFailuresPerIp: 5,
|
||||
blockPhoneFailureThreshold: 6,
|
||||
blockIpFailureThreshold: 10,
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
authorizeEndpoint: '',
|
||||
accessTokenEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
callbackPath: '',
|
||||
defaultRedirectPath: '/',
|
||||
mockUserId: '',
|
||||
mockUnionId: '',
|
||||
mockDisplayName: '',
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackAutoAssetService(testName: string) {
|
||||
const config = createAutoAssetTestConfig(testName);
|
||||
return new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
|
||||
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
userId: string,
|
||||
@@ -161,6 +263,7 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('draft'),
|
||||
});
|
||||
const userId = 'user-phase3-draft';
|
||||
const readySession = await createReadySession(orchestrator, userId);
|
||||
@@ -179,6 +282,16 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
|
||||
const draftProfile = snapshot?.draftProfile as Record<string, unknown> | undefined;
|
||||
const playableNpcs = Array.isArray(draftProfile?.playableNpcs)
|
||||
? draftProfile?.playableNpcs
|
||||
: [];
|
||||
const storyNpcs = Array.isArray(draftProfile?.storyNpcs)
|
||||
? draftProfile?.storyNpcs
|
||||
: [];
|
||||
const sceneChapters = Array.isArray(draftProfile?.sceneChapters)
|
||||
? draftProfile?.sceneChapters
|
||||
: [];
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(snapshot?.stage, 'object_refining');
|
||||
@@ -189,6 +302,23 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark'));
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread'));
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter'));
|
||||
assert.equal(playableNpcs.length, 1);
|
||||
assert.ok(storyNpcs.length >= 4);
|
||||
assert.equal(sceneChapters.length, 2);
|
||||
assert.ok(
|
||||
sceneChapters.every(
|
||||
(entry) => Array.isArray((entry as { acts?: unknown[] }).acts) && ((entry as { acts?: unknown[] }).acts?.length ?? 0) === 3,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
playableNpcs.every(
|
||||
(entry) =>
|
||||
typeof (entry as { imageSrc?: unknown }).imageSrc === 'string' &&
|
||||
typeof (entry as { generatedVisualAssetId?: unknown }).generatedVisualAssetId === 'string',
|
||||
),
|
||||
);
|
||||
assert.ok((snapshot?.assetCoverage.sceneAssets.length ?? 0) >= 6);
|
||||
assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true);
|
||||
assert.equal(
|
||||
typeof (snapshot?.draftProfile as Record<string, unknown>)?.name,
|
||||
'string',
|
||||
@@ -221,6 +351,7 @@ test('phase3 draft_foundation rejects not-ready session', async () => {
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('not-ready'),
|
||||
});
|
||||
const userId = 'user-phase3-not-ready';
|
||||
const createdSession = await orchestrator.createSession(userId, {
|
||||
@@ -241,6 +372,7 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('summary'),
|
||||
});
|
||||
const userId = 'user-phase3-summary';
|
||||
const readySession = await createReadySession(orchestrator, userId);
|
||||
@@ -264,10 +396,70 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const draft = items.find((item) => item.sessionId === readySession.sessionId);
|
||||
const compiledProfile = normalizeFoundationDraftProfile(
|
||||
(
|
||||
await orchestrator.getSessionSnapshot(userId, readySession.sessionId)
|
||||
)?.draftProfile,
|
||||
);
|
||||
const totalRoleCount = [
|
||||
...new Set(
|
||||
[
|
||||
...(compiledProfile?.playableNpcs ?? []),
|
||||
...(compiledProfile?.storyNpcs ?? []),
|
||||
].map((entry) => entry.id),
|
||||
),
|
||||
].length;
|
||||
|
||||
assert.ok(draft);
|
||||
assert.ok((draft?.playableNpcCount ?? 0) >= 3);
|
||||
assert.ok((draft?.landmarkCount ?? 0) >= 4);
|
||||
assert.equal(draft?.playableNpcCount ?? 0, totalRoleCount);
|
||||
assert.equal(draft?.landmarkCount ?? 0, 2);
|
||||
assert.match(draft?.summary ?? '', /潮雾|守灯|航道/u);
|
||||
assert.match(draft?.subtitle ?? '', /守灯|冲突|列岛/u);
|
||||
});
|
||||
|
||||
test('phase3 draft foundation still completes when auto asset generation fails', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const autoAssetService = new CustomWorldAgentAutoAssetService(
|
||||
createAutoAssetTestConfig('asset-failure'),
|
||||
async () => {
|
||||
throw new Error('visual service timeout');
|
||||
},
|
||||
async () => {
|
||||
throw new Error('scene service timeout');
|
||||
},
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService,
|
||||
});
|
||||
const userId = 'user-phase3-asset-failure';
|
||||
const readySession = await createReadySession(orchestrator, userId);
|
||||
|
||||
const response = await orchestrator.executeAction(
|
||||
userId,
|
||||
readySession.sessionId,
|
||||
{
|
||||
action: 'draft_foundation',
|
||||
},
|
||||
);
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
readySession.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.doesNotMatch(operation?.phaseDetail ?? '', /资产补齐待后续处理/u);
|
||||
assert.ok(snapshot?.draftCards.length);
|
||||
assert.ok(
|
||||
snapshot?.messages.every(
|
||||
(message) =>
|
||||
message.role !== 'assistant' || !message.text.includes('资产补齐未完成'),
|
||||
),
|
||||
);
|
||||
assert.equal(snapshot?.assetCoverage.allRoleAssetsReady, true);
|
||||
assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
@@ -88,6 +93,102 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
};
|
||||
}
|
||||
|
||||
function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-agent-phase5-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir: path.join(projectRoot, 'logs'),
|
||||
dataDir: path.join(projectRoot, 'data'),
|
||||
rawEnv: {},
|
||||
databaseUrl: `pg-mem://${testName}`,
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
editorApiEnabled: true,
|
||||
assetsApiEnabled: true,
|
||||
jwtSecret: 'test',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
endpoint: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: '',
|
||||
templateCode: '',
|
||||
templateParamKey: '',
|
||||
countryCode: '86',
|
||||
schemeName: '',
|
||||
codeLength: 6,
|
||||
codeType: 1,
|
||||
validTimeSeconds: 300,
|
||||
intervalSeconds: 60,
|
||||
duplicatePolicy: 1,
|
||||
caseAuthPolicy: 1,
|
||||
returnVerifyCode: false,
|
||||
mockVerifyCode: '123456',
|
||||
maxSendPerPhonePerDay: 20,
|
||||
maxSendPerIpPerHour: 30,
|
||||
maxVerifyFailuresPerPhonePerHour: 12,
|
||||
maxVerifyFailuresPerIpPerHour: 24,
|
||||
captchaTtlSeconds: 180,
|
||||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||
captchaTriggerVerifyFailuresPerIp: 5,
|
||||
blockPhoneFailureThreshold: 6,
|
||||
blockIpFailureThreshold: 10,
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
authorizeEndpoint: '',
|
||||
accessTokenEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
callbackPath: '',
|
||||
defaultRedirectPath: '/',
|
||||
mockUserId: '',
|
||||
mockUnionId: '',
|
||||
mockDisplayName: '',
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackAutoAssetService(testName: string) {
|
||||
const config = createAutoAssetTestConfig(testName);
|
||||
return new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
|
||||
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
userId: string,
|
||||
@@ -178,6 +279,7 @@ test('phase5 generate_role_assets only allows a single role and moves session in
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('generate-role-assets'),
|
||||
});
|
||||
const userId = 'user-phase5-generate-role-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
@@ -217,6 +319,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in
|
||||
message.text.includes('角色资产工坊'),
|
||||
),
|
||||
);
|
||||
const preparedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
|
||||
(entry) => entry.roleId === characterIds[0],
|
||||
);
|
||||
assert.equal(preparedAssetSummary?.status, 'visual_ready');
|
||||
});
|
||||
|
||||
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
|
||||
@@ -224,6 +330,7 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('sync-role-assets'),
|
||||
});
|
||||
const userId = 'user-phase5-sync-role-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
|
||||
@@ -82,3 +82,48 @@ test('role asset summary treats idle and die as optional', () => {
|
||||
assert.equal(summary.status, 'complete');
|
||||
assert.deepEqual(summary.missingAnimations, []);
|
||||
});
|
||||
|
||||
test('role asset coverage includes scene act background readiness', async () => {
|
||||
const { rebuildRoleAssetCoverage } = await import(
|
||||
'./customWorldAgentRoleAssetStateService.js'
|
||||
);
|
||||
|
||||
const coverage = rebuildRoleAssetCoverage({
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'role-playable',
|
||||
name: '沈砺',
|
||||
threadIds: ['thread-1'],
|
||||
imageSrc: '/generated/role-playable.png',
|
||||
generatedVisualAssetId: 'visual-role-playable',
|
||||
skills: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
sceneChapters: [
|
||||
{
|
||||
sceneId: 'scene-dock',
|
||||
sceneName: '潮汐码头',
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-dock-act-1',
|
||||
title: '雾里靠岸',
|
||||
backgroundImageSrc: '/generated/scene-dock-act-1.png',
|
||||
backgroundAssetId: 'scene-act-asset-1',
|
||||
},
|
||||
{
|
||||
id: 'scene-dock-act-2',
|
||||
title: '封锁加压',
|
||||
backgroundImageSrc: '',
|
||||
backgroundAssetId: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(coverage.sceneAssets.length, 2);
|
||||
assert.equal(coverage.sceneAssets[0]?.status, 'ready');
|
||||
assert.equal(coverage.sceneAssets[1]?.status, 'missing');
|
||||
assert.equal(coverage.allSceneAssetsReady, false);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CustomWorldAssetPriorityTier,
|
||||
CustomWorldRoleAssetStatus,
|
||||
CustomWorldRoleAssetSummary,
|
||||
CustomWorldSceneAssetSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
|
||||
const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const;
|
||||
@@ -26,6 +27,19 @@ type DraftRoleRecord = {
|
||||
|
||||
type DraftRoleKind = 'playable' | 'story';
|
||||
|
||||
type DraftSceneActRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundAssetId?: string | null;
|
||||
};
|
||||
|
||||
type DraftSceneChapterRecord = {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
acts: DraftSceneActRecord[];
|
||||
};
|
||||
|
||||
type MergeRoleAssetIntoDraftProfilePayload = {
|
||||
roleId: string;
|
||||
portraitPath: string;
|
||||
@@ -66,6 +80,17 @@ function toAnimationMap(value: unknown) {
|
||||
return toRecord(value);
|
||||
}
|
||||
|
||||
function normalizeSceneActs(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => ({
|
||||
id: toText(item.id) || `act-${index + 1}`,
|
||||
title: toText(item.title) || `第 ${index + 1} 幕`,
|
||||
backgroundImageSrc: toText(item.backgroundImageSrc) || null,
|
||||
backgroundAssetId: toText(item.backgroundAssetId) || null,
|
||||
}))
|
||||
.filter((item) => Boolean(item.id));
|
||||
}
|
||||
|
||||
function hasAnimationAsset(entryValue: unknown) {
|
||||
const entry = toRecord(entryValue);
|
||||
if (!entry) {
|
||||
@@ -194,6 +219,31 @@ function collectDraftRoles(profileInput: unknown) {
|
||||
];
|
||||
}
|
||||
|
||||
function collectDraftSceneChapters(profileInput: unknown) {
|
||||
const profile = toRecord(profileInput);
|
||||
if (!profile) {
|
||||
return [] as DraftSceneChapterRecord[];
|
||||
}
|
||||
|
||||
return toRecordArray(profile.sceneChapters)
|
||||
.map((item, index) => {
|
||||
const sceneId = toText(item.sceneId);
|
||||
const sceneName = toText(item.sceneName) || toText(item.title);
|
||||
const acts = normalizeSceneActs(item.acts);
|
||||
|
||||
if (!sceneId || acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sceneId,
|
||||
sceneName: sceneName || `场景 ${index + 1}`,
|
||||
acts,
|
||||
} satisfies DraftSceneChapterRecord;
|
||||
})
|
||||
.filter((item): item is DraftSceneChapterRecord => Boolean(item));
|
||||
}
|
||||
|
||||
export function resolveRoleAssetStatusLabel(
|
||||
status: CustomWorldRoleAssetStatus,
|
||||
) {
|
||||
@@ -267,14 +317,36 @@ export function rebuildRoleAssetCoverage(
|
||||
const roleAssets = collectDraftRoles(draftProfile).map((entry) =>
|
||||
buildRoleAssetSummary(entry),
|
||||
);
|
||||
const sceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters(
|
||||
draftProfile,
|
||||
).flatMap((sceneChapter) =>
|
||||
sceneChapter.acts.map((act) => {
|
||||
const imageSrc = act.backgroundImageSrc ?? null;
|
||||
const assetId = act.backgroundAssetId ?? null;
|
||||
const ready = Boolean(imageSrc || assetId);
|
||||
|
||||
return {
|
||||
sceneId: sceneChapter.sceneId,
|
||||
sceneName: sceneChapter.sceneName,
|
||||
actId: act.id,
|
||||
actTitle: act.title,
|
||||
imageSrc,
|
||||
assetId,
|
||||
status: ready ? 'ready' : 'missing',
|
||||
nextPointCost: ready ? 0 : 12,
|
||||
} satisfies CustomWorldSceneAssetSummary;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
roleAssets,
|
||||
sceneAssets: [],
|
||||
sceneAssets,
|
||||
allRoleAssetsReady:
|
||||
roleAssets.length > 0 &&
|
||||
roleAssets.every((entry) => entry.status === 'complete'),
|
||||
allSceneAssetsReady: false,
|
||||
roleAssets.every((entry) => entry.status !== 'missing'),
|
||||
allSceneAssetsReady:
|
||||
sceneAssets.length > 0 &&
|
||||
sceneAssets.every((entry) => entry.status === 'ready'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -453,13 +453,18 @@ function buildCompatibleAssetCoverage(
|
||||
) {
|
||||
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
|
||||
const existingCoverage = toRecord(record.assetCoverage);
|
||||
const sceneAssets = Array.isArray(existingCoverage?.sceneAssets)
|
||||
? existingCoverage.sceneAssets
|
||||
: [];
|
||||
const sceneAssets =
|
||||
derivedCoverage.sceneAssets.length > 0
|
||||
? derivedCoverage.sceneAssets
|
||||
: Array.isArray(existingCoverage?.sceneAssets)
|
||||
? existingCoverage.sceneAssets
|
||||
: [];
|
||||
const allSceneAssetsReady =
|
||||
typeof existingCoverage?.allSceneAssetsReady === 'boolean'
|
||||
? existingCoverage.allSceneAssetsReady
|
||||
: false;
|
||||
derivedCoverage.sceneAssets.length > 0
|
||||
? derivedCoverage.allSceneAssetsReady
|
||||
: typeof existingCoverage?.allSceneAssetsReady === 'boolean'
|
||||
? existingCoverage.allSceneAssetsReady
|
||||
: false;
|
||||
|
||||
return {
|
||||
...derivedCoverage,
|
||||
|
||||
278
server-node/src/services/customWorldCoverAssetService.test.ts
Normal file
278
server-node/src/services/customWorldCoverAssetService.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { type AppConfig } from '../config.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import {
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
} from './customWorldCoverAssetService.js';
|
||||
|
||||
function createTestConfig(
|
||||
projectRoot: string,
|
||||
dashScopeBaseUrl: string,
|
||||
): AppConfig {
|
||||
return {
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
dashScope: {
|
||||
baseUrl: dashScopeBaseUrl,
|
||||
apiKey: 'test-dashscope-key',
|
||||
imageModel: 'wan2.2-t2i-flash',
|
||||
requestTimeoutMs: 5_000,
|
||||
},
|
||||
} as AppConfig;
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, payload: unknown) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function withHttpServer<T>(
|
||||
buildHandler: (
|
||||
baseUrl: string,
|
||||
) => (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
let handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void> = () => undefined;
|
||||
const server = createServer((req, res) => {
|
||||
Promise.resolve(handler(req, res)).catch((error) => {
|
||||
res.statusCode = 500;
|
||||
res.end(error instanceof Error ? error.stack : String(error));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('failed to resolve test server address');
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
handler = buildHandler(baseUrl);
|
||||
|
||||
try {
|
||||
return await run(baseUrl);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('uploadCustomWorldCoverImage crops to 16:9 and saves a compressed webp cover', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-cover-upload-'),
|
||||
);
|
||||
const context = {
|
||||
config: createTestConfig(tempRoot, 'http://127.0.0.1:9999/api/v1'),
|
||||
} as AppContext;
|
||||
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 2400,
|
||||
height: 1800,
|
||||
channels: 3,
|
||||
background: { r: 40, g: 78, b: 132 },
|
||||
},
|
||||
})
|
||||
.jpeg({ quality: 92 })
|
||||
.toBuffer();
|
||||
const imageDataUrl = `data:image/jpeg;base64,${inputBuffer.toString('base64')}`;
|
||||
|
||||
const result = await uploadCustomWorldCoverImage(context, {
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾群岛',
|
||||
imageDataUrl,
|
||||
cropRect: {
|
||||
x: 240,
|
||||
y: 225,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.sourceType, 'uploaded');
|
||||
assert.match(result.imageSrc, /^\/generated-custom-world-covers\//u);
|
||||
|
||||
const savedPath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
assert.equal(fs.existsSync(savedPath), true);
|
||||
const metadata = await sharp(savedPath).metadata();
|
||||
assert.equal(metadata.format, 'webp');
|
||||
assert.equal(metadata.width, 1600);
|
||||
assert.equal(metadata.height, 900);
|
||||
assert.ok(fs.statSync(savedPath).size <= Math.floor(1.5 * 1024 * 1024));
|
||||
});
|
||||
|
||||
test('generateCustomWorldCoverImage sends opening act and role images as reference images', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-cover-generate-'),
|
||||
);
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'images', 'scene'), { recursive: true });
|
||||
fs.mkdirSync(path.join(publicDir, 'images', 'roles'), { recursive: true });
|
||||
|
||||
const referenceBuffer = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 3,
|
||||
background: { r: 80, g: 120, b: 160 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
fs.writeFileSync(
|
||||
path.join(publicDir, 'images', 'scene', 'opening.png'),
|
||||
referenceBuffer,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(publicDir, 'images', 'roles', 'lead.png'),
|
||||
referenceBuffer,
|
||||
);
|
||||
|
||||
const capturedBodies: string[] = [];
|
||||
|
||||
await withHttpServer(
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
|
||||
) {
|
||||
capturedBodies.push((await readRequestBody(req)).toString('utf8'));
|
||||
sendJson(res, {
|
||||
output: {
|
||||
results: [
|
||||
{
|
||||
url: `${baseUrl}/downloads/cover.png`,
|
||||
actual_prompt: '整理后的封面提示词',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/cover.png') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(referenceBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const context = {
|
||||
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
|
||||
} as AppContext;
|
||||
|
||||
const result = await generateCustomWorldCoverImage(context, {
|
||||
profile: {
|
||||
id: 'world-1',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '旧航道与沉钟回响',
|
||||
summary: '用于验证封面参考素材收集。',
|
||||
tone: '潮湿、压抑',
|
||||
playerGoal: '查明旧航道真相',
|
||||
settingText: '旧港与潮雾正在失衡。',
|
||||
camp: null,
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '沉钟码头',
|
||||
description: '海雾压进旧码头。',
|
||||
imageSrc: '/images/scene/opening.png',
|
||||
},
|
||||
],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '林潮',
|
||||
title: '守潮人',
|
||||
role: '可扮演角色',
|
||||
description: '站在最前面的主角色。',
|
||||
imageSrc: '/images/roles/lead.png',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '沉钟码头',
|
||||
summary: '玩家第一次登上旧码头。',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '第一幕潮声压低,玩家刚踏上栈桥。',
|
||||
backgroundImageSrc: '/images/scene/opening.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
userPrompt: '像正式作品封面。',
|
||||
referenceImageSrc: '',
|
||||
characterRoleIds: ['playable-1'],
|
||||
size: '1600*900',
|
||||
});
|
||||
|
||||
assert.equal(result.sourceType, 'generated');
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(capturedBodies.length, 1);
|
||||
const createPayload = JSON.parse(capturedBodies[0] ?? '{}') as {
|
||||
input?: {
|
||||
messages?: Array<{
|
||||
content?: Array<{ image?: string; text?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const content =
|
||||
createPayload.input?.messages?.[0]?.content?.map((item) =>
|
||||
item.image ? 'image' : item.text ? 'text' : 'unknown',
|
||||
) ?? [];
|
||||
assert.ok(content.filter((item) => item === 'image').length >= 2);
|
||||
assert.equal(content[content.length - 1], 'text');
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
@@ -33,6 +34,21 @@ const coverLandmarkSchema = z.object({
|
||||
imageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverActSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
title: z.string().trim().optional().default(''),
|
||||
summary: z.string().trim().optional().default(''),
|
||||
backgroundImageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverSceneChapterSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
sceneId: z.string().trim().optional().default(''),
|
||||
title: z.string().trim().optional().default(''),
|
||||
summary: z.string().trim().optional().default(''),
|
||||
acts: z.array(coverActSchema).optional().default([]),
|
||||
});
|
||||
|
||||
const coverProfileSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
@@ -44,6 +60,11 @@ const coverProfileSchema = z.object({
|
||||
camp: coverCampSchema.nullable().optional(),
|
||||
landmarks: z.array(coverLandmarkSchema).optional().default([]),
|
||||
playableNpcs: z.array(coverRoleSchema).optional().default([]),
|
||||
storyNpcs: z.array(coverRoleSchema).optional().default([]),
|
||||
sceneChapterBlueprints: z
|
||||
.array(coverSceneChapterSchema)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const customWorldCoverImageSchema = z.object({
|
||||
@@ -58,10 +79,26 @@ export const customWorldCoverUploadSchema = z.object({
|
||||
profileId: z.string().trim().optional().default(''),
|
||||
worldName: z.string().trim().optional().default(''),
|
||||
imageDataUrl: z.string().trim().min(1),
|
||||
cropRect: z.object({
|
||||
x: z.number().finite().min(0),
|
||||
y: z.number().finite().min(0),
|
||||
width: z.number().finite().positive(),
|
||||
height: z.number().finite().positive(),
|
||||
}),
|
||||
});
|
||||
|
||||
type CoverProfile = z.infer<typeof coverProfileSchema>;
|
||||
|
||||
const COVER_OUTPUT_WIDTH = 1600;
|
||||
const COVER_OUTPUT_HEIGHT = 900;
|
||||
const COVER_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const COVER_OUTPUT_MAX_BYTES = Math.floor(1.5 * 1024 * 1024);
|
||||
|
||||
type ParsedImageDataUrl = {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
function parseImageDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
if (!matched) {
|
||||
@@ -74,6 +111,160 @@ function parseImageDataUrl(source: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function clampCoverText(value: string, maxLength: number) {
|
||||
return value.trim().replace(/\s+/gu, ' ').slice(0, maxLength);
|
||||
}
|
||||
|
||||
function resolveOpeningAct(profile: CoverProfile) {
|
||||
return profile.sceneChapterBlueprints[0]?.acts[0] ?? null;
|
||||
}
|
||||
|
||||
function collectCoverReferenceImageSrcs(
|
||||
profile: CoverProfile,
|
||||
requestedRoleIds: string[],
|
||||
explicitReferenceImageSrc: string,
|
||||
) {
|
||||
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
|
||||
const sceneImageSrc = clampCoverText(
|
||||
resolveOpeningAct(profile)?.backgroundImageSrc ?? '',
|
||||
240,
|
||||
);
|
||||
const roleImageSrcs = selectedRoles
|
||||
.map((role) => clampCoverText(role.imageSrc, 240))
|
||||
.filter(Boolean);
|
||||
const campImageSrc = clampCoverText(profile.camp?.imageSrc ?? '', 240);
|
||||
const landmarkImageSrc = profile.landmarks
|
||||
.map((landmark) => clampCoverText(landmark.imageSrc, 240))
|
||||
.filter(Boolean)[0] ?? '';
|
||||
|
||||
return [
|
||||
clampCoverText(explicitReferenceImageSrc, 240),
|
||||
sceneImageSrc,
|
||||
...roleImageSrcs,
|
||||
campImageSrc,
|
||||
landmarkImageSrc,
|
||||
].filter(
|
||||
(source) =>
|
||||
Boolean(source) && (source.startsWith('/') || source.startsWith('data:')),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCoverPromptContext(profile: CoverProfile, requestedRoleIds: string[]) {
|
||||
const openingAct = resolveOpeningAct(profile);
|
||||
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
|
||||
const roleSummary = selectedRoles
|
||||
.map((role) =>
|
||||
[
|
||||
clampCoverText(role.name, 18),
|
||||
clampCoverText(role.title || role.role, 24),
|
||||
clampCoverText(role.description, 72),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
const storyRoleSummary = profile.storyNpcs
|
||||
.slice(0, 4)
|
||||
.map((role) =>
|
||||
[clampCoverText(role.name, 18), clampCoverText(role.title || role.role, 24)]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
|
||||
return {
|
||||
openingActTitle: clampCoverText(openingAct?.title ?? '', 24),
|
||||
openingActSummary: clampCoverText(openingAct?.summary ?? '', 96),
|
||||
roleSummary,
|
||||
storyRoleSummary,
|
||||
landmarkSummary: profile.landmarks
|
||||
.slice(0, 3)
|
||||
.map((landmark) =>
|
||||
[
|
||||
clampCoverText(landmark.name, 18),
|
||||
clampCoverText(landmark.description, 72),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
};
|
||||
}
|
||||
|
||||
async function optimizeUploadedCoverImage(
|
||||
parsedDataUrl: ParsedImageDataUrl,
|
||||
cropRect: z.infer<typeof customWorldCoverUploadSchema>['cropRect'],
|
||||
) {
|
||||
if (parsedDataUrl.buffer.byteLength > COVER_UPLOAD_MAX_BYTES) {
|
||||
throw badRequest('上传封面原图不能超过 10 MB。');
|
||||
}
|
||||
|
||||
const image = sharp(parsedDataUrl.buffer, { failOn: 'none' });
|
||||
const metadata = await image.metadata();
|
||||
const sourceWidth = metadata.width ?? 0;
|
||||
const sourceHeight = metadata.height ?? 0;
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
throw badRequest('无法解析上传封面的尺寸。');
|
||||
}
|
||||
|
||||
const normalizedCrop = {
|
||||
left: Math.max(0, Math.min(sourceWidth - 1, Math.floor(cropRect.x))),
|
||||
top: Math.max(0, Math.min(sourceHeight - 1, Math.floor(cropRect.y))),
|
||||
width: Math.max(1, Math.min(sourceWidth, Math.floor(cropRect.width))),
|
||||
height: Math.max(1, Math.min(sourceHeight, Math.floor(cropRect.height))),
|
||||
};
|
||||
normalizedCrop.width = Math.min(
|
||||
normalizedCrop.width,
|
||||
sourceWidth - normalizedCrop.left,
|
||||
);
|
||||
normalizedCrop.height = Math.min(
|
||||
normalizedCrop.height,
|
||||
sourceHeight - normalizedCrop.top,
|
||||
);
|
||||
|
||||
if (
|
||||
normalizedCrop.width <= 0 ||
|
||||
normalizedCrop.height <= 0 ||
|
||||
normalizedCrop.width / normalizedCrop.height < 1.7 ||
|
||||
normalizedCrop.width / normalizedCrop.height > 1.8
|
||||
) {
|
||||
throw badRequest('上传封面裁剪区域必须保持 16:9。');
|
||||
}
|
||||
|
||||
const encodeWithQuality = async (quality: number) =>
|
||||
image
|
||||
.extract(normalizedCrop)
|
||||
.resize(COVER_OUTPUT_WIDTH, COVER_OUTPUT_HEIGHT, {
|
||||
fit: 'cover',
|
||||
position: 'centre',
|
||||
})
|
||||
.webp({ quality, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
let optimizedBuffer = await encodeWithQuality(90);
|
||||
for (
|
||||
let quality = 84;
|
||||
optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES && quality >= 60;
|
||||
quality -= 8
|
||||
) {
|
||||
optimizedBuffer = await encodeWithQuality(quality);
|
||||
}
|
||||
|
||||
if (optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES) {
|
||||
throw badRequest('上传封面压缩后仍超过体积限制,请缩小裁剪范围或更换图片。');
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: optimizedBuffer,
|
||||
mimeType: 'image/webp',
|
||||
extension: 'webp',
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) {
|
||||
const trimmedSource = source.trim();
|
||||
if (!trimmedSource) {
|
||||
@@ -207,15 +398,7 @@ function buildCustomWorldCoverImagePrompt(
|
||||
} = {},
|
||||
) {
|
||||
const openingScene = profile.camp ?? profile.landmarks[0] ?? null;
|
||||
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
|
||||
const roleSummary = selectedRoles
|
||||
.map((role) =>
|
||||
[role.name, role.title || role.role, role.description]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
const promptContext = buildCoverPromptContext(profile, requestedRoleIds);
|
||||
|
||||
return [
|
||||
'为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。',
|
||||
@@ -231,9 +414,13 @@ function buildCustomWorldCoverImagePrompt(
|
||||
profile.summary ? `世界概述:${profile.summary}。` : '',
|
||||
profile.tone ? `整体基调:${profile.tone}。` : '',
|
||||
profile.playerGoal ? `主线目标:${profile.playerGoal}。` : '',
|
||||
promptContext.openingActTitle ? `开局第一幕标题:${promptContext.openingActTitle}。` : '',
|
||||
promptContext.openingActSummary ? `开局第一幕摘要:${promptContext.openingActSummary}。` : '',
|
||||
openingScene?.name ? `开局场景:${openingScene.name}。` : '',
|
||||
openingScene?.description ? `场景描述:${openingScene.description}。` : '',
|
||||
roleSummary ? `需要出现的角色主形象:${roleSummary}。` : '',
|
||||
promptContext.landmarkSummary ? `关键场景素材:${promptContext.landmarkSummary}。` : '',
|
||||
promptContext.roleSummary ? `需要出现的角色主形象:${promptContext.roleSummary}。` : '',
|
||||
promptContext.storyRoleSummary ? `可辅助参考的场景角色:${promptContext.storyRoleSummary}。` : '',
|
||||
userPrompt ? `额外要求:${userPrompt}。` : '',
|
||||
'整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。',
|
||||
]
|
||||
@@ -286,7 +473,7 @@ async function createCoverImageFromReference(params: {
|
||||
apiKey: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
referenceImage: string;
|
||||
referenceImages: string[];
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`${params.baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
@@ -303,7 +490,7 @@ async function createCoverImageFromReference(params: {
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ image: params.referenceImage },
|
||||
...params.referenceImages.map((image) => ({ image })),
|
||||
{ text: params.prompt },
|
||||
],
|
||||
},
|
||||
@@ -419,11 +606,10 @@ export async function uploadCustomWorldCoverImage(
|
||||
throw badRequest('上传封面必须是有效图片 Data URL。');
|
||||
}
|
||||
|
||||
const extension = parsedDataUrl.mimeType.includes('png')
|
||||
? 'png'
|
||||
: parsedDataUrl.mimeType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const optimizedImage = await optimizeUploadedCoverImage(
|
||||
parsedDataUrl,
|
||||
payload.cropRect,
|
||||
);
|
||||
const assetId = `custom-cover-upload-${Date.now()}`;
|
||||
const worldSegment = sanitizeSegment(
|
||||
payload.profileId || payload.worldName,
|
||||
@@ -436,8 +622,8 @@ export async function uploadCustomWorldCoverImage(
|
||||
);
|
||||
const outputDir = path.join(context.config.publicDir, relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `cover.${extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), parsedDataUrl.buffer);
|
||||
const fileName = `cover.${optimizedImage.extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), optimizedImage.buffer);
|
||||
|
||||
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
|
||||
fs.writeFileSync(
|
||||
@@ -447,6 +633,8 @@ export async function uploadCustomWorldCoverImage(
|
||||
assetId,
|
||||
sourceType: 'uploaded',
|
||||
imageSrc,
|
||||
size: `${COVER_OUTPUT_WIDTH}*${COVER_OUTPUT_HEIGHT}`,
|
||||
outputBytes: optimizedImage.buffer.byteLength,
|
||||
worldName: payload.worldName,
|
||||
profileId: payload.profileId,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -468,29 +656,33 @@ export async function generateCustomWorldCoverImage(
|
||||
input: z.infer<typeof customWorldCoverImageSchema>,
|
||||
) {
|
||||
const payload = customWorldCoverImageSchema.parse(input);
|
||||
const referenceImageSources = collectCoverReferenceImageSrcs(
|
||||
payload.profile,
|
||||
payload.characterRoleIds,
|
||||
payload.referenceImageSrc,
|
||||
).slice(0, 6);
|
||||
const prompt = buildCustomWorldCoverImagePrompt(
|
||||
payload.profile,
|
||||
payload.characterRoleIds,
|
||||
payload.userPrompt,
|
||||
{
|
||||
hasReferenceImage: Boolean(payload.referenceImageSrc.trim()),
|
||||
hasReferenceImage: referenceImageSources.length > 0,
|
||||
},
|
||||
);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc.trim()
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
const referenceImages = await Promise.all(
|
||||
referenceImageSources.map((source) =>
|
||||
resolveReferenceImageAsDataUrl(context.config.projectRoot, source),
|
||||
),
|
||||
);
|
||||
|
||||
if (referenceImage) {
|
||||
if (referenceImages.length > 0) {
|
||||
const referenceResult = await createCoverImageFromReference({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
prompt,
|
||||
size: payload.size,
|
||||
referenceImage,
|
||||
referenceImages,
|
||||
});
|
||||
|
||||
return saveGeneratedCoverAsset({
|
||||
|
||||
@@ -95,14 +95,17 @@ function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
|
||||
function resolveDraftCounts(session: CustomWorldAgentSessionRecord) {
|
||||
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
if (draftProfile) {
|
||||
return {
|
||||
playableNpcCount: [
|
||||
...new Set(
|
||||
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
// 草稿列表里的“角色”展示的是当前草稿中全部可编辑角色,而不是仅限可扮演角色。
|
||||
const totalRoleCount = [
|
||||
...new Set(
|
||||
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
].length,
|
||||
),
|
||||
].length;
|
||||
|
||||
return {
|
||||
playableNpcCount: totalRoleCount,
|
||||
landmarkCount: draftProfile.landmarks.length,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user