diff --git a/.gitattributes b/.gitattributes index ceec391..ae293ce 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/desktop/README.md b/desktop/README.md index 20fffb6..d6c10ab 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -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` 起提供), @@ -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) | @@ -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 diff --git a/desktop/package.json b/desktop/package.json index 3f07385..d0a3ab2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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": { diff --git a/desktop/resources/server-bootstrap/install-local-server.ps1 b/desktop/resources/server-bootstrap/install-local-server.ps1 index 59408e9..1f3ffed 100644 --- a/desktop/resources/server-bootstrap/install-local-server.ps1 +++ b/desktop/resources/server-bootstrap/install-local-server.ps1 @@ -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 { @@ -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 @@ -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." } @@ -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 @@ -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 @@ -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 @( @@ -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 @@ -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" @@ -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" diff --git a/desktop/scripts/create-ce-archive.py b/desktop/scripts/create-ce-archive.py index 2075e9b..a7fb7ff 100644 --- a/desktop/scripts/create-ce-archive.py +++ b/desktop/scripts/create-ce-archive.py @@ -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", ) diff --git a/desktop/scripts/desktop-dependencies.mjs b/desktop/scripts/desktop-dependencies.mjs new file mode 100644 index 0000000..970529c --- /dev/null +++ b/desktop/scripts/desktop-dependencies.mjs @@ -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"); +} diff --git a/desktop/scripts/desktop-dependencies.test.mjs b/desktop/scripts/desktop-dependencies.test.mjs new file mode 100644 index 0000000..d6d387a --- /dev/null +++ b/desktop/scripts/desktop-dependencies.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + DESKTOP_REQUIREMENTS_FILE, + WINDOWS_DESKTOP_LOCK_FILE, + desktopDependencyFingerprint, + readAndValidateWindowsDesktopLock, +} from "./desktop-dependencies.mjs"; + +const desktopDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(desktopDir, ".."); + +function requirementNames(contents) { + return new Set( + contents + .split(/\r?\n/) + .filter((line) => line && !line.startsWith(" ") && !line.startsWith("#")) + .map((line) => line.match(/^([A-Za-z0-9_.-]+)/)?.[1]) + .filter(Boolean) + .map((name) => name.toLowerCase().replaceAll("_", "-")), + ); +} + +test("desktop dependency profile excludes server and container providers", () => { + const requirements = readFileSync( + join(repoRoot, DESKTOP_REQUIREMENTS_FILE), + "utf8", + ); + const names = requirementNames(requirements); + for (const excluded of [ + "aliyun-python-sdk-core", + "bandit", + "boto3", + "botocore", + "e2b-code-interpreter", + "neo4j", + "opensandbox", + "opensandbox-code-interpreter", + "oss2", + "psycopg2-binary", + "safety", + ]) { + assert.equal(names.has(excluded), false, `${excluded} must stay excluded`); + } + for (const required of [ + "agentscope", + "fastapi", + "mem0ai", + "milvus-lite", + "pymilvus", + "python-docx", + "scipy", + "uv", + ]) { + assert.equal(names.has(required), true, `${required} must stay available`); + } +}); + +test("Windows Python 3.11 lock is exact and matches its desktop input", () => { + const lock = readAndValidateWindowsDesktopLock(repoRoot); + const names = requirementNames(lock); + assert.ok(names.size > 100, "the transitive desktop lock must be complete"); + for (const line of lock.split(/\r?\n/)) { + if (/^[A-Za-z0-9_.-]+/.test(line)) { + assert.match(line, /^[A-Za-z0-9_.-]+==[^\s]+$/); + } + } + const mcpVersion = lock.match(/^mcp==([^\s]+)$/m)?.[1]; + assert.ok(mcpVersion, "the lock must contain mcp"); + assert.equal(Number(mcpVersion.split(".")[0]), 1); + assert.match(lock, /^pymilvus==2\.5\.18$/m); + assert.match(lock, /^milvus-lite==3\.1\.0$/m); + for (const excluded of ["oss2", "boto3", "neo4j", "opensandbox"]) + assert.equal(names.has(excluded), false); +}); + +test("desktop dependency fingerprint is stable and content-addressed", () => { + const first = desktopDependencyFingerprint(repoRoot); + const second = desktopDependencyFingerprint(repoRoot); + assert.match(first, /^[a-f0-9]{64}$/); + assert.equal(second, first); + assert.ok( + readFileSync(join(repoRoot, WINDOWS_DESKTOP_LOCK_FILE), "utf8").length > + 10_000, + ); +}); + +test("desktop dependency hash is independent of checkout line endings", () => { + const fixture = mkdtempSync(join(tmpdir(), "desktop-dependencies-")); + try { + for (const file of [DESKTOP_REQUIREMENTS_FILE, WINDOWS_DESKTOP_LOCK_FILE]) { + const contents = readFileSync(join(repoRoot, file), "utf8"); + writeFileSync(join(fixture, file), contents.replaceAll("\n", "\r\n")); + } + assert.doesNotThrow(() => readAndValidateWindowsDesktopLock(fixture)); + assert.equal( + desktopDependencyFingerprint(fixture), + desktopDependencyFingerprint(repoRoot), + ); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +test("Windows bootstrap uses one locked sync and no full pip fallback", () => { + const installer = readFileSync( + join( + desktopDir, + "resources", + "server-bootstrap", + "install-local-server.ps1", + ), + "utf8", + ); + assert.match(installer, /"pip", "sync"/); + assert.match( + installer, + /https:\/\/mirrors\.aliyun\.com\/pypi\/simple/, + ); + assert.match(installer, /HUGAGENT_PYPI_INDEX_URL/); + assert.match(installer, /"--default-index"/); + assert.match(installer, /BundledDependencyFingerprint/); + assert.match(installer, /Python 依赖未变化,复用现有运行环境/); + assert.doesNotMatch(installer, /retrying with pip/i); + assert.doesNotMatch(installer, /SourceDir "requirements\.txt"/); +}); diff --git a/desktop/scripts/generate-windows-requirements-lock.mjs b/desktop/scripts/generate-windows-requirements-lock.mjs new file mode 100644 index 0000000..35c2363 --- /dev/null +++ b/desktop/scripts/generate-windows-requirements-lock.mjs @@ -0,0 +1,66 @@ +import { spawnSync } from "node:child_process"; +import { + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + DESKTOP_REQUIREMENTS_FILE, + WINDOWS_DESKTOP_LOCK_FILE, + WINDOWS_LOCK_INPUT_MARKER, + desktopRequirementsInputHash, +} from "./desktop-dependencies.mjs"; + +const desktopDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(desktopDir, ".."); +const output = join(repoRoot, WINDOWS_DESKTOP_LOCK_FILE); +const temporary = `${output}.tmp`; + +rmSync(temporary, { force: true }); +const result = spawnSync( + "uv", + [ + "pip", + "compile", + DESKTOP_REQUIREMENTS_FILE, + "--python", + "3.11", + "--python-version", + "3.11", + "--python-platform", + "x86_64-pc-windows-msvc", + "--only-binary", + ":all:", + "--no-header", + "--output-file", + temporary, + ], + { + cwd: repoRoot, + encoding: "utf8", + shell: false, + stdio: ["ignore", "inherit", "inherit"], + }, +); +if (result.error) throw result.error; +if (result.status !== 0) { + rmSync(temporary, { force: true }); + throw new Error(`uv pip compile failed with exit code ${result.status}`); +} + +const compiled = readFileSync(temporary, "utf8").replace(/\r\n/g, "\n"); +const inputHash = desktopRequirementsInputHash(repoRoot); +const header = [ + "# This file is generated. Do not edit it by hand.", + "# Regenerate with: npm --prefix desktop run lock:windows", + `${WINDOWS_LOCK_INPUT_MARKER}${inputHash}`, + "# Target: CPython 3.11 on Windows x86_64", + "", +].join("\n"); +writeFileSync(temporary, `${header}${compiled}`, "utf8"); +renameSync(temporary, output); +console.log(`Wrote ${WINDOWS_DESKTOP_LOCK_FILE}`); diff --git a/desktop/scripts/mac-installer.test.mjs b/desktop/scripts/mac-installer.test.mjs index fbec5d1..fb6eeac 100644 --- a/desktop/scripts/mac-installer.test.mjs +++ b/desktop/scripts/mac-installer.test.mjs @@ -34,6 +34,15 @@ function writeExecutable(path, contents) { function buildBundleArchive(bundle, fixture) { const archive = join(fixture, "server-ce.zip"); const manifest = join(fixture, "server-ce-manifest.json"); + if (!existsSync(join(bundle, "requirements-desktop.txt"))) { + writeFileSync(join(bundle, "requirements-desktop.txt"), "fastapi==1\n"); + } + if (!existsSync(join(bundle, "requirements-desktop-windows-py311.lock"))) { + writeFileSync( + join(bundle, "requirements-desktop-windows-py311.lock"), + "# test lock\nuv==0.11.33\n", + ); + } copyFileSync(join(bundle, "desktop-bundle.json"), manifest); let result; for (const python of ["python3", "python"]) { diff --git a/desktop/scripts/prepare-bundle.mjs b/desktop/scripts/prepare-bundle.mjs index e099e32..f812d94 100644 --- a/desktop/scripts/prepare-bundle.mjs +++ b/desktop/scripts/prepare-bundle.mjs @@ -1,9 +1,20 @@ import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { stageTrackedCeRepository } from "./ce-payload.mjs"; +import { + DESKTOP_REQUIREMENTS_FILE, + WINDOWS_DESKTOP_LOCK_FILE, + desktopDependencyFingerprint, +} from "./desktop-dependencies.mjs"; import { readDesktopVersion } from "./desktop-version.mjs"; const desktopDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -15,6 +26,7 @@ const npmCommand = const npmPrefix = process.platform === "win32" ? ["/d", "/s", "/c", "npm.cmd"] : []; const desktopVersion = readDesktopVersion(desktopDir); +const dependencyFingerprint = desktopDependencyFingerprint(repoRoot); const python = findPython(); function run(command, args, options = {}) { @@ -93,6 +105,26 @@ if (existsSync(ceBuilder)) { console.log(`[desktop] Staged ${copied} tracked CE files`); } +// Desktop dependencies are a release input, not a derivative of the generic +// server requirements. Copy them explicitly so development builds also see +// newly added files before they have been committed to Git. +for (const dependencyFile of [ + DESKTOP_REQUIREMENTS_FILE, + WINDOWS_DESKTOP_LOCK_FILE, +]) { + copyFileSync( + join(repoRoot, dependencyFile), + join(generatedRoot, dependencyFile), + ); +} +const bundledDependencyFingerprint = desktopDependencyFingerprint(generatedRoot); +if (bundledDependencyFingerprint !== dependencyFingerprint) { + throw new Error("Bundled desktop dependencies changed while staging."); +} +console.log( + `[desktop] Dependency lock ready: ${dependencyFingerprint.slice(0, 12)}`, +); + console.log("[desktop] Building the CE login/onboarding web application"); run( npmCommand, @@ -131,6 +163,7 @@ writeFileSync( schema: 1, desktop_version: desktopVersion, source_revision: revision.stdout.trim(), + dependency_fingerprint: dependencyFingerprint, }, null, 2, diff --git a/requirements-desktop-windows-py311.lock b/requirements-desktop-windows-py311.lock new file mode 100644 index 0000000..0586a5e --- /dev/null +++ b/requirements-desktop-windows-py311.lock @@ -0,0 +1,654 @@ +# This file is generated. Do not edit it by hand. +# Regenerate with: npm --prefix desktop run lock:windows +# input-sha256: 15faea40b55de92bad03702050d204c87ecae51c9325e20dd4e4dda80c6e4efa +# Target: CPython 3.11 on Windows x86_64 +agentscope==2.0.0 + # via -r requirements-desktop.txt +aiofiles==25.1.0 + # via agentscope +aiohappyeyeballs==2.7.1 + # via aiohttp +aiohttp==3.14.3 + # via + # dashscope + # dingtalk-stream + # litellm +aioitertools==0.13.0 + # via agentscope +aiosignal==1.4.0 + # via aiohttp +alembic==1.18.5 + # via -r requirements-desktop.txt +annotated-doc==0.0.5 + # via + # fastapi + # typer +annotated-types==0.8.0 + # via pydantic +anthropic==0.120.2 + # via agentscope +anyio==4.14.2 + # via + # -r requirements-desktop.txt + # anthropic + # google-genai + # httpx + # mcp + # openai + # sse-starlette + # starlette +argon2-cffi==25.1.0 + # via -r requirements-desktop.txt +argon2-cffi-bindings==25.1.0 + # via argon2-cffi +async-timeout==5.0.1 + # via redis +attrs==26.1.0 + # via + # aiohttp + # jsonschema + # referencing +backoff==2.2.1 + # via posthog +beautifulsoup4==4.15.0 + # via + # -r requirements-desktop.txt + # markdownify + # markitdown +bidict==0.23.1 + # via python-socketio +cached-property==2.0.1 + # via python-datauri +certifi==2026.7.22 + # via + # dashscope + # httpcore + # httpx + # requests +cffi==2.1.0 + # via + # argon2-cffi-bindings + # cryptography +charset-normalizer==3.4.9 + # via + # markitdown + # pdfminer-six + # reportlab + # requests +click==8.4.2 + # via + # huggingface-hub + # litellm + # magika + # uvicorn +colorama==0.4.6 + # via + # click + # qrcode + # tqdm + # typer +coloredlogs==15.0.1 + # via onnxruntime +contourpy==1.3.3 + # via matplotlib +croniter==6.2.4 + # via -r requirements-desktop.txt +cryptography==49.0.0 + # via + # -r requirements-desktop.txt + # dashscope + # google-auth + # pdfminer-six + # pyjwt +cycler==0.12.1 + # via matplotlib +dashscope==1.26.4 + # via agentscope +defusedxml==0.7.1 + # via + # -r requirements-desktop.txt + # markitdown +deprecated==1.3.1 + # via limits +dingtalk-stream==0.24.3 + # via -r requirements-desktop.txt +distro==1.9.0 + # via + # anthropic + # google-genai + # openai + # posthog +docstring-parser==0.18.0 + # via + # agentscope + # anthropic +et-xmlfile==2.0.0 + # via openpyxl +faiss-cpu==1.14.3 + # via milvus-lite +fakeredis==2.37.0 + # via -r requirements-desktop.txt +fastapi==0.140.13 + # via -r requirements-desktop.txt +fastuuid==0.14.0 + # via litellm +filelock==3.32.0 + # via huggingface-hub +filetype==1.2.0 + # via agentscope +flatbuffers==25.12.19 + # via onnxruntime +fonttools==4.63.0 + # via matplotlib +frozenlist==1.8.0 + # via + # aiohttp + # aiosignal +fsspec==2026.7.0 + # via huggingface-hub +google-auth==2.56.2 + # via google-genai +google-genai==2.14.0 + # via -r requirements-desktop.txt +googleapis-common-protos==1.75.0 + # via + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +greenlet==3.5.4 + # via sqlalchemy +grpcio==1.83.0 + # via + # milvus-lite + # opentelemetry-exporter-otlp-proto-grpc + # pymilvus + # qdrant-client +h11==0.16.0 + # via + # httpcore + # uvicorn + # wsproto +h2==4.4.0 + # via httpx +hf-xet==1.5.2 + # via huggingface-hub +hpack==4.2.0 + # via h2 +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via + # -r requirements-desktop.txt + # agentscope + # anthropic + # dashscope + # google-genai + # huggingface-hub + # lark-oapi + # litellm + # mcp + # mem0ai + # ollama + # openai + # qdrant-client + # tavily-python +httpx-sse==0.4.3 + # via + # dashscope + # mcp +huggingface-hub==1.25.1 + # via tokenizers +humanfriendly==10.0 + # via coloredlogs +hyperframe==6.1.0 + # via h2 +idna==3.18 + # via + # anyio + # httpx + # requests + # yarl +importlib-metadata==8.9.0 + # via litellm +jinja2==3.1.6 + # via + # agentscope + # litellm +jiter==0.16.0 + # via + # anthropic + # openai +json-repair==0.61.7 + # via agentscope +json5==0.15.0 + # via agentscope +jsonschema==4.26.0 + # via + # -r requirements-desktop.txt + # agentscope + # litellm + # mcp +jsonschema-specifications==2025.9.1 + # via jsonschema +kiwisolver==1.5.0 + # via matplotlib +lark-oapi==1.7.1 + # via -r requirements-desktop.txt +limits==5.8.0 + # via slowapi +litellm==1.94.0 + # via -r requirements-desktop.txt +lxml==6.1.1 + # via + # -r requirements-desktop.txt + # pikepdf + # python-docx + # python-pptx +magika==0.6.3 + # via markitdown +mako==1.3.12 + # via alembic +markdown-it-py==4.2.0 + # via rich +markdownify==1.2.3 + # via markitdown +markitdown==0.1.6 + # via -r requirements-desktop.txt +markupsafe==3.0.3 + # via + # jinja2 + # mako + # werkzeug +matplotlib==3.11.1 + # via + # -r requirements-desktop.txt + # seaborn +mcp==1.29.0 + # via + # -r requirements-desktop.txt + # agentscope +mdurl==0.1.2 + # via markdown-it-py +mem0ai==2.0.14 + # via -r requirements-desktop.txt +milvus-lite==3.1.0 + # via -r requirements-desktop.txt +mpmath==1.3.0 + # via sympy +multidict==6.7.1 + # via + # aiohttp + # yarl +numpy==2.4.6 + # via + # -r requirements-desktop.txt + # agentscope + # contourpy + # faiss-cpu + # magika + # matplotlib + # milvus-lite + # onnxruntime + # pandas + # qdrant-client + # scipy + # seaborn +ollama==0.6.2 + # via -r requirements-desktop.txt +onnxruntime==1.20.1 + # via magika +openai==2.50.0 + # via + # agentscope + # litellm + # mem0ai +openpyxl==3.1.5 + # via -r requirements-desktop.txt +opentelemetry-api==1.44.0 + # via + # agentscope + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-exporter-otlp==1.44.0 + # via agentscope +opentelemetry-exporter-otlp-proto-common==1.44.0 + # via + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-exporter-otlp-proto-grpc==1.44.0 + # via opentelemetry-exporter-otlp +opentelemetry-exporter-otlp-proto-http==1.44.0 + # via opentelemetry-exporter-otlp +opentelemetry-proto==1.44.0 + # via + # opentelemetry-exporter-otlp-proto-common + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-sdk==1.44.0 + # via + # agentscope + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http +opentelemetry-semantic-conventions==0.65b0 + # via + # agentscope + # opentelemetry-sdk +packaging==26.2 + # via + # faiss-cpu + # huggingface-hub + # limits + # matplotlib + # onnxruntime + # pikepdf + # wheel +pandas==3.0.5 + # via + # -r requirements-desktop.txt + # pymilvus + # seaborn +pdfminer-six==20260107 + # via pdfplumber +pdfplumber==0.11.10 + # via -r requirements-desktop.txt +pikepdf==10.10.0 + # via -r requirements-desktop.txt +pillow==12.3.0 + # via + # -r requirements-desktop.txt + # matplotlib + # pdfplumber + # pikepdf + # python-pptx + # qrcode + # reportlab +pip==26.1.2 + # via -r requirements-desktop.txt +portalocker==3.2.0 + # via qdrant-client +posthog==7.32.0 + # via mem0ai +propcache==0.5.2 + # via + # aiohttp + # yarl +protobuf==6.33.6 + # via + # -r requirements-desktop.txt + # googleapis-common-protos + # mem0ai + # onnxruntime + # opentelemetry-proto + # pymilvus + # qdrant-client +pyarrow==25.0.0 + # via milvus-lite +pyasn1==0.6.4 + # via pyasn1-modules +pyasn1-modules==0.4.2 + # via google-auth +pycparser==3.0 + # via cffi +pycryptodome==3.23.0 + # via lark-oapi +pydantic==2.13.4 + # via + # -r requirements-desktop.txt + # anthropic + # fastapi + # google-genai + # litellm + # mcp + # mem0ai + # ollama + # openai + # pydantic-settings + # qdrant-client +pydantic-core==2.46.4 + # via pydantic +pydantic-settings==2.14.2 + # via mcp +pygments==2.20.0 + # via rich +pyjwt==2.13.0 + # via mcp +pymilvus==2.5.18 + # via -r requirements-desktop.txt +pyparsing==3.3.2 + # via matplotlib +pypdf==6.14.2 + # via -r requirements-desktop.txt +pypdf2==3.0.1 + # via -r requirements-desktop.txt +pypdfium2==5.12.1 + # via pdfplumber +pyreadline3==3.5.6 + # via humanfriendly +python-datauri==3.0.2 + # via agentscope +python-dateutil==2.9.0.post0 + # via + # croniter + # matplotlib + # pandas +python-docx==1.2.0 + # via -r requirements-desktop.txt +python-dotenv==1.2.2 + # via + # -r requirements-desktop.txt + # litellm + # magika + # pydantic-settings + # pymilvus +python-engineio==4.13.3 + # via python-socketio +python-frontmatter==1.3.0 + # via agentscope +python-multipart==0.0.32 + # via + # -r requirements-desktop.txt + # mcp +python-pptx==1.0.2 + # via + # -r requirements-desktop.txt + # markitdown +python-socketio==5.16.3 + # via agentscope +pytz==2026.3.post1 + # via + # -r requirements-desktop.txt + # mem0ai +pywin32==312 + # via + # mcp + # portalocker +pyyaml==6.0.3 + # via + # huggingface-hub + # python-frontmatter +qdrant-client==1.18.0 + # via mem0ai +qrcode==8.2 + # via -r requirements-desktop.txt +redis==8.0.1 + # via + # -r requirements-desktop.txt + # fakeredis +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +regex==2026.7.19 + # via tiktoken +reportlab==5.0.0 + # via -r requirements-desktop.txt +requests==2.34.2 + # via + # -r requirements-desktop.txt + # dashscope + # dingtalk-stream + # google-auth + # google-genai + # lark-oapi + # markitdown + # opentelemetry-exporter-otlp-proto-http + # posthog + # requests-toolbelt + # tavily-python + # tiktoken +requests-toolbelt==1.0.0 + # via lark-oapi +rich==15.0.0 + # via + # dashscope + # typer +ripgrep==14.1.0 + # via agentscope +rpds-py==2026.6.3 + # via + # jsonschema + # referencing +scipy==1.17.1 + # via -r requirements-desktop.txt +seaborn==0.13.2 + # via -r requirements-desktop.txt +segno==1.6.6 + # via -r requirements-desktop.txt +setuptools==83.0.0 + # via + # -r requirements-desktop.txt + # pymilvus +shellingham==1.5.4 + # via typer +shortuuid==1.0.13 + # via agentscope +simple-websocket==1.1.0 + # via python-engineio +six==1.17.0 + # via + # markdownify + # python-dateutil +slowapi==0.1.10 + # via -r requirements-desktop.txt +sniffio==1.3.1 + # via + # anthropic + # google-genai + # openai +sortedcontainers==2.4.0 + # via fakeredis +soupsieve==2.9.1 + # via beautifulsoup4 +sqlalchemy==2.0.51 + # via + # -r requirements-desktop.txt + # alembic + # mem0ai +sse-starlette==3.4.6 + # via mcp +starlette==1.3.1 + # via + # fastapi + # mcp + # sse-starlette +structlog==26.1.0 + # via -r requirements-desktop.txt +sympy==1.14.0 + # via onnxruntime +tabulate==0.10.0 + # via -r requirements-desktop.txt +tavily-python==0.7.26 + # via -r requirements-desktop.txt +tenacity==9.1.4 + # via google-genai +tiktoken==0.13.0 + # via + # litellm + # tavily-python +tokenizers==0.23.1 + # via litellm +tqdm==4.70.0 + # via + # -r requirements-desktop.txt + # huggingface-hub + # openai +tree-sitter==0.26.0 + # via agentscope +tree-sitter-bash==0.25.1 + # via agentscope +typer==0.27.0 + # via dashscope +typing-extensions==4.16.0 + # via + # aiohttp + # aiosignal + # alembic + # anthropic + # anyio + # beautifulsoup4 + # fastapi + # google-genai + # grpcio + # huggingface-hub + # limits + # mcp + # openai + # opentelemetry-api + # opentelemetry-exporter-otlp-proto-grpc + # opentelemetry-exporter-otlp-proto-http + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # posthog + # pydantic + # pydantic-core + # python-datauri + # python-docx + # python-pptx + # referencing + # sqlalchemy + # starlette + # typing-inspection +typing-inspection==0.4.2 + # via + # fastapi + # mcp + # pydantic + # pydantic-settings +tzdata==2026.3 + # via pandas +ujson==5.13.0 + # via pymilvus +urllib3==2.7.0 + # via + # qdrant-client + # requests +uv==0.11.33 + # via -r requirements-desktop.txt +uvicorn==0.51.0 + # via + # -r requirements-desktop.txt + # mcp +websocket-client==1.9.0 + # via dashscope +websockets==15.0.1 + # via + # dingtalk-stream + # google-genai + # lark-oapi +werkzeug==3.1.8 + # via -r requirements-desktop.txt +wheel==0.47.0 + # via -r requirements-desktop.txt +wrapt==2.3.0 + # via deprecated +wsproto==1.3.2 + # via simple-websocket +xlsxwriter==3.2.9 + # via + # -r requirements-desktop.txt + # python-pptx +yarl==1.24.5 + # via aiohttp +zipp==4.1.0 + # via importlib-metadata diff --git a/requirements-desktop.txt b/requirements-desktop.txt new file mode 100644 index 0000000..f36d7ec --- /dev/null +++ b/requirements-desktop.txt @@ -0,0 +1,85 @@ +# Desktop local-server runtime dependencies. +# +# This profile is intentionally narrower than requirements.txt. The desktop +# service always uses SQLite, local object storage, and the host script runner, +# so container-only database, cloud-storage, and remote-sandbox providers do +# not belong in the end-user Python environment. +# +# Regenerate the Windows Python 3.11 lock after changing this file: +# npm --prefix desktop run lock:windows + +# Bootstrap tools kept in the lock so `uv pip sync` can reconcile the complete +# environment without removing the installer that is currently running. +pip>=24.0 +setuptools>=78.1.1 +wheel>=0.45.1 +uv==0.11.33 + +# Core web service and orchestration. +fastapi>=0.110.0 +uvicorn>=0.27.0 +pydantic>=2.6.0 +anyio>=4.2.0 +python-dotenv>=1.0.0 +requests>=2.31.0 +httpx>=0.27.0 +jsonschema>=4.20.0 +agentscope==2.0.0 +litellm>=1.50.0 +google-genai>=0.3.0 +ollama>=0.3.0 +mcp>=1.0.0,<2.0.0 +tavily-python>=0.5.0 +beautifulsoup4>=4.12.0 + +# Local persistence and application services. PostgreSQL, S3, and OSS clients +# are omitted because the desktop profile uses SQLite and local storage. +sqlalchemy>=2.0.25 +alembic>=1.13.0 +redis>=5.0.0 +fakeredis>=2.21.0 +werkzeug>=3.0.0 +python-multipart>=0.0.9 +structlog>=23.2.0 +slowapi>=0.1.9 +argon2-cffi>=23.1.0 +croniter>=2.0.0 +pytz>=2024.1 +cryptography>=42.0.0 + +# Model channels and inbound integrations retained by the local service. +segno>=1.6.0 +lark-oapi>=1.4.0 +dingtalk-stream>=0.21.0 + +# Persistent memory. Graph memory is disabled in the desktop profile, so the +# Neo4j driver is omitted. Pin the embedded Milvus client/server pair to avoid +# installing PyMilvus 3 and immediately downgrading it during bootstrap. +mem0ai>=0.1.50 +protobuf<7 +pymilvus==2.5.18 +milvus-lite==3.1.0 + +# File parsing, scripts, charts, and document generation. Remote OpenSandbox +# and E2B clients are omitted because desktop execution uses the host runner. +pandas>=2.1.0 +numpy>=1.26.0 +matplotlib>=3.8.0 +seaborn>=0.13.0 +openpyxl>=3.1.0 +xlsxwriter>=3.1.0 +scipy>=1.11.0 +python-docx>=1.1.0 +python-pptx>=1.0.0 +lxml>=5.1.0 +Pillow>=10.0.0 +qrcode[pil]>=7.4.2 +PyPDF2>=3.0.0 +pdfplumber>=0.10.0 +reportlab>=4.0.0 +pypdf>=4.0.0 +pikepdf>=9.0.0 +defusedxml>=0.7.1 +markitdown[pptx]>=0.1.0 +tabulate>=0.9.0 +tqdm>=4.66.0 diff --git a/src/frontend/src/updates.ts b/src/frontend/src/updates.ts index 209fc86..8e60a3e 100644 --- a/src/frontend/src/updates.ts +++ b/src/frontend/src/updates.ts @@ -1,4 +1,10 @@ -import type { UpdateEntry, CapItem } from './types'; +import type { UpdateEntry } from './types'; + +interface CapItem { + title: string; + desc: string; + bullets: string[]; +} // 社区版默认更新记录 / 能力清单(真源在 DB content_blocks,可经部署后配置覆盖)。 // 商业版历史更新记录与行业能力介绍不随社区版分发。