1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 21:06:48 +08:00
parent 1c72066bab
commit 75944b1f1f
102 changed files with 9648 additions and 1540 deletions

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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 >=

View File

@@ -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

View File

@@ -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-'),

View File

@@ -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) =>

View File

@@ -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,
};
}

View File

@@ -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 {

View File

@@ -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 编译入口。

View File

@@ -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 条下一轮可以直接说出口的中文接话短句。`,

View File

@@ -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');
});

View File

@@ -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;

View File

@@ -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),

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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),
),
);
});

View 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,
};
};
}
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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'),
};
}

View File

@@ -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,

View 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');
});

View File

@@ -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({

View File

@@ -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,
};
}