install.ps1 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. # 观澜·如东样板 v2 — Windows 离线安装
  2. # 用法: powershell -ExecutionPolicy Bypass -File install.ps1
  3. # ... -File install.ps1 -Python "C:\Python312\python.exe" # 多版本机器指定
  4. # ... -File install.ps1 -UseBundledPython # 强制用包内便携运行时
  5. #
  6. # 编码约定 (2026-09-10, 同事 xzy 实机报 ParserError 后定): 本文件必须存成 **UTF-8 带 BOM + CRLF**。
  7. # Windows PowerShell 5.1 对没有 BOM 的 .ps1 按 ANSI 代码页 (中文机 = GBK) 解码, 文件里的中文会被
  8. # 错读, 相邻的转义反引号可能被一起吞掉 → 引号不配对 → 级联 ParserError (报在看起来没问题的行上)。
  9. # install.bat 里的 chcp 65001 管不了这件事 (它改的是控制台代码页, 不是脚本文件的解码方式)。
  10. # 另: 本文件**不再使用** 形如 & cmd /c "\"$exe\" -m pip ..." 的嵌套引号写法 —— 一旦有字节被吃掉就崩; 改用
  11. # PowerShell 原生调用 & $exe arg1 arg2, 不需要任何引号转义。
  12. param([string]$Python = "", [switch]$UseBundledPython, [switch]$SkipOllama, [switch]$NoDesktopShortcut,
  13. [switch]$Service, [switch]$Force)
  14. $ErrorActionPreference = "Stop"; $env:PYTHONUTF8 = "1"
  15. # 机器上若全局设了 PYTHONPATH (例如指向 D:\Program Files\Python\Lib\site-packages), pip 会把那里的包
  16. # 当成 "已满足", 于是不装进 .venv —— venv 里就缺 urllib3 / polars / pyyaml / jinja2 等传递依赖,
  17. # 换个没设 PYTHONPATH 的 shell 立刻 import 失败; 且 PYTHONPATH 排在 venv 之前会顶掉 pin 住的版本。
  18. # 先把环境变量摘掉, 让下面 pip 老老实实按 requirements.txt 装进 .venv。
  19. Remove-Item Env:PYTHONPATH -ErrorAction SilentlyContinue
  20. Set-Location $PSScriptRoot
  21. function Say($m){ Write-Host $m }
  22. Say "== 0/7 安装前检查: 是否已安装及版本差异 (用户令 2026-09-17)"
  23. # 版本单一真源 = 公共层的 version.py(模块化重构后它在 app_common\common\app_common_guanlan\);
  24. # ★2026-09-22 布局无关化: 原先只按 "src\version.py" 读字面量 —— 该路径现在是**兼容转发壳**(无 VERSION 行),
  25. # 于是回落到读 dist-manifest.json, 而 PS 5.1 的 Get-Content 默认按 ANSI 解码无 BOM 的 UTF-8 ⇒ 中文乱码、
  26. # ConvertFrom-Json 抛异常、安装中断。现在: ①按候选路径逐个找版本文件(新路径优先);
  27. # ②JSON 一律 -Encoding UTF8 读。
  28. $pkgVer = ""
  29. foreach ($vf in @("app_common\common\app_common_guanlan\version.py", "src\version.py")) {
  30. if (-not (Test-Path $vf)) { continue }
  31. $verLine = Select-String -Path $vf -Pattern "^VERSION\s*=\s*'([^']+)'" -ErrorAction SilentlyContinue | Select-Object -First 1
  32. if ($verLine) { $pkgVer = $verLine.Matches[0].Groups[1].Value; break }
  33. }
  34. if (-not $pkgVer -and (Test-Path "dist-manifest.json")) {
  35. $pkgVer = (Get-Content "dist-manifest.json" -Raw -Encoding UTF8 | ConvertFrom-Json).version
  36. }
  37. $prev = $null
  38. if (Test-Path "install-info.json") {
  39. try { $prev = Get-Content "install-info.json" -Raw -Encoding UTF8 | ConvertFrom-Json } catch { $prev = $null }
  40. }
  41. $prevVer = if ($prev) { [string]$prev.version } else { "" }
  42. if (-not $prev) {
  43. Say " 未检测到已安装的观澜(将全新安装 v$pkgVer)"
  44. } else {
  45. Say " 已安装: v$($prevVer)(装于 $($prev.installed_at); 运行方式 $($prev.mode))"
  46. if (-not $prevVer) {
  47. Say " [!] 旧安装没有版本记录 —— 本包 v$pkgVer 将按'重装/升级'处理(旧 .venv 会被重建)"
  48. } elseif ($prevVer -eq $pkgVer) {
  49. Say " [i] 版本相同(v$pkgVer): 重装会重建 .venv、覆盖程序文件; data/ 与 outputs/ 不受影响"
  50. } else {
  51. $a = [version]($prevVer -replace '[^0-9.]', ''); $b = [version]($pkgVer -replace '[^0-9.]', '')
  52. if ($a -lt $b) { Say " [!] 版本差异: v$prevVer -> v$pkgVer(升级)" }
  53. else { Say " [X] 版本差异: 已装 v$prevVer 比本包 v$pkgVer 更新 —— 用旧包覆盖新装通常不是你想要的, 请确认" }
  54. }
  55. if ($Force) { Say " (-Force: 跳过询问, 直接继续)" }
  56. elseif ($NoDesktopShortcut -or [Console]::IsInputRedirected) { Say " (非交互: 自动继续安装)" }
  57. else {
  58. $ans = Read-Host " 是否重新安装/覆盖? 输入 y 继续, 其它键退出"
  59. if ($ans -notmatch '^(y|Y|yes|YES)$') { Say " 已取消(未改任何文件)"; exit 0 }
  60. }
  61. }
  62. Say "== 1/7 选 Python (需 3.11+; 本项目依赖 pandas 3, 3.10 及以下没有轮子)"
  63. $py = $null
  64. if ($Python) { $py = $Python }
  65. elseif (-not $UseBundledPython) {
  66. # 列出机器上所有 Python, 选第一个 >=3.11 (多版本共存时不猜, 打印出来让人看见)
  67. $cands = @()
  68. foreach ($c in @("py -3.13","py -3.12","py -3.11","python","python3")) {
  69. $exe, $arg = $c.Split(" ", 2)
  70. try {
  71. $v = if ($arg) { & $exe $arg -c "import sys;print('%d.%d'%sys.version_info[:2])" 2>$null }
  72. else { & $exe -c "import sys;print('%d.%d'%sys.version_info[:2])" 2>$null }
  73. if ($LASTEXITCODE -eq 0 -and $v) { $cands += [pscustomobject]@{cmd=$c; exe=$exe; arg=$arg; ver=$v} }
  74. } catch {}
  75. }
  76. if ($cands) { Say (" 机器上的 Python: " + (($cands | ForEach-Object { "$($_.cmd)=$($_.ver)" }) -join ", ")) }
  77. $ok = $cands | Where-Object { [version]$_.ver -ge [version]"3.11" } | Select-Object -First 1
  78. if ($ok) { $py = $ok }
  79. }
  80. if (-not $py) {
  81. $rt = Get-ChildItem "vendor\python\cpython-3.12*windows*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
  82. if ($rt) {
  83. Say " 系统无 3.11+, 解包内便携运行时: $($rt.Name)"
  84. if (Test-Path runtime) { Remove-Item runtime -Recurse -Force }
  85. New-Item -ItemType Directory runtime | Out-Null
  86. tar -xzf $rt.FullName -C runtime # Windows 10 1803+ 自带 tar
  87. $py = [pscustomobject]@{ exe = (Resolve-Path "runtime\python\python.exe").Path; arg = "" }
  88. } else {
  89. Say " X 没有 3.11+ 的 Python, 包内也没有便携运行时。"
  90. Say " 办法一: 装 https://www.python.org/downloads/windows/ 的 3.12.x (勾 Add to PATH)"
  91. Say " 办法二: -Python <python.exe 的完整路径> 指定"
  92. exit 2
  93. }
  94. } elseif ($py -is [string]) {
  95. $py = [pscustomobject]@{ exe = $py; arg = "" }
  96. }
  97. Say " 使用: $($py.exe) $($py.arg)"
  98. Say "== 2/7 建虚拟环境 .venv"
  99. if ($py.arg) { & $py.exe $py.arg -m venv .venv } else { & $py.exe -m venv .venv }
  100. $vpy = (Resolve-Path ".\.venv\Scripts\python.exe").Path
  101. & $vpy -m pip install --upgrade pip --quiet
  102. Say "== 3/7 装依赖 (优先包内离线轮子 wheels\win_amd64)"
  103. if (Test-Path ".\wheels\win_amd64") { & $vpy -m pip install --no-index --find-links .\wheels\win_amd64 -r requirements.txt }
  104. else { Say " 包内无轮子, 改为联网安装"; & $vpy -m pip install -r requirements.txt }
  105. if ($LASTEXITCODE -ne 0) { Say " X 依赖安装失败 (上面有 pip 的报错) —— 先解决它, 别接着跑 start.bat"; exit 3 }
  106. Say "== 4/7 写配置"
  107. # 别改回 Get-Content / Set-Content -Encoding UTF8 (2026-09-10 实机踩过):
  108. # PS 5.1 的 Get-Content 把无 BOM 的 UTF-8 当 ANSI(GBK) 解码 -> 中文注释变乱码;
  109. # Set-Content -Encoding UTF8 又写出 BOM -> Python 侧用裸 utf-8 json.loads 直接 JSONDecodeError。
  110. # 交给 venv 里的 Python 读写: 读 utf-8-sig (有 BOM 也认), 写无 BOM 的 UTF-8。
  111. # 下面 Python 代码故意只用单引号, 避开 PS 5.1 传参时的引号转义坑。
  112. $cfgFix = @'
  113. import json, pathlib, sys
  114. p = pathlib.Path('configs/serve.json') # portability-allow: 安装脚本已 Set-Location 到安装根
  115. c = json.loads(p.read_text(encoding='utf-8-sig'))
  116. # 写**相对**路径 (相对安装根): 整个目录拷到别的电脑/别的盘后仍然有效 (绝对路径会在换机后失效)
  117. exe = pathlib.Path(sys.executable).resolve()
  118. root = pathlib.Path('.').resolve()
  119. try:
  120. c['python'] = exe.relative_to(root).as_posix()
  121. except ValueError:
  122. c['python'] = exe.as_posix()
  123. p.write_text(json.dumps(c, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  124. print(' configs/serve.json -> ' + c['python'] + ' (相对安装根)')
  125. '@
  126. & $vpy -c $cfgFix
  127. if ($LASTEXITCODE -ne 0) { Say " X 写 configs\serve.json 失败"; exit 4 }
  128. Say "== 5/7 建启动快捷方式 (无窗口; 不依赖 Windows 脚本宿主)"
  129. # 2026-09-16 用户令二 "把 VBScript 替换掉": 原先无窗口启动靠 start_hidden.vbs (WScript.Shell.Run 窗口样式 0),
  130. # 现在快捷方式直接指向 **pythonw.exe**(无控制台子系统的解释器) + 启动器脚本 —— 运行期一个窗口都没有,
  131. # 且不经过 wscript/cscript: WSH 被组策略禁用、或系统未安装 WSH 功能的机器上同样可用。
  132. # 注意: 这里是 PowerShell 自己的 COM 调用, 不是 .vbs 文件; 建不出来也不让安装失败 (提示手工建/用 start.bat)。
  133. $rootDir = (Get-Location).Path
  134. $pywPath = (Resolve-Path ".\.venv\Scripts\pythonw.exe").Path
  135. $launcher = Join-Path $rootDir "scripts\guanlan_start_hidden.py"
  136. function New-GuanlanShortcut([string]$Path) {
  137. $sh = New-Object -ComObject WScript.Shell
  138. $lnk = $sh.CreateShortcut($Path)
  139. $lnk.TargetPath = $pywPath
  140. $lnk.Arguments = '"' + $launcher + '"'
  141. $lnk.WorkingDirectory = $rootDir
  142. $lnk.WindowStyle = 7 # 最小化; pythonw 本身无窗口, 这里是双保险
  143. $lnk.Description = "观澜·如东样板 v2 - 无窗口启动 (pythonw.exe)"
  144. $ico = Join-Path $rootDir "resources\guanlan.ico"
  145. if (Test-Path $ico) { $lnk.IconLocation = $ico }
  146. $lnk.Save()
  147. }
  148. $made = @()
  149. try {
  150. # 中文名那份给人双击 (资源管理器里好认); ASCII 名那份给控制台/CI/脚本引用
  151. # (用户令 2 之后 .bat 一律纯 ASCII, 所以 start.bat 的帮助文字只能指向 ASCII 名 —— 2026-09-17)。
  152. foreach ($nm in @("启动观澜.lnk", "start_guanlan.lnk")) {
  153. $lnkRoot = Join-Path $rootDir $nm
  154. New-GuanlanShortcut $lnkRoot
  155. $made += $lnkRoot
  156. }
  157. try {
  158. if ($NoDesktopShortcut) {
  159. Say " (-NoDesktopShortcut: 跳过桌面快捷方式 —— 自动核验用, 免得在别人桌面上留一个指向临时目录的死链)"
  160. } else {
  161. $desk = [Environment]::GetFolderPath('Desktop')
  162. if ($desk) {
  163. $lnkDesk = Join-Path $desk "观澜·如东样板 v2.lnk"
  164. New-GuanlanShortcut $lnkDesk
  165. $made += $lnkDesk
  166. }
  167. }
  168. } catch { Say " (桌面快捷方式没建成, 不影响使用: $($_.Exception.Message))" }
  169. } catch {
  170. Say " [!] 快捷方式没建成: $($_.Exception.Message)"
  171. Say " 不影响使用: 双击 start.bat 一样起得来(会有一次极短的窗口闪烁);"
  172. Say " 或手工建: 目标填 $pywPath 参数填 `"$launcher`""
  173. }
  174. foreach ($m in $made) { Say " 已建: $m" }
  175. if ($made.Count -gt 0) { Say " * 双击这个快捷方式 = 完全不出现命令窗口 (start.bat 会有一次极短闪烁)" }
  176. Say "== 6/7 服务化 (可选; 用户令 2026-09-17)"
  177. if ($Service) {
  178. & $vpy scripts\service_ctl.py install
  179. if ($LASTEXITCODE -ne 0) {
  180. Say " [!] 服务注册没成功(退出码 $LASTEXITCODE) —— 不影响使用: 仍可双击快捷方式/start.bat 启动"
  181. Say " 需要管理员权限才能注册服务: 用【管理员】PowerShell 跑"
  182. Say " & $vpy scripts\service_ctl.py install"
  183. Say " 不想提权可改用登录自启: & $vpy scripts\service_ctl.py install --user-login"
  184. }
  185. } else {
  186. Say " (未指定 -Service: 跳过。要装成 Windows 服务: 管理员 PowerShell 里跑"
  187. Say " .venv\Scripts\python.exe scripts\service_ctl.py install"
  188. Say " 什么都不想提权、只要登录后自动起: scripts\service_ctl.py install --user-login)"
  189. Say " 先看它会做什么: scripts\service_ctl.py install --dry-run"
  190. }
  191. Say "== 7/7 Ollama (只影响问答与本地审核; 其余页面不受影响)"
  192. if (-not $SkipOllama) {
  193. $has = Get-Command ollama -ErrorAction SilentlyContinue
  194. if ($has) { Say " 已安装: $(& ollama -v 2>&1 | Select-Object -First 1)" }
  195. elseif (Test-Path "vendor\ollama\OllamaSetup.exe") {
  196. Say " 包内有离线安装包, 现在静默安装 (约 1.5 GB, 需几分钟)…"
  197. Start-Process -FilePath "vendor\ollama\OllamaSetup.exe" -ArgumentList "/SILENT" -Wait
  198. Say " 安装完成; 模型请把有网机器的 %USERPROFILE%\.ollama\models 整个目录拷到本机同位置"
  199. } else { Say " 未装且包内无离线包: 问答不可用。装法见 说明书 §5" }
  200. }
  201. # 写安装记录 (版本单一真源 src\version.py):安装检查与 guanlan.py check 都读它
  202. & $vpy -c "import sys,pathlib; sys.path.insert(0, str(pathlib.Path('.').resolve())); from src import version as V; print(' 安装记录: ' + str(V.write_installed(pathlib.Path('.').resolve(), python=sys.executable, mode=('windows-service' if '$Service' == 'True' else 'manual'))))"
  203. Say ""
  204. & $vpy guanlan.py check
  205. Say ""
  206. Say "安装完成. 启动: 双击「启动观澜」快捷方式 (无窗口) 或 start.bat; 停止: stop.bat; 自检: check.bat"
  207. Say " 入口: http://127.0.0.1:28084/"