Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ Makefile text eol=lf
# 后端源码会 bind-mount 进 Linux 容器运行
*.py text eol=lf

# 桌面依赖锁的内容哈希必须在 Windows / Linux 检出时保持一致
requirements-desktop.txt text eol=lf
requirements-desktop-windows-py311.lock text eol=lf

# Windows 专用脚本保持 CRLF
*.bat text eol=crlf
*.cmd text eol=crlf
Expand Down
32 changes: 30 additions & 2 deletions desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,30 @@ HUGAGENT_SERVER_BASE=https://你的后端 npm run dev
> Linux 仍只构建桌面前端,不携带 Windows 本机服务载荷;dev 模式从仓库内
> `src/frontend/dist` 读取静态资源。

### Windows 本机服务依赖

Windows 本机模式使用独立的 Python 3.11 依赖档案,避免把容器部署专用的
PostgreSQL、云存储和远程沙箱 SDK 安装到最终用户环境:

- `requirements-desktop.txt` 只声明 SQLite、local storage 和 host script runner
所需的直接依赖。
- `requirements-desktop-windows-py311.lock` 锁定 Windows x86_64 / CPython 3.11
的完整传递依赖。安装器通过一次 `uv pip sync` 同步环境,不在用户机器上重新
解析浮动版本或构建源码包。
- `desktop-bundle.json` 的 `dependency_fingerprint` 标识依赖内容。客户端更新只
修改源码或前端、且指纹不变时,安装器复用现有 venv,只重新注册本机服务包。

修改桌面依赖后,必须在仓库根目录重新生成并提交锁文件:

```bash
npm --prefix desktop run lock:windows
```

构建脚本会校验锁文件内的输入 SHA-256;锁文件过期时会在耗时的前端构建前失败。
Windows 首装默认优先使用阿里云 PyPI 镜像,镜像不可用或缺少锁定版本时使用同一
份 uv 缓存重试官方 PyPI。内网部署可在启动客户端前设置
`HUGAGENT_PYPI_INDEX_URL`,覆盖为组织自己的 PEP 503 兼容镜像地址。

正式发版前需确保工作区干净,并在 Windows PowerShell 设置
`$env:HUGAGENT_RELEASE_BUILD="1"`;此时 CE 生成器不会接受 `--allow-dirty`。版本号必须同时更新
`package.json`、`src-tauri/tauri.conf.json`、`src-tauri/Cargo.toml`(本机服务从 `0.2.0` 起提供),
Expand All @@ -145,6 +169,9 @@ HUGAGENT_SERVER_BASE=https://你的后端 npm run dev
| `resources/server-bootstrap/install-local-server.sh` | macOS 应用数据目录内准备独立 Python 运行时并安装随包 CE 服务 |
| `scripts/prepare-bundle.mjs` | 发行构建前生成同版本 CE 服务资源、清单和单文件 ZIP 载荷 |
| `scripts/create-ce-archive.py` | 以稳定顺序把 CE 服务树压缩成 `server-ce.zip` |
| `../requirements-desktop.txt` | 桌面本机服务专用的跨平台直接依赖档案 |
| `../requirements-desktop-windows-py311.lock` | Windows x86_64 / CPython 3.11 精确依赖锁 |
| `scripts/generate-windows-requirements-lock.mjs` | 从桌面依赖档案重新生成并标记 Windows 锁文件 |
| `scripts/ce-payload.mjs` | 在派生 CE 仓校验版本标识并只暂存 tracked tree,源代码仓仍走生成器 |
| `scripts/validate-release-version.mjs` | CI 三平台矩阵启动前校验桌面版本文件与 release tag |
| `src-tauri/capabilities/default.json` | 插件权限(opener / deep-link / notification / global-shortcut / updater) |
Expand Down Expand Up @@ -251,8 +278,9 @@ npm run build

- **Windows 包**仍需在 Windows 侧构建(Tauri 不支持交叉编译);**Linux 包在装好 Rust 的
Linux / WSL 环境可直接构建**(apt 依赖见上)。
- Windows 本机服务首次安装需要联网下载 Python wheels。未安装 Python 3.11+ 时,引导脚本会
优先用 `winget` 为当前用户静默安装;系统同时缺少 Python 和 `winget` 时,进度页会给出可重试错误。
- Windows 本机服务首次安装需要联网下载锁定的 Python wheels。安装器固定使用 Python 3.11;
未安装时会优先用 `winget` 为当前用户静默安装。系统同时缺少 Python 3.11 和 `winget` 时,
进度页会给出可重试错误。
Node.js 20+ 缺失时也会尝试通过 `winget` 补齐;这一步失败不阻断核心服务,但 React 建站和高级
PDF 渲染会保持降级状态。
- macOS 本机服务首次安装会在应用数据目录下载独立的 `uv` 和 Python 3.11,不修改系统 Python
Expand Down
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"dev": "tauri dev",
"build": "tauri build",
"test:scripts": "node --test scripts/*.test.mjs",
"lock:windows": "node scripts/generate-windows-requirements-lock.mjs",
"version:desktop": "node scripts/set-desktop-version.mjs"
},
"devDependencies": {
Expand Down
167 changes: 112 additions & 55 deletions desktop/resources/server-bootstrap/install-local-server.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function Start-FastDirectoryCleanup {
function Test-PythonCandidate {
param([string]$Executable, [string[]]$PrefixArguments)
try {
& $Executable @PrefixArguments -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null
& $Executable @PrefixArguments -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 11) else 1)" 2>$null
return $LASTEXITCODE -eq 0
}
catch {
Expand All @@ -81,7 +81,7 @@ function Resolve-Python {
$candidates = @()
$pyLauncher = Get-Command "py.exe" -ErrorAction SilentlyContinue
if ($pyLauncher) {
$candidates += [PSCustomObject]@{ Executable = $pyLauncher.Source; Prefix = @("-3") }
$candidates += [PSCustomObject]@{ Executable = $pyLauncher.Source; Prefix = @("-3.11") }
}
foreach ($name in @("python.exe", "python3.exe")) {
$command = Get-Command $name -ErrorAction SilentlyContinue
Expand Down Expand Up @@ -172,6 +172,34 @@ function Resolve-Bash {
return $null
}

function Resolve-PythonPackageIndex {
$Configured = [string]$env:HUGAGENT_PYPI_INDEX_URL
if ($Configured) {
return [PSCustomObject]@{
Url = $Configured.TrimEnd('/')
Name = "configured mirror"
AllowOfficialFallback = $false
}
}

$DomesticMirror = "https://mirrors.aliyun.com/pypi/simple"
try {
Invoke-WebRequest -Uri "$DomesticMirror/uv/" -Method Head -TimeoutSec 6 -UseBasicParsing | Out-Null
return [PSCustomObject]@{
Url = $DomesticMirror
Name = "Alibaba Cloud mirror"
AllowOfficialFallback = $true
}
}
catch {
return [PSCustomObject]@{
Url = "https://pypi.org/simple"
Name = "official PyPI"
AllowOfficialFallback = $false
}
}
}

if (-not (Test-Path -LiteralPath $BundleArchive -PathType Leaf)) {
throw "The desktop package doesn't contain the CE server archive."
}
Expand All @@ -187,6 +215,23 @@ $NodeDataDir = Join-Path $RuntimeRoot "node"
$InstalledManifest = Join-Path $RuntimeRoot "installed-bundle.json"
New-Item -ItemType Directory -Path $RuntimeRoot -Force | Out-Null

function Read-DependencyFingerprint {
param([string]$ManifestPath)
if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) {
return ""
}
try {
$Manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
return [string]$Manifest.dependency_fingerprint
}
catch {
return ""
}
}

$BundledDependencyFingerprint = Read-DependencyFingerprint $BundleManifest
$InstalledDependencyFingerprint = Read-DependencyFingerprint $InstalledManifest

Write-ProgressLine 5 "正在解压同版本服务端资源…"
$StagedSource = Join-Path $RuntimeRoot "source.next-$([Guid]::NewGuid().ToString('N'))"
New-Item -ItemType Directory -Path $StagedSource -Force | Out-Null
Expand All @@ -200,8 +245,11 @@ try {
if (-not (Test-Path -LiteralPath (Join-Path $StagedSource "src\frontend\dist\index.html") -PathType Leaf)) {
throw "The bundled CE web application is missing."
}
if (-not (Test-Path -LiteralPath (Join-Path $StagedSource "requirements-mem0.txt") -PathType Leaf)) {
throw "The desktop package doesn't contain the persistent-memory dependencies."
if (-not (Test-Path -LiteralPath (Join-Path $StagedSource "requirements-desktop.txt") -PathType Leaf)) {
throw "The desktop package doesn't contain its local-server dependency profile."
}
if (-not (Test-Path -LiteralPath (Join-Path $StagedSource "requirements-desktop-windows-py311.lock") -PathType Leaf)) {
throw "The desktop package doesn't contain its Windows Python 3.11 dependency lock."
}
if (Test-Path -LiteralPath $SourceDir) {
$PreviousSource = Move-DirectoryToCleanup $SourceDir
Expand All @@ -227,7 +275,7 @@ $Python = Resolve-Python
if (-not $Python) {
$Winget = Get-Command "winget.exe" -ErrorAction SilentlyContinue
if (-not $Winget) {
throw "Python 3.11+ isn't installed, and Windows Package Manager (winget) isn't available. Install Python 3.11 and retry."
throw "Python 3.11 isn't installed, and Windows Package Manager (winget) isn't available. Install Python 3.11 and retry."
}
Write-ProgressLine 16 "正在为当前用户安装 Python 3.11…"
Invoke-Checked $Winget.Source @(
Expand All @@ -244,7 +292,7 @@ Write-Output "Using Python: $($Python.Executable)"
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
$RebuildVenv = $true
if (Test-Path $VenvPython) {
& $VenvPython -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null
& $VenvPython -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 11) else 1)" 2>$null
$RebuildVenv = $LASTEXITCODE -ne 0
if (-not $RebuildVenv) {
& $VenvPython -m pip --version 2>$null | Out-Null
Expand All @@ -260,58 +308,66 @@ if ($RebuildVenv) {
Invoke-Checked $Python.Executable $VenvArguments "Unable to create the Python virtual environment"
}

Write-ProgressLine 32 "正在更新 Python 安装工具…"
Invoke-Checked $VenvPython @("-m", "pip", "install", "--disable-pip-version-check", "--upgrade", "pip", "setuptools", "wheel") "Unable to prepare pip"

# uv 并行下载 + 快速解析,同网络下比原生 pip 快数倍(macOS 引导已用 uv,这里对齐)。
# uv 装不上或安装中途失败都自动回退 pip,不会因此阻塞整个安装。
$WindowsRequirementsLock = Join-Path $SourceDir "requirements-desktop-windows-py311.lock"
$VenvUv = Join-Path $VenvDir "Scripts\uv.exe"
try {
& $VenvPython -m pip install --disable-pip-version-check --upgrade uv
if ($LASTEXITCODE -ne 0) {
Write-Warning "uv couldn't be installed; dependency installs fall back to pip."
$DependenciesChanged = $RebuildVenv -or `
-not (Test-Path -LiteralPath $VenvUv -PathType Leaf) -or `
-not $BundledDependencyFingerprint -or `
$BundledDependencyFingerprint -ne $InstalledDependencyFingerprint

if ($DependenciesChanged) {
Write-ProgressLine 32 "正在准备锁定的 Python 3.11 运行环境…"
$PythonPackageIndex = Resolve-PythonPackageIndex
Write-Output "Using Python package index: $($PythonPackageIndex.Name)"
$UvRequirement = Get-Content -LiteralPath $WindowsRequirementsLock | Where-Object {
$_ -match '^uv==[0-9]+\.[0-9]+\.[0-9]+$'
} | Select-Object -First 1
if (-not $UvRequirement) {
throw "The Windows dependency lock doesn't contain an exact uv version."
}
$UvPackage = $UvRequirement.Trim()
& $VenvPython -m pip install --disable-pip-version-check `
--index-url $PythonPackageIndex.Url $UvPackage
if ($LASTEXITCODE -ne 0 -and $PythonPackageIndex.AllowOfficialFallback) {
Write-Warning "The domestic Python mirror couldn't provide uv; retrying official PyPI."
Invoke-Checked $VenvPython @(
"-m", "pip", "install", "--disable-pip-version-check",
"--index-url", "https://pypi.org/simple", $UvPackage
) "Unable to prepare the locked dependency installer"
}
elseif ($LASTEXITCODE -ne 0) {
throw "Unable to prepare the locked dependency installer (exit code $LASTEXITCODE)"
}

# Keep the build/cache prefix short. Some source distributions contain
# deeply nested paths and otherwise exceed MAX_PATH before wheel creation.
$env:UV_CACHE_DIR = Join-Path $env:LOCALAPPDATA "desktop-uv"
$env:UV_HTTP_RETRIES = "5"

Write-ProgressLine 42 "正在同步锁定的桌面运行依赖,首次安装需要数分钟…"
$SyncArguments = @(
"pip", "sync", "--python", $VenvPython,
"--only-binary", ":all:",
"--default-index", $PythonPackageIndex.Url,
$WindowsRequirementsLock
)
& $VenvUv @SyncArguments
if ($LASTEXITCODE -ne 0 -and $PythonPackageIndex.AllowOfficialFallback) {
Write-Warning "The domestic Python mirror is incomplete or unavailable; retrying the same locked sync with official PyPI."
Invoke-Checked $VenvUv @(
"pip", "sync", "--python", $VenvPython,
"--only-binary", ":all:",
"--default-index", "https://pypi.org/simple",
$WindowsRequirementsLock
) "Unable to synchronize the locked desktop dependencies"
}
elseif ($LASTEXITCODE -ne 0) {
throw "Unable to synchronize the locked desktop dependencies (exit code $LASTEXITCODE)"
}
}
catch {
Write-Warning "uv couldn't be installed; dependency installs fall back to pip. $($_.Exception.Message)"
else {
Write-ProgressLine 70 "Python 依赖未变化,复用现有运行环境…"
}
$env:UV_CACHE_DIR = Join-Path $RuntimeRoot "cache\uv"
$env:UV_HTTP_RETRIES = "5"

function Install-PythonPackages {
param([string[]]$InstallArguments, [string]$FailureMessage)
if (Test-Path $VenvUv) {
& $VenvUv pip install --python $VenvPython @InstallArguments
if ($LASTEXITCODE -eq 0) {
return
}
Write-Warning "uv install failed (exit code $LASTEXITCODE); retrying with pip."
}
Invoke-Checked $VenvPython (
@("-m", "pip", "install", "--disable-pip-version-check", "--prefer-binary") + $InstallArguments
) $FailureMessage
}

Write-ProgressLine 42 "正在安装服务端依赖,首次安装需要数分钟…"
Install-PythonPackages @(
"-r", (Join-Path $SourceDir "requirements.txt")
) "Unable to install the server dependencies"

Write-ProgressLine 58 "正在安装永久记忆运行环境…"
Install-PythonPackages @(
"--upgrade",
"-r", (Join-Path $SourceDir "requirements-mem0.txt"),
"protobuf<7", "pymilvus==2.5.18", "milvus-lite==3.1.0"
) "Unable to install the persistent-memory dependencies"

Write-ProgressLine 70 "正在安装本机脚本与文档处理能力…"
# requirements-mcp.txt 必须显式列入:图表/报告 MCP(matplotlib、python-docx)在
# 容器部署里装在独立 mcp 镜像,本机模式与 script-runner 共用同一 venv——不能
# 依赖 script-runner 清单恰好覆盖它。
Install-PythonPackages @(
"-r", (Join-Path $SourceDir "docker\requirements-script-runner.txt"),
"-r", (Join-Path $SourceDir "docker\requirements-mcp.txt")
) "Unable to install the local tool dependencies"

Write-ProgressLine 75 "正在准备本机 Bash 脚本能力…"
$BashExecutableFile = Join-Path $RuntimeRoot "bash-executable.txt"
Expand Down Expand Up @@ -484,7 +540,8 @@ elseif (Test-Path $NodeExecutableFile) {
}

Write-ProgressLine 86 "正在注册 HugAgentOS 本机服务…"
Install-PythonPackages @(
Invoke-Checked $VenvUv @(
"pip", "install", "--python", $VenvPython,
"--no-deps", "--editable", $SourceDir
) "Unable to install the HugAgentOS command"

Expand Down
2 changes: 2 additions & 0 deletions desktop/scripts/create-ce-archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
REQUIRED_FILES = (
"desktop-bundle.json",
"pyproject.toml",
"requirements-desktop.txt",
"requirements-desktop-windows-py311.lock",
"requirements-mem0.txt",
"src/frontend/dist/index.html",
)
Expand Down
51 changes: 51 additions & 0 deletions desktop/scripts/desktop-dependencies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";

export const DESKTOP_REQUIREMENTS_FILE = "requirements-desktop.txt";
export const WINDOWS_DESKTOP_LOCK_FILE =
"requirements-desktop-windows-py311.lock";
export const WINDOWS_LOCK_INPUT_MARKER = "# input-sha256: ";

function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}

function readNormalizedText(path) {
return readFileSync(path, "utf8").replaceAll("\r\n", "\n");
}

export function desktopRequirementsInputHash(root) {
return sha256(readNormalizedText(join(root, DESKTOP_REQUIREMENTS_FILE)));
}

export function readAndValidateWindowsDesktopLock(root) {
const lock = readNormalizedText(join(root, WINDOWS_DESKTOP_LOCK_FILE));
const marker = lock
.split(/\r?\n/, 8)
.find((line) => line.startsWith(WINDOWS_LOCK_INPUT_MARKER));
const expected = desktopRequirementsInputHash(root);
const actual = marker?.slice(WINDOWS_LOCK_INPUT_MARKER.length).trim();
if (actual !== expected) {
throw new Error(
`${WINDOWS_DESKTOP_LOCK_FILE} is stale; run ` +
"`npm --prefix desktop run lock:windows`.",
);
}
return lock;
}

export function desktopDependencyFingerprint(root) {
const requirements = readNormalizedText(join(root, DESKTOP_REQUIREMENTS_FILE));
const windowsLock = readAndValidateWindowsDesktopLock(root);
const hash = createHash("sha256");
hash.update("desktop-dependencies-v1\0");
hash.update(DESKTOP_REQUIREMENTS_FILE);
hash.update("\0");
hash.update(requirements);
hash.update("\0");
hash.update(WINDOWS_DESKTOP_LOCK_FILE);
hash.update("\0");
hash.update(windowsLock);
return hash.digest("hex");
}
Loading
Loading