@@ -20,6 +20,7 @@ const venvDir = path.join(projectRoot, 'python-env');
2020const bootstrapDir = path . join ( projectRoot , 'python-bootstrap' ) ;
2121const miniforgePrefix = path . join ( bootstrapDir , 'miniforge' ) ;
2222const requirementsPath = path . join ( projectRoot , 'requirements.txt' ) ;
23+ const pyprojectPath = path . join ( projectRoot , 'pyproject.toml' ) ;
2324
2425const isWin = process . platform === 'win32' ;
2526const isMac = process . platform === 'darwin' ;
@@ -92,6 +93,38 @@ function hasUv() {
9293 return res . success ;
9394}
9495
96+ function hasPyproject ( ) {
97+ return fs . existsSync ( pyprojectPath ) ;
98+ }
99+
100+ function pathExistsOrSymlink ( p ) {
101+ // fs.existsSync 会跟随 symlink;当 symlink 断链时会返回 false。
102+ // 但我们需要把“断链 symlink”也当作“路径占位存在”,否则创建目录会报 File exists。
103+ if ( fs . existsSync ( p ) ) return true ;
104+ try {
105+ fs . lstatSync ( p ) ;
106+ return true ;
107+ } catch {
108+ return false ;
109+ }
110+ }
111+
112+ function removeIfBrokenSymlink ( p ) {
113+ try {
114+ const stat = fs . lstatSync ( p ) ;
115+ if ( ! stat . isSymbolicLink ( ) ) return false ;
116+ // 如果是断链 symlink:existsSync 会是 false;这时应删除 link 本身
117+ if ( ! fs . existsSync ( p ) ) {
118+ console . warn ( `[prepare-python-env] Detected broken symlink, removing: ${ p } ` ) ;
119+ fs . unlinkSync ( p ) ;
120+ return true ;
121+ }
122+ return false ;
123+ } catch {
124+ return false ;
125+ }
126+ }
127+
95128function bootstrapMiniforge ( ) {
96129 if ( process . platform !== 'darwin' ) {
97130 throw new Error (
@@ -144,6 +177,9 @@ function bootstrapMiniforge() {
144177}
145178
146179function ensureCondaEnv ( miniforgePython , { forceRebuild = false } = { } ) {
180+ // 兼容:venvDir 可能是断链 symlink(例如 python-env -> .venv 且 .venv 不存在)
181+ removeIfBrokenSymlink ( venvDir ) ;
182+
147183 let condaBin = isWin
148184 ? path . join ( miniforgePrefix , 'Scripts' , 'conda.exe' )
149185 : path . join ( miniforgePrefix , 'bin' , 'conda' ) ;
@@ -185,6 +221,9 @@ function ensureCondaEnv(miniforgePython, { forceRebuild = false } = {}) {
185221}
186222
187223function ensureVenv ( pythonCmd , { forceRebuild = false } = { } ) {
224+ // 兼容:venvDir 可能是断链 symlink(例如 python-env -> .venv 且 .venv 不存在)
225+ removeIfBrokenSymlink ( venvDir ) ;
226+
188227 if ( fs . existsSync ( venvDir ) && forceRebuild ) {
189228 console . log ( `[prepare-python-env] removing existing venv for rebuild: ${ venvDir } ` ) ;
190229 fs . rmSync ( venvDir , { recursive : true , force : true } ) ;
@@ -310,7 +349,10 @@ function packCondaEnv() {
310349
311350function ensureUvVenv ( pythonSpec , { forceRebuild = false , relocatable = false } = { } ) {
312351 const useRelocatable = relocatable ? '--relocatable' : '' ;
313- if ( fs . existsSync ( venvDir ) && ( forceRebuild || ! isVenvPrefix ( venvDir ) ) ) {
352+ // 兼容:venvDir 可能是断链 symlink(例如 python-env -> .venv 且 .venv 不存在)
353+ removeIfBrokenSymlink ( venvDir ) ;
354+
355+ if ( pathExistsOrSymlink ( venvDir ) && ( forceRebuild || ! isVenvPrefix ( venvDir ) ) ) {
314356 console . log ( `[prepare-python-env] (uv) clearing existing env: ${ venvDir } ` ) ;
315357 // uv venv --clear 会清理目标目录;这里也兼容“目录存在但不是 venv”的情况
316358 run ( `uv venv "${ venvDir } " --clear ${ useRelocatable } --python "${ pythonSpec } " --managed-python` ) ;
@@ -333,6 +375,69 @@ function installDepsWithUv() {
333375 run ( `uv pip install -r "${ requirementsPath } " -p "${ pythonPath } "` ) ;
334376}
335377
378+ /**
379+ * 使用 uv sync(pyproject.toml 模式)一次性创建 venv 并安装依赖
380+ * 比 uv venv + uv pip install 更简洁、更可靠
381+ */
382+ function ensureUvSync ( { forceRebuild = false , pythonSpec = desiredPy } = { } ) {
383+ // 清理断链 symlink
384+ removeIfBrokenSymlink ( venvDir ) ;
385+
386+ if ( forceRebuild && pathExistsOrSymlink ( venvDir ) ) {
387+ console . log ( `[prepare-python-env] (uv sync) removing existing env for rebuild: ${ venvDir } ` ) ;
388+ fs . rmSync ( venvDir , { recursive : true , force : true } ) ;
389+ }
390+
391+ console . log ( `[prepare-python-env] (uv sync) syncing project (python=${ pythonSpec } ) ...` ) ;
392+ // uv sync 会自动:创建 venv(如不存在)、解析 pyproject.toml、安装依赖
393+ // --python 指定版本,--managed-python 让 uv 自动下载对应版本
394+ // UV_PROJECT_ENVIRONMENT 指定 venv 路径(默认是 .venv,我们改成 python-env)
395+ run ( `uv sync --python "${ pythonSpec } " --managed-python` , {
396+ cwd : projectRoot ,
397+ env : { ...process . env , UV_PROJECT_ENVIRONMENT : venvDir } ,
398+ } ) ;
399+ }
400+
401+ /**
402+ * Patch funasr_onnx/__init__.py,移除对 torch 的无用依赖(兼容层)
403+ * funasr_onnx >= 0.3 的 __init__.py 无条件导入了 sensevoice_bin(需要 torch),
404+ * 但本项目不使用 SenseVoice。当前 pyproject.toml 锁定 funasr-onnx < 0.3 以避免此问题,
405+ * 此 patch 仅作为兼容层,以防未来升级。
406+ */
407+ function patchFunasrOnnx ( ) {
408+ const sitePackages = path . join ( venvDir , isWin ? 'Lib/site-packages' : 'lib/python' + desiredPy + '/site-packages' ) ;
409+ const initFile = path . join ( sitePackages , 'funasr_onnx' , '__init__.py' ) ;
410+
411+ if ( ! fs . existsSync ( initFile ) ) {
412+ console . log ( '[prepare-python-env] funasr_onnx not found, skipping patch' ) ;
413+ return ;
414+ }
415+
416+ let content = fs . readFileSync ( initFile , 'utf-8' ) ;
417+
418+ // 检查是否已经 patch 过
419+ if ( content . includes ( '# PATCHED: sensevoice_bin' ) ) {
420+ console . log ( '[prepare-python-env] funasr_onnx already patched' ) ;
421+ return ;
422+ }
423+
424+ // 替换 sensevoice_bin 的导入为 try/except
425+ const originalImport = 'from .sensevoice_bin import SenseVoiceSmall' ;
426+ const patchedImport = `# PATCHED: sensevoice_bin requires torch, but we don't use SenseVoice
427+ try:
428+ from .sensevoice_bin import SenseVoiceSmall
429+ except ImportError:
430+ SenseVoiceSmall = None` ;
431+
432+ if ( content . includes ( originalImport ) ) {
433+ content = content . replace ( originalImport , patchedImport ) ;
434+ fs . writeFileSync ( initFile , content ) ;
435+ console . log ( '[prepare-python-env] patched funasr_onnx to skip torch dependency' ) ;
436+ } else {
437+ console . log ( '[prepare-python-env] funasr_onnx import pattern not found, skipping patch' ) ;
438+ }
439+ }
440+
336441/**
337442 * 修复 venv 中的 Python 符号链接为实际文件副本
338443 * venv 创建的符号链接是绝对路径,打包后在其他机器上会失效
@@ -398,10 +503,20 @@ function main() {
398503 // - dev: 默认用 uv(若可用)创建 venv 并安装依赖,避免 Miniforge+conda-pack 的笨重流程
399504 // - bundle: 默认使用 conda env + conda-pack,确保可搬运(打包发布)
400505 if ( useUv ) {
401- // dev 默认不需要 relocatable;bundle 模式下允许用 uv 的 relocatable venv(实验性)
402- ensureUvVenv ( desiredPy , { forceRebuild, relocatable : useBundle } ) ;
403- installDepsWithUv ( ) ;
404- fixPythonSymlinks ( ) ;
506+ // 优先使用 pyproject.toml + uv sync(更现代、更干净)
507+ if ( hasPyproject ( ) ) {
508+ ensureUvSync ( { forceRebuild, pythonSpec : desiredPy } ) ;
509+ } else {
510+ // 回退:uv venv + uv pip install(兼容只有 requirements.txt 的场景)
511+ ensureUvVenv ( desiredPy , { forceRebuild, relocatable : useBundle } ) ;
512+ installDepsWithUv ( ) ;
513+ }
514+ // patch funasr_onnx 以移除对 torch 的无用依赖
515+ patchFunasrOnnx ( ) ;
516+ // 只在 bundle 模式下 fix symlinks(开发模式保持 symlink 指向 uv managed python)
517+ if ( useBundle ) {
518+ fixPythonSymlinks ( ) ;
519+ }
405520 } else if ( useBundle ) {
406521 const updatedPy = ensureCondaEnv ( pythonCmd , { forceRebuild } ) ;
407522 if ( updatedPy ) {
0 commit comments