From 16478b40f002d48f93bef095b2830e23e69b9136 Mon Sep 17 00:00:00 2001 From: Luhaozhu Date: Wed, 22 Jul 2026 11:44:10 +0900 Subject: [PATCH 1/4] fix(desktop): harden macOS setup and release flow --- .github/workflows/desktop-ci.yml | 35 +++ .github/workflows/desktop-release.yml | 21 +- desktop/package-lock.json | 4 +- desktop/package.json | 5 +- .../server-bootstrap/install-local-server.sh | 176 +++++++++++-- .../requirements-macos-overrides.txt | 4 + desktop/scripts/desktop-version.mjs | 106 +++++++- desktop/scripts/desktop-version.test.mjs | 39 +++ desktop/scripts/mac-installer.test.mjs | 235 +++++++++++++++++- desktop/scripts/set-desktop-version.mjs | 17 ++ desktop/scripts/validate-release-version.mjs | 12 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/lib.rs | 74 ++++-- desktop/src-tauri/src/local_server.rs | 198 ++++++++++++++- desktop/src-tauri/src/proxy.rs | 93 ++++--- desktop/src-tauri/tauri.conf.json | 2 +- 17 files changed, 900 insertions(+), 125 deletions(-) create mode 100644 desktop/resources/server-bootstrap/requirements-macos-overrides.txt create mode 100644 desktop/scripts/set-desktop-version.mjs diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index d3df4e19..c2fc0823 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -82,5 +82,40 @@ jobs: working-directory: desktop run: node scripts/prepare-bundle.mjs + - name: Install pinned uv for macOS dependency validation + if: matrix.os == 'macos-latest' + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.30" + enable-cache: false + + - name: Validate macOS bootstrap on the native shell + if: matrix.os == 'macos-latest' + working-directory: desktop + run: npm run test:scripts + + - name: Resolve macOS 12 compatible local-server dependencies + if: matrix.os == 'macos-latest' + working-directory: desktop + run: | + uv --system-certs pip install \ + --dry-run \ + --target "$RUNNER_TEMP/hugagent-deps-arm64" \ + --python-version 3.11 \ + --python-platform aarch64-apple-darwin \ + --requirements generated/server-ce/requirements.txt \ + --requirements generated/server-ce/docker/requirements-script-runner.txt \ + --overrides resources/server-bootstrap/requirements-macos-overrides.txt \ + --only-binary pikepdf + uv --system-certs pip install \ + --dry-run \ + --target "$RUNNER_TEMP/hugagent-deps-x86_64" \ + --python-version 3.11 \ + --python-platform x86_64-apple-darwin \ + --requirements generated/server-ce/requirements.txt \ + --requirements generated/server-ce/docker/requirements-script-runner.txt \ + --overrides resources/server-bootstrap/requirements-macos-overrides.txt \ + --only-binary pikepdf + - name: Run desktop Rust tests run: cargo test --manifest-path desktop/src-tauri/Cargo.toml diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 4163a717..39a2c9a6 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -11,9 +11,10 @@ # TAURI_SIGNING_PRIVATE_KEY_PASSWORD # # To cut a release: -# 1. Keep desktop/package.json, desktop/src-tauri/Cargo.toml, and -# desktop/src-tauri/tauri.conf.json on the same version. -# 2. Push or dispatch exactly `desktop-vX.Y.Z` for that version. +# 1. Run `npm --prefix desktop run version:desktop -- X.Y.Z` and commit the +# synchronized version files. +# 2. Push `desktop-vX.Y.Z`, or manually dispatch this workflow from that commit; +# manual runs derive the release tag from the committed desktop version. # 3. Review the draft release after every platform succeeds, then publish it. name: Desktop Release @@ -23,21 +24,20 @@ on: tags: - "desktop-v*" workflow_dispatch: - inputs: - tag: - description: "Release tag; must match the desktop version (for example, desktop-v0.2.1)" - required: true permissions: contents: read concurrency: - group: desktop-release-${{ github.event.inputs.tag || github.ref_name }} + group: desktop-release-${{ github.ref }} cancel-in-progress: false jobs: validate-release: runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.desktop_version.outputs.version }} + release_tag: ${{ steps.desktop_version.outputs.release_tag }} steps: - name: Checkout uses: actions/checkout@v4 @@ -52,8 +52,9 @@ jobs: run: npm run test:scripts - name: Validate release tag and desktop versions + id: desktop_version env: - RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + RELEASE_TAG: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || '' }} run: node desktop/scripts/validate-release-version.mjs "$RELEASE_TAG" build: @@ -138,7 +139,7 @@ jobs: NODE_OPTIONS: "--max-old-space-size=4096" with: projectPath: desktop - tagName: ${{ github.event.inputs.tag || github.ref_name }} + tagName: ${{ needs.validate-release.outputs.release_tag }} releaseName: "HugAgentOS Desktop __VERSION__" releaseBody: "Download the installer for your platform below. See the docs for setup." releaseDraft: true diff --git a/desktop/package-lock.json b/desktop/package-lock.json index f48561ae..77a6b660 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "hugagent-desktop", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hugagent-desktop", - "version": "0.2.1", + "version": "0.2.2", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/desktop/package.json b/desktop/package.json index e0108178..2bdedd7c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,13 +1,14 @@ { "name": "hugagent-desktop", - "version": "0.2.1", + "version": "0.2.2", "private": true, "description": "HugAgentOS 桌面客户端(远程连接 + Windows/macOS 无 Docker 本机服务)", "scripts": { "tauri": "tauri", "dev": "tauri dev", "build": "tauri build", - "test:scripts": "node --test scripts/*.test.mjs" + "test:scripts": "node --test scripts/*.test.mjs", + "version:desktop": "node scripts/set-desktop-version.mjs" }, "devDependencies": { "@tauri-apps/cli": "^2" diff --git a/desktop/resources/server-bootstrap/install-local-server.sh b/desktop/resources/server-bootstrap/install-local-server.sh index 045b1ad9..f5142d15 100755 --- a/desktop/resources/server-bootstrap/install-local-server.sh +++ b/desktop/resources/server-bootstrap/install-local-server.sh @@ -4,6 +4,8 @@ set -euo pipefail BundleDir="" InstallRoot="" +ScriptDir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +MacOverrides="$ScriptDir/requirements-macos-overrides.txt" while [[ $# -gt 0 ]]; do case "$1" in @@ -38,19 +40,79 @@ if [[ ! -f "$BundleDir/src/frontend/dist/index.html" ]]; then echo "The bundled CE web application is missing." >&2 exit 3 fi +if [[ ! -f "$MacOverrides" ]]; then + echo "The macOS dependency compatibility overrides are missing." >&2 + exit 3 +fi -SourceDir="$InstallRoot/source" -VenvDir="$InstallRoot/venv" -VenvPython="$VenvDir/bin/python" -InstalledManifest="$InstallRoot/installed-bundle.json" ToolsDir="$InstallRoot/tools" UvBin="$ToolsDir/uv" PythonDir="$InstallRoot/python" +ReleasesDir="$InstallRoot/releases" +CurrentLink="$InstallRoot/current" +PreviousLink="$InstallRoot/current.previous" +CurrentNext="$InstallRoot/current.next" +CandidateDir="" +CandidateCommitted=0 +ObsoleteRelease="" + +cleanup_candidate() { + ExitCode=$? + trap - EXIT + if [[ "$CandidateCommitted" -eq 0 && -n "$CandidateDir" && -d "$CandidateDir" ]]; then + /bin/rm -rf -- "$CandidateDir" + fi + /bin/rm -f -- "$CurrentNext" + exit "$ExitCode" +} +trap cleanup_candidate EXIT + +download_with_retry() { + DownloadUrl="$1" + DownloadTarget="$2" + /usr/bin/curl \ + --fail \ + --location \ + --silent \ + --show-error \ + --retry 5 \ + --retry-all-errors \ + --retry-delay 2 \ + --connect-timeout 20 \ + --max-time 300 \ + --output "$DownloadTarget" \ + "$DownloadUrl" +} + +uv_run() { + "$UvBin" --system-certs "$@" +} -mkdir -p "$InstallRoot" +mkdir -p "$InstallRoot" "$ReleasesDir" + +progress 3 "正在检查安装空间…" +AvailableKb="$(/bin/df -Pk "$InstallRoot" | /usr/bin/awk 'NR == 2 { print $4 }')" +BundleKb="$(/usr/bin/du -sk "$BundleDir" | /usr/bin/awk '{ print $1 }')" +MinimumFreeKb="${HUGAGENT_MIN_FREE_KB:-4194304}" +if [[ ! "$AvailableKb" =~ ^[0-9]+$ || ! "$BundleKb" =~ ^[0-9]+$ || ! "$MinimumFreeKb" =~ ^[0-9]+$ ]]; then + echo "The installer couldn't determine available disk space." >&2 + exit 4 +fi +RequiredKb=$((MinimumFreeKb + BundleKb * 2)) +if (( AvailableKb < RequiredKb )); then + RequiredGb=$(((RequiredKb + 1048575) / 1048576)) + AvailableGb=$((AvailableKb / 1048576)) + echo "Not enough disk space: ${AvailableGb} GB available, ${RequiredGb} GB required." >&2 + exit 4 +fi + +BundleHash="$(/usr/bin/shasum -a 256 "$BundleDir/desktop-bundle.json" | /usr/bin/awk '{ print $1 }')" +CandidateDir="$(/usr/bin/mktemp -d "$ReleasesDir/${BundleHash}.XXXXXX")" +SourceDir="$CandidateDir/source" +VenvDir="$CandidateDir/venv" +VenvPython="$VenvDir/bin/python" progress 5 "正在复制同版本服务端资源…" -rm -rf "$SourceDir" mkdir -p "$SourceDir" if [[ -x /usr/bin/ditto ]]; then /usr/bin/ditto "$BundleDir" "$SourceDir" @@ -62,9 +124,39 @@ progress 12 "正在准备独立运行环境…" if [[ ! -x "$UvBin" ]]; then mkdir -p "$ToolsDir" progress 16 "正在下载运行环境管理器…" - /usr/bin/curl --fail --location --silent --show-error \ - https://astral.sh/uv/0.11.30/install.sh \ - | env UV_UNMANAGED_INSTALL="$ToolsDir" UV_NO_MODIFY_PATH=1 /bin/sh + UvVersion="0.11.30" + case "$(/usr/bin/uname -m)" in + arm64|aarch64) + UvArtifact="uv-aarch64-apple-darwin.tar.gz" + UvDirectory="uv-aarch64-apple-darwin" + UvSha256="9bed3567d496d8dab84ecf7a1247551ac94ef1baaebb7b65df008dd93e9dc357" + ;; + x86_64) + UvArtifact="uv-x86_64-apple-darwin.tar.gz" + UvDirectory="uv-x86_64-apple-darwin" + UvSha256="ce285fbbfbe294b1e1bc6c87c8b59d9622b85383b88b2b132a2df5c73e83d7c1" + ;; + *) + echo "This Mac architecture isn't supported by the local installer." >&2 + exit 4 + ;; + esac + UvArchive="$ToolsDir/$UvArtifact" + UvPrimaryUrl="https://releases.astral.sh/github/uv/releases/download/$UvVersion/$UvArtifact" + UvFallbackUrl="https://github.com/astral-sh/uv/releases/download/$UvVersion/$UvArtifact" + if ! download_with_retry "$UvPrimaryUrl" "$UvArchive"; then + /bin/rm -f -- "$UvArchive" + download_with_retry "$UvFallbackUrl" "$UvArchive" + fi + ActualUvSha256="$(/usr/bin/shasum -a 256 "$UvArchive" | /usr/bin/awk '{ print $1 }')" + if [[ "$ActualUvSha256" != "$UvSha256" ]]; then + /bin/rm -f -- "$UvArchive" + echo "The downloaded runtime manager failed its SHA-256 verification." >&2 + exit 4 + fi + /usr/bin/tar -xzf "$UvArchive" -C "$ToolsDir" --strip-components 1 "$UvDirectory/uv" + /bin/chmod 755 "$UvBin" + /bin/rm -f -- "$UvArchive" fi if [[ ! -x "$UvBin" ]]; then echo "The local runtime manager couldn't be installed." >&2 @@ -74,31 +166,26 @@ fi export UV_CACHE_DIR="$InstallRoot/cache/uv" export UV_PYTHON_INSTALL_DIR="$PythonDir" export UV_PYTHON_BIN_DIR="$InstallRoot/python-bin" +export UV_HTTP_RETRIES=5 progress 20 "正在下载 Python 3.11 运行环境…" -"$UvBin" python install 3.11 --install-dir "$PythonDir" --no-bin +uv_run python install 3.11 --install-dir "$PythonDir" --no-bin -RebuildVenv=1 -if [[ -x "$VenvPython" ]] \ - && "$VenvPython" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' >/dev/null 2>&1; then - RebuildVenv=0 -fi -if [[ "$RebuildVenv" -eq 1 ]]; then - progress 26 "正在创建独立 Python 环境…" - rm -rf "$VenvDir" - "$UvBin" venv --python 3.11 "$VenvDir" -fi +progress 26 "正在创建独立 Python 环境…" +uv_run venv --python 3.11 "$VenvDir" progress 34 "正在准备 Python 安装工具…" -"$UvBin" pip install --python "$VenvPython" --upgrade pip setuptools wheel +uv_run pip install --python "$VenvPython" --upgrade pip setuptools wheel progress 42 "正在安装服务端依赖,首次安装需要数分钟…" -"$UvBin" pip install --python "$VenvPython" --prefer-binary \ +uv_run pip install --python "$VenvPython" \ --requirements "$SourceDir/requirements.txt" progress 70 "正在安装本机脚本与文档处理能力…" -"$UvBin" pip install --python "$VenvPython" --prefer-binary \ - --requirements "$SourceDir/docker/requirements-script-runner.txt" +uv_run pip install --python "$VenvPython" \ + --requirements "$SourceDir/docker/requirements-script-runner.txt" \ + --overrides "$MacOverrides" \ + --only-binary pikepdf progress 78 "正在检查可选的 Node.js 文档能力…" NodeExecutable="" @@ -143,13 +230,48 @@ else fi progress 86 "正在注册 HugAgentOS 本机服务…" -"$UvBin" pip install --python "$VenvPython" --no-deps --editable "$SourceDir" +uv_run pip install --python "$VenvPython" --no-deps --editable "$SourceDir" if [[ ! -x "$VenvDir/bin/hugagent" ]]; then echo "The HugAgentOS service command wasn't installed correctly." >&2 exit 5 fi +if ! "$VenvDir/bin/hugagent" --help >/dev/null; then + echo "The installed HugAgentOS service failed its startup validation." >&2 + exit 5 +fi + +/bin/cp "$BundleDir/desktop-bundle.json" "$CandidateDir/desktop-bundle.json" + +progress 88 "正在安全切换到新版本…" +if [[ -L "$PreviousLink" ]]; then + PreviousPreviousTarget="$(/usr/bin/readlink "$PreviousLink")" + case "$PreviousPreviousTarget" in + "$ReleasesDir"/*) ObsoleteRelease="$PreviousPreviousTarget" ;; + esac +fi +/bin/rm -f -- "$PreviousLink" "$CurrentNext" +if [[ -L "$CurrentLink" ]]; then + PreviousTarget="$(/usr/bin/readlink "$CurrentLink")" + /bin/ln -s "$PreviousTarget" "$PreviousLink" +elif [[ -e "$CurrentLink" ]]; then + echo "The local release pointer is invalid; the existing installation was left unchanged." >&2 + exit 5 +elif [[ -x "$InstallRoot/venv/bin/hugagent" && -d "$InstallRoot/source" ]]; then + if [[ -f "$InstallRoot/installed-bundle.json" ]]; then + /bin/cp "$InstallRoot/installed-bundle.json" "$InstallRoot/desktop-bundle.json" + fi + /bin/ln -s "$InstallRoot" "$PreviousLink" +fi +/bin/ln -s "$CandidateDir" "$CurrentNext" +if ! /bin/mv -fh "$CurrentNext" "$CurrentLink" 2>/dev/null; then + # GNU mv (used by the Linux script test) spells BSD/macOS `-h` as `-T`. + /bin/mv -fT "$CurrentNext" "$CurrentLink" +fi +CandidateCommitted=1 +if [[ -n "$ObsoleteRelease" && "$ObsoleteRelease" != "$CandidateDir" ]]; then + /bin/rm -rf -- "$ObsoleteRelease" || true +fi -/bin/cp "$BundleDir/desktop-bundle.json" "$InstalledManifest" progress 90 "本机服务安装完成,正在启动…" -printf 'Local server installed at %s\n' "$InstallRoot" +printf 'Local server installed at %s\n' "$CandidateDir" diff --git a/desktop/resources/server-bootstrap/requirements-macos-overrides.txt b/desktop/resources/server-bootstrap/requirements-macos-overrides.txt new file mode 100644 index 00000000..0a6eb4c8 --- /dev/null +++ b/desktop/resources/server-bootstrap/requirements-macos-overrides.txt @@ -0,0 +1,4 @@ +# pikepdf 9+ only publishes Apple Silicon wheels for macOS 14+ (and current +# Intel wheels require newer macOS). The desktop client supports macOS 12+, so +# keep the last release that ships Python 3.11 wheels for both Mac architectures. +pikepdf==8.15.1 diff --git a/desktop/scripts/desktop-version.mjs b/desktop/scripts/desktop-version.mjs index 35f17223..7b6abd78 100644 --- a/desktop/scripts/desktop-version.mjs +++ b/desktop/scripts/desktop-version.mjs @@ -1,30 +1,62 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -export function readDesktopVersion(desktopDir) { - const packageJson = JSON.parse( - readFileSync(join(desktopDir, "package.json"), "utf8"), - ); - const tauriConfig = JSON.parse( - readFileSync(join(desktopDir, "src-tauri", "tauri.conf.json"), "utf8"), +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, value) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +export function readDesktopVersions(desktopDir) { + const packageJson = readJson(join(desktopDir, "package.json")); + const packageLock = readJson(join(desktopDir, "package-lock.json")); + const tauriConfig = readJson( + join(desktopDir, "src-tauri", "tauri.conf.json"), ); const cargoToml = readFileSync( join(desktopDir, "src-tauri", "Cargo.toml"), "utf8", ); + const cargoLock = readFileSync( + join(desktopDir, "src-tauri", "Cargo.lock"), + "utf8", + ); const cargoVersion = cargoToml.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + const cargoLockVersion = cargoLock.match( + /\[\[package\]\]\nname = "hugagent-desktop"\nversion = "([^"]+)"/, + )?.[1]; + + return { + package: packageJson.version, + packageLock: packageLock.version, + packageLockRoot: packageLock.packages?.[""]?.version, + tauri: tauriConfig.version, + cargo: cargoVersion, + cargoLock: cargoLockVersion, + }; +} + +export function readDesktopVersion(desktopDir) { + const versions = readDesktopVersions(desktopDir); + const version = versions.package; if ( - !cargoVersion || - packageJson.version !== tauriConfig.version || - packageJson.version !== cargoVersion + !version || + !SEMVER.test(version) || + Object.values(versions).some((candidate) => candidate !== version) ) { throw new Error( - `Desktop version mismatch: package=${packageJson.version}, tauri=${tauriConfig.version}, cargo=${cargoVersion || "missing"}`, + `Desktop version mismatch: ${Object.entries(versions) + .map(([name, value]) => `${name}=${value || "missing"}`) + .join(", ")}. Run: npm --prefix desktop run version:desktop -- `, ); } - return packageJson.version; + return version; } export function validateDesktopReleaseTag(desktopDir, releaseTag) { @@ -37,3 +69,53 @@ export function validateDesktopReleaseTag(desktopDir, releaseTag) { } return { version, expectedTag }; } + +export function resolveDesktopReleaseTag(desktopDir, releaseTag) { + const version = readDesktopVersion(desktopDir); + const expectedTag = `desktop-v${version}`; + if (releaseTag) { + return validateDesktopReleaseTag(desktopDir, releaseTag); + } + return { version, expectedTag }; +} + +export function setDesktopVersion(desktopDir, version) { + if (!SEMVER.test(version || "")) { + throw new Error(`Invalid desktop version: ${version || "missing"}`); + } + + const packagePath = join(desktopDir, "package.json"); + const packageLockPath = join(desktopDir, "package-lock.json"); + const tauriPath = join(desktopDir, "src-tauri", "tauri.conf.json"); + const cargoPath = join(desktopDir, "src-tauri", "Cargo.toml"); + const cargoLockPath = join(desktopDir, "src-tauri", "Cargo.lock"); + + const packageJson = readJson(packagePath); + packageJson.version = version; + writeJson(packagePath, packageJson); + + const packageLock = readJson(packageLockPath); + packageLock.version = version; + if (packageLock.packages?.[""]) { + packageLock.packages[""].version = version; + } + writeJson(packageLockPath, packageLock); + + const tauriConfig = readJson(tauriPath); + tauriConfig.version = version; + writeJson(tauriPath, tauriConfig); + + const cargoToml = readFileSync(cargoPath, "utf8").replace( + /(^\[package\][\s\S]*?^version\s*=\s*")[^"]+(".*$)/m, + `$1${version}$2`, + ); + writeFileSync(cargoPath, cargoToml, "utf8"); + + const cargoLock = readFileSync(cargoLockPath, "utf8").replace( + /(\[\[package\]\]\nname = "hugagent-desktop"\nversion = ")[^"]+("\n)/, + `$1${version}$2`, + ); + writeFileSync(cargoLockPath, cargoLock, "utf8"); + + return readDesktopVersion(desktopDir); +} diff --git a/desktop/scripts/desktop-version.test.mjs b/desktop/scripts/desktop-version.test.mjs index fccf11c8..06b1244b 100644 --- a/desktop/scripts/desktop-version.test.mjs +++ b/desktop/scripts/desktop-version.test.mjs @@ -6,6 +6,8 @@ import test from "node:test"; import { readDesktopVersion, + resolveDesktopReleaseTag, + setDesktopVersion, validateDesktopReleaseTag, } from "./desktop-version.mjs"; @@ -17,6 +19,13 @@ function createDesktopFixture(versions = {}) { join(desktopDir, "package.json"), JSON.stringify({ version: versions.package || "1.2.3" }), ); + writeFileSync( + join(desktopDir, "package-lock.json"), + JSON.stringify({ + version: versions.packageLock || "1.2.3", + packages: { "": { version: versions.packageLockRoot || "1.2.3" } }, + }), + ); writeFileSync( join(desktopDir, "src-tauri", "tauri.conf.json"), JSON.stringify({ version: versions.tauri || "1.2.3" }), @@ -25,6 +34,10 @@ function createDesktopFixture(versions = {}) { join(desktopDir, "src-tauri", "Cargo.toml"), `[package]\nversion = "${versions.cargo || "1.2.3"}"\n`, ); + writeFileSync( + join(desktopDir, "src-tauri", "Cargo.lock"), + `[[package]]\nname = "hugagent-desktop"\nversion = "${versions.cargoLock || "1.2.3"}"\n`, + ); return { root, desktopDir }; } @@ -67,3 +80,29 @@ test("rejects a release tag that does not match the desktop version", () => { rmSync(fixture.root, { recursive: true, force: true }); } }); + +test("manual release derives its tag from the committed desktop version", () => { + const fixture = createDesktopFixture(); + try { + assert.deepEqual(resolveDesktopReleaseTag(fixture.desktopDir, ""), { + version: "1.2.3", + expectedTag: "desktop-v1.2.3", + }); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test("version command synchronizes every desktop manifest", () => { + const fixture = createDesktopFixture(); + try { + assert.equal(setDesktopVersion(fixture.desktopDir, "1.3.0"), "1.3.0"); + assert.equal(readDesktopVersion(fixture.desktopDir), "1.3.0"); + assert.deepEqual( + validateDesktopReleaseTag(fixture.desktopDir, "desktop-v1.3.0"), + { version: "1.3.0", expectedTag: "desktop-v1.3.0" }, + ); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +}); diff --git a/desktop/scripts/mac-installer.test.mjs b/desktop/scripts/mac-installer.test.mjs index 76caa05c..624621b5 100644 --- a/desktop/scripts/mac-installer.test.mjs +++ b/desktop/scripts/mac-installer.test.mjs @@ -2,9 +2,11 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { chmodSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, + readdirSync, rmSync, writeFileSync, } from "node:fs"; @@ -31,6 +33,7 @@ test("macOS bootstrap completes a clean CE install with an isolated runtime", () const fixture = mkdtempSync(join(tmpdir(), "hugagent-macos-installer-")); const bundle = join(fixture, "bundle"); const installRoot = join(fixture, "installed"); + const uvLog = join(fixture, "uv.log"); try { mkdirSync(join(bundle, "src", "frontend", "dist"), { recursive: true }); @@ -45,6 +48,15 @@ test("macOS bootstrap completes a clean CE install with an isolated runtime", () join(installRoot, "tools", "uv"), `#!/bin/bash set -e +if [[ "$1" == "--system-certs" ]]; then shift; fi +printf '%s\n' "$*" >> "\${HUGAGENT_UV_LOG:?}" +if [[ " $* " == *" --prefer-binary "* ]]; then + echo "unexpected pip-only argument: --prefer-binary" >&2 + exit 2 +fi +if [[ "\${HUGAGENT_FAIL_REQUIREMENTS:-0}" == "1" && "$1" == "pip" && " $* " == *" --requirements "* ]]; then + exit 9 +fi if [[ "$1" == "venv" ]]; then destination="\${!#}" mkdir -p "$destination/bin" @@ -74,20 +86,237 @@ fi ], { encoding: "utf8", - env: { ...process.env, HUGAGENT_SKIP_OPTIONAL_NODE: "1" }, + env: { + ...process.env, + HUGAGENT_SKIP_OPTIONAL_NODE: "1", + HUGAGENT_UV_LOG: uvLog, + }, }, ); assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /HUGAGENT_PROGRESS\|90\|/); assert.equal( - readFileSync(join(installRoot, "installed-bundle.json"), "utf8"), + readFileSync( + join(installRoot, "current", "desktop-bundle.json"), + "utf8", + ), '{"desktop_version":"test"}\n', ); assert.equal( - readFileSync(join(installRoot, "source", "src", "frontend", "dist", "index.html"), "utf8"), + readFileSync( + join( + installRoot, + "current", + "source", + "src", + "frontend", + "dist", + "index.html", + ), + "utf8", + ), "ok", ); + const uvCalls = readFileSync(uvLog, "utf8"); + assert.match(uvCalls, /--overrides .*requirements-macos-overrides\.txt/); + assert.match(uvCalls, /--only-binary pikepdf/); + assert.doesNotMatch(uvCalls, /--prefer-binary/); + + writeFileSync( + join(bundle, "src", "frontend", "dist", "index.html"), + "updated", + ); + writeFileSync( + join(bundle, "desktop-bundle.json"), + '{"desktop_version":"updated"}\n', + ); + const update = spawnSync( + "/bin/bash", + [ + installer, + "--bundle-dir", + bundle, + "--install-root", + installRoot, + ], + { + encoding: "utf8", + env: { + ...process.env, + HUGAGENT_SKIP_OPTIONAL_NODE: "1", + HUGAGENT_UV_LOG: uvLog, + }, + }, + ); + assert.equal(update.status, 0, update.stderr || update.stdout); + assert.equal( + readFileSync( + join( + installRoot, + "current", + "source", + "src", + "frontend", + "dist", + "index.html", + ), + "utf8", + ), + "updated", + ); + assert.equal( + readFileSync( + join( + installRoot, + "current.previous", + "source", + "src", + "frontend", + "dist", + "index.html", + ), + "utf8", + ), + "ok", + ); + + writeFileSync( + join(bundle, "desktop-bundle.json"), + '{"desktop_version":"third"}\n', + ); + const third = spawnSync( + "/bin/bash", + [ + installer, + "--bundle-dir", + bundle, + "--install-root", + installRoot, + ], + { + encoding: "utf8", + env: { + ...process.env, + HUGAGENT_SKIP_OPTIONAL_NODE: "1", + HUGAGENT_UV_LOG: uvLog, + }, + }, + ); + assert.equal(third.status, 0, third.stderr || third.stdout); + assert.equal(readdirSync(join(installRoot, "releases")).length, 2); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +test("macOS bootstrap leaves the previous release untouched after dependency failure", () => { + const fixture = mkdtempSync(join(tmpdir(), "hugagent-macos-rollback-")); + const bundle = join(fixture, "bundle"); + const installRoot = join(fixture, "installed"); + const uvLog = join(fixture, "uv.log"); + + try { + mkdirSync(join(bundle, "src", "frontend", "dist"), { recursive: true }); + mkdirSync(join(bundle, "docker"), { recursive: true }); + writeFileSync(join(bundle, "pyproject.toml"), "[project]\nname='test'\n"); + writeFileSync(join(bundle, "requirements.txt"), "broken>=1\n"); + writeFileSync(join(bundle, "docker", "requirements-script-runner.txt"), ""); + writeFileSync(join(bundle, "src", "frontend", "dist", "index.html"), "new"); + writeFileSync(join(bundle, "desktop-bundle.json"), '{"desktop_version":"new"}\n'); + + mkdirSync(join(installRoot, "source"), { recursive: true }); + writeFileSync(join(installRoot, "source", "version.txt"), "old"); + writeExecutable(join(installRoot, "venv", "bin", "hugagent"), "#!/bin/bash\nexit 0\n"); + writeFileSync(join(installRoot, "installed-bundle.json"), '{"desktop_version":"old"}\n'); + writeExecutable( + join(installRoot, "tools", "uv"), + `#!/bin/bash +set -e +if [[ "$1" == "--system-certs" ]]; then shift; fi +printf '%s\n' "$*" >> "\${HUGAGENT_UV_LOG:?}" +if [[ "$1" == "venv" ]]; then + destination="\${!#}" + mkdir -p "$destination/bin" + printf '#!/bin/bash\\nexit 0\\n' > "$destination/bin/python" + chmod +x "$destination/bin/python" +elif [[ "$1" == "pip" && " $* " == *" --requirements "* ]]; then + exit 9 +fi +`, + ); + + const result = spawnSync( + "/bin/bash", + [ + installer, + "--bundle-dir", + bundle, + "--install-root", + installRoot, + ], + { + encoding: "utf8", + env: { + ...process.env, + HUGAGENT_SKIP_OPTIONAL_NODE: "1", + HUGAGENT_UV_LOG: uvLog, + }, + }, + ); + + assert.equal(result.status, 9, result.stderr || result.stdout); + assert.equal(readFileSync(join(installRoot, "source", "version.txt"), "utf8"), "old"); + assert.equal( + readFileSync(join(installRoot, "installed-bundle.json"), "utf8"), + '{"desktop_version":"old"}\n', + ); + } finally { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +test("macOS bootstrap pins verified uv downloads and checks free space", () => { + const contents = readFileSync(installer, "utf8"); + assert.match(contents, /--retry 5/); + assert.match(contents, /--retry-all-errors/); + assert.match(contents, /shasum -a 256/); + assert.match(contents, /UvSha256="9bed3567/); + assert.match(contents, /HUGAGENT_MIN_FREE_KB/); +}); + +test("macOS bootstrap stops before copying when free space is insufficient", () => { + const fixture = mkdtempSync(join(tmpdir(), "hugagent-macos-disk-")); + const bundle = join(fixture, "bundle"); + const installRoot = join(fixture, "installed"); + + try { + mkdirSync(join(bundle, "src", "frontend", "dist"), { recursive: true }); + mkdirSync(join(bundle, "docker"), { recursive: true }); + writeFileSync(join(bundle, "pyproject.toml"), "[project]\nname='test'\n"); + writeFileSync(join(bundle, "requirements.txt"), ""); + writeFileSync(join(bundle, "docker", "requirements-script-runner.txt"), ""); + writeFileSync(join(bundle, "src", "frontend", "dist", "index.html"), "ok"); + writeFileSync(join(bundle, "desktop-bundle.json"), '{"desktop_version":"test"}\n'); + + const result = spawnSync( + "/bin/bash", + [ + installer, + "--bundle-dir", + bundle, + "--install-root", + installRoot, + ], + { + encoding: "utf8", + env: { ...process.env, HUGAGENT_MIN_FREE_KB: "999999999999" }, + }, + ); + + assert.equal(result.status, 4, result.stderr || result.stdout); + assert.match(result.stderr, /Not enough disk space/); + assert.equal(existsSync(join(installRoot, "source")), false); } finally { rmSync(fixture, { recursive: true, force: true }); } diff --git a/desktop/scripts/set-desktop-version.mjs b/desktop/scripts/set-desktop-version.mjs new file mode 100644 index 00000000..a9a3dfb1 --- /dev/null +++ b/desktop/scripts/set-desktop-version.mjs @@ -0,0 +1,17 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { setDesktopVersion } from "./desktop-version.mjs"; + +const desktopDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const version = process.argv[2]; + +try { + const updated = setDesktopVersion(desktopDir, version); + console.log(`[desktop] Version synchronized: ${updated}`); +} catch (error) { + console.error( + `[desktop] ${error instanceof Error ? error.message : String(error)}`, + ); + process.exitCode = 1; +} diff --git a/desktop/scripts/validate-release-version.mjs b/desktop/scripts/validate-release-version.mjs index b78b066a..7604bc02 100644 --- a/desktop/scripts/validate-release-version.mjs +++ b/desktop/scripts/validate-release-version.mjs @@ -1,19 +1,27 @@ +import { appendFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { validateDesktopReleaseTag } from "./desktop-version.mjs"; +import { resolveDesktopReleaseTag } from "./desktop-version.mjs"; const desktopDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const releaseTag = process.argv[2]; try { - const { version, expectedTag } = validateDesktopReleaseTag( + const { version, expectedTag } = resolveDesktopReleaseTag( desktopDir, releaseTag, ); console.log( `[desktop] Release version validated: ${version} (${expectedTag})`, ); + if (process.env.GITHUB_OUTPUT) { + appendFileSync( + process.env.GITHUB_OUTPUT, + `version=${version}\nrelease_tag=${expectedTag}\n`, + "utf8", + ); + } } catch (error) { console.error( `[desktop] ${error instanceof Error ? error.message : String(error)}`, diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 522f272c..af0d8c78 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1677,7 +1677,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hugagent-desktop" -version = "0.2.1" +version = "0.2.2" dependencies = [ "axum", "bytes", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1fdc5f04..b04156a2 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hugagent-desktop" -version = "0.2.1" +version = "0.2.2" description = "HugAgentOS桌面客户端" edition = "2021" rust-version = "1.77" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4092c7a..8f75cccb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ use std::sync::Arc; use tauri::menu::{Menu, MenuItem}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::webview::PageLoadEvent; use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::DialogExt; @@ -153,7 +154,7 @@ async fn logout_desktop(app: tauri::AppHandle) { } pub fn run() { - tauri::Builder::default() + let app = tauri::Builder::default() // single-instance:第二次被 deep-link 拉起时,把 URL 转交给已运行实例。 .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { for arg in argv.iter() { @@ -187,10 +188,8 @@ pub fn run() { // 原生菜单栏事件分发(文件/编辑/视图/帮助)。 .on_menu_event(menu::handle) .invoke_handler(tauri::generate_handler![open_login, logout_desktop]) - // 关闭主窗口时不直接退出:首次弹出**自定义确认窗**(带「记住我的选择」勾选框) - // 问「最小化到托盘」还是「退出」。只有勾选后才记住,之后关闭直接执行、不再弹。 - // 可在托盘「关闭时重新询问」重置。(自定义窗而非原生对话框,是因为原生对话框 - // 不支持勾选框。) + // macOS 遵循平台习惯:红色关闭按钮只隐藏主窗口并继续驻留后台,退出由系统 + // 菜单或托盘显式执行。其他平台首次关闭时仍弹出自定义确认窗,可记住后续行为。 .on_window_event(|window, event| { // RDP/ToDesk 调整分辨率、或把窗口移到不同 DPI 的显示器时重新计算 zoom。 // `set_zoom` 不改变外窗尺寸,因此处理 Resized 不会形成窗口 resize 循环。 @@ -207,18 +206,28 @@ pub fn run() { return; } api.prevent_close(); - let app = window.app_handle().clone(); - let config_dir = app.state::().config_dir.clone(); - // 已记住选择 → 直接执行,不弹确认窗。 - match prefs::load_close_action(&config_dir) { - Some(prefs::CloseAction::Minimize) => { - let _ = window.hide(); - } - Some(prefs::CloseAction::Exit) => { - app.exit(0); + #[cfg(target_os = "macos")] + { + let _ = window.hide(); + return; + } + + #[cfg(not(target_os = "macos"))] + { + let app = window.app_handle().clone(); + let config_dir = app.state::().config_dir.clone(); + + // 已记住选择 → 直接执行,不弹确认窗。 + match prefs::load_close_action(&config_dir) { + Some(prefs::CloseAction::Minimize) => { + let _ = window.hide(); + } + Some(prefs::CloseAction::Exit) => { + app.exit(0); + } + None => open_close_confirm(&app), } - None => open_close_confirm(&app), } } }) @@ -378,8 +387,22 @@ pub fn run() { Ok(()) }) - .run(tauri::generate_context!()) + .build(tauri::generate_context!()) .expect("运行 Tauri 应用失败"); + + app.run(|_app_handle, _event| { + // 主窗口被红色关闭按钮隐藏后,点击 Dock 图标应立即恢复,而不是只激活一个 + // 没有可见窗口的后台进程。 + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Reopen { + has_visible_windows, + } = _event + { + if !has_visible_windows { + show_main_window(_app_handle); + } + } + }); } /// 显示并聚焦主窗口(从托盘恢复 / 单实例再次拉起 / deep-link 回跳时用)。 @@ -502,6 +525,8 @@ fn build_tray(app: &tauri::App) -> tauri::Result<()> { fn open_close_confirm(app: &tauri::AppHandle) { // 已经开着就聚焦,别重复弹。 if let Some(w) = app.get_webview_window("close-confirm") { + let _ = w.center(); + let _ = w.show(); let _ = w.set_focus(); return; } @@ -522,7 +547,18 @@ fn open_close_confirm(app: &tauri::AppHandle) { .always_on_top(true) .skip_taskbar(true) .center() - .focused(true) + // WebView2 first paints an empty native surface and only then loads the + // confirmation HTML. Keep it hidden until the final page-load event so + // Windows never exposes that white frame. + .visible(false) + .focused(false) + .on_page_load(|window, payload| { + if matches!(payload.event(), PageLoadEvent::Finished) { + let _ = window.center(); + let _ = window.show(); + let _ = window.set_focus(); + } + }) .on_navigation(move |u| { // 只拦哨兵;确认页自身的加载 / 其它放行。 if !(matches!(u.scheme(), "http" | "https") @@ -555,7 +591,9 @@ fn open_close_confirm(app: &tauri::AppHandle) { ); } if let Some(cw) = app2.get_webview_window("close-confirm") { - let _ = cw.close(); + // Reuse the fully-loaded WebView next time. Rebuilding it is + // both slower and the main source of close-dialog flicker. + let _ = cw.hide(); } if exit { app2.exit(0); diff --git a/desktop/src-tauri/src/local_server.rs b/desktop/src-tauri/src/local_server.rs index cfe4a82b..7f1bc59e 100644 --- a/desktop/src-tauri/src/local_server.rs +++ b/desktop/src-tauri/src/local_server.rs @@ -80,6 +80,16 @@ impl LocalServerManager { }) } + #[cfg(target_os = "macos")] + fn source_dir(&self) -> PathBuf { + let current = self.root.join("current"); + if current.is_dir() { + return current.join("source"); + } + self.root.join("source") + } + + #[cfg(not(target_os = "macos"))] fn source_dir(&self) -> PathBuf { self.root.join("source") } @@ -105,13 +115,33 @@ impl LocalServerManager { self.root.join("venv").join("Scripts").join("hugagent.exe") } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + fn executable(&self) -> PathBuf { + let current = self.root.join("current"); + if current.is_dir() { + return current.join("venv").join("bin").join("hugagent"); + } + self.root.join("venv").join("bin").join("hugagent") + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] fn executable(&self) -> PathBuf { self.root.join("venv").join("bin").join("hugagent") } + fn installed_manifest_path(&self) -> PathBuf { + #[cfg(target_os = "macos")] + { + let current = self.root.join("current"); + if current.is_dir() { + return current.join("desktop-bundle.json"); + } + } + self.root.join("installed-bundle.json") + } + pub fn is_installed(&self) -> bool { - self.executable().is_file() && self.root.join("installed-bundle.json").is_file() + self.executable().is_file() && self.installed_manifest_path().is_file() } pub fn needs_install(&self) -> bool { @@ -119,7 +149,7 @@ impl LocalServerManager { return true; } let bundled = std::fs::read_to_string(self.bundle_dir.join("desktop-bundle.json")); - let installed = std::fs::read_to_string(self.root.join("installed-bundle.json")); + let installed = std::fs::read_to_string(self.installed_manifest_path()); match (bundled, installed) { (Ok(a), Ok(b)) => a.trim() != b.trim(), _ => true, @@ -468,13 +498,38 @@ impl LocalServerManager { .map_err(|e| format!("等待安装器退出失败:{e}"))?; let _ = tokio::join!(out_task, err_task); if !exit.success() { - return Err(format!("依赖安装未完成(退出码 {:?})", exit.code())); + let error = format!("依赖安装未完成(退出码 {:?})", exit.code()); + if self.is_installed() { + self.append_log("新版本安装失败,正在恢复原有本机服务…") + .await; + if let Err(restart_error) = self.start_server().await { + self.append_log(format!("原有本机服务恢复失败:{restart_error}")) + .await; + } + } + return Err(error); } } self.update("starting", 92, "依赖安装完成,正在启动服务…") .await; - self.start_server().await + match self.start_server().await { + Ok(()) => Ok(()), + Err(start_error) => { + #[cfg(target_os = "macos")] + if restore_previous_release(&self.root)? { + self.append_log(format!("新版本启动失败,已回滚原有版本:{start_error}")) + .await; + self.start_server().await.map_err(|rollback_error| { + format!( + "新版本启动失败({start_error}),回滚后原有版本也无法启动({rollback_error})" + ) + })?; + return Err(format!("新版本启动失败,已自动恢复原有版本:{start_error}")); + } + Err(start_error) + } + } } #[cfg(any(target_os = "windows", target_os = "macos"))] @@ -503,7 +558,7 @@ impl LocalServerManager { return Ok(()); } } - stop_recorded_server(&self.pid_path(), &self.executable())?; + stop_recorded_server(&self.pid_path(), &self.executable(), &self.root)?; let _ = std::fs::remove_file(self.pid_path()); Ok(()) } @@ -548,7 +603,11 @@ fn hide_console(command: &mut Command) { fn hide_console(_command: &mut Command) {} #[cfg(target_os = "windows")] -fn stop_recorded_server(pid_path: &Path, expected_executable: &Path) -> Result<(), String> { +fn stop_recorded_server( + pid_path: &Path, + expected_executable: &Path, + _install_root: &Path, +) -> Result<(), String> { let Ok(raw_pid) = std::fs::read_to_string(pid_path) else { return Ok(()); }; @@ -595,12 +654,96 @@ fn stop_server_process( } } -#[cfg(not(target_os = "windows"))] -fn stop_recorded_server(pid_path: &Path, _expected_executable: &Path) -> Result<(), String> { +#[cfg(target_os = "macos")] +fn stop_recorded_server( + pid_path: &Path, + _expected_executable: &Path, + install_root: &Path, +) -> Result<(), String> { + let Ok(raw_pid) = std::fs::read_to_string(pid_path) else { + return Ok(()); + }; + let Ok(pid) = raw_pid.trim().parse::() else { + return Ok(()); + }; + let output = Command::new("/bin/ps") + .args(["-p", &pid.to_string(), "-o", "command="]) + .output() + .map_err(|error| format!("无法检查上次本机服务进程:{error}"))?; + if !output.status.success() { + return Ok(()); + } + let command_line = String::from_utf8_lossy(&output.stdout); + if !mac_server_command_matches(&command_line, install_root) { + return Ok(()); + } + + let pid_text = pid.to_string(); + let _ = Command::new("/bin/kill") + .args(["-TERM", &pid_text]) + .status(); + for _ in 0..20 { + if !mac_process_exists(&pid_text) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(100)); + } + let _ = Command::new("/bin/kill") + .args(["-KILL", &pid_text]) + .status(); + if mac_process_exists(&pid_text) { + return Err("无法结束上次遗留的本机服务进程".to_string()); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn mac_process_exists(pid: &str) -> bool { + Command::new("/bin/kill") + .args(["-0", pid]) + .status() + .map(|status| status.success()) + .unwrap_or(false) +} + +#[cfg(any(target_os = "macos", test))] +fn mac_server_command_matches(command_line: &str, install_root: &Path) -> bool { + let root = install_root.to_string_lossy(); + command_line.contains(root.as_ref()) + && command_line.contains("hugagent") + && command_line.contains(" serve") + && command_line.contains(&format!("--port {LOCAL_SERVER_PORT}")) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +fn stop_recorded_server( + pid_path: &Path, + _expected_executable: &Path, + _install_root: &Path, +) -> Result<(), String> { let _ = std::fs::remove_file(pid_path); Ok(()) } +#[cfg(any(target_os = "macos", test))] +fn restore_previous_release(root: &Path) -> Result { + let previous = root.join("current.previous"); + if !previous.is_symlink() { + return Ok(false); + } + let current = root.join("current"); + let failed_release = std::fs::read_link(¤t).ok(); + std::fs::rename(&previous, ¤t) + .map_err(|error| format!("恢复原有本机服务版本失败:{error}"))?; + if let Some(failed_release) = failed_release { + let releases = root.join("releases"); + if failed_release.starts_with(&releases) { + let _ = std::fs::remove_dir_all(failed_release); + } + } + Ok(true) +} + #[cfg(target_os = "windows")] fn hide_tokio_console(command: &mut tokio::process::Command) { use std::os::windows::process::CommandExt; @@ -662,6 +805,43 @@ mod tests { assert_eq!(tail_file(&log, 2), vec!["two", "three"]); } + #[test] + fn mac_stale_process_match_is_scoped_to_this_install_and_port() { + let root = Path::new("/Users/test/Library/Application Support/HugAgentOS/local-server"); + assert!(mac_server_command_matches( + "/Users/test/Library/Application Support/HugAgentOS/local-server/releases/abc/venv/bin/python /Users/test/Library/Application Support/HugAgentOS/local-server/current/venv/bin/hugagent serve --host 127.0.0.1 --port 32101", + root, + )); + assert!(!mac_server_command_matches( + "/tmp/hugagent serve --host 127.0.0.1 --port 32101", + root, + )); + assert!(!mac_server_command_matches( + "/Users/test/Library/Application Support/HugAgentOS/local-server/current/venv/bin/hugagent serve --port 32102", + root, + )); + } + + #[cfg(unix)] + #[test] + fn failed_release_can_atomically_restore_previous_pointer() { + use std::os::unix::fs::symlink; + + let manager = manager("rollback"); + let root = &manager.root; + let old = root.join("releases").join("old"); + let new = root.join("releases").join("new"); + std::fs::create_dir_all(&old).unwrap(); + std::fs::create_dir_all(&new).unwrap(); + symlink(&new, root.join("current")).unwrap(); + symlink(&old, root.join("current.previous")).unwrap(); + + assert!(restore_previous_release(root).unwrap()); + assert_eq!(std::fs::read_link(root.join("current")).unwrap(), old); + assert!(!root.join("current.previous").exists()); + assert!(!new.exists()); + } + #[cfg(target_os = "windows")] #[test] fn powershell_paths_drop_verbatim_prefixes() { diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs index 7c275cd9..1c5053b2 100644 --- a/desktop/src-tauri/src/proxy.rs +++ b/desktop/src-tauri/src/proxy.rs @@ -359,7 +359,7 @@ bar.addEventListener('dblclick',function(event){if(isControl(event.target))retur // we only reserve a compact draggable title region for the traffic lights; a // second branded toolbar would duplicate the native chrome and waste space. const MAC_TB_CSS: &str = r##" -#hugagent-mac-titlebar{position:fixed;inset:0 0 auto 0;height:38px;z-index:2147483647;background:rgba(250,250,248,.78);border-bottom:1px solid rgba(28,28,28,.075);backdrop-filter:saturate(160%) blur(18px);-webkit-backdrop-filter:saturate(160%) blur(18px);-webkit-user-select:none;user-select:none} +#hugagent-mac-titlebar{position:fixed;inset:0 0 auto 0;height:38px;z-index:2147483647;background:transparent;border:0;box-shadow:none;-webkit-user-select:none;user-select:none} #hugagent-mac-titlebar *{box-sizing:border-box} "##; @@ -534,64 +534,75 @@ const SETUP_HTML: &str = r##"
- 社区版 CE +

HugAgentOS 社区版

在这台电脑上开始使用

自动准备运行环境并启动本机服务。完成后即可直接进入 HugAgentOS,无需 Docker,也无需手动配置。

-

单用户运行数据保存在本机可随时切换服务器

+

运行环境与数据仅保存在这台电脑上

准备安装…0%
@@ -629,7 +640,7 @@ const SETUP_HTML: &str = r##" var button=document.getElementById('install'); button.disabled=true;button.textContent='正在开始…'; document.getElementById('connect').style.display='none'; - document.querySelector('.promise').style.display='none'; + document.querySelector('.privacy-note').style.display='none'; document.getElementById('progressWrap').style.display = 'block'; document.getElementById('message').textContent='正在准备本机服务…'; document.getElementById('error').style.display='none'; @@ -824,6 +835,9 @@ mod tests { let block = mac_titlebar_block(MAC_OFFSET_SPA); assert!(block.contains("hugagent-mac-titlebar")); assert!(block.contains("height:38px")); + assert!(block.contains("background:transparent")); + assert!(!block.contains("border-bottom")); + assert!(!block.contains("backdrop-filter")); assert!(!block.contains("data-act=")); assert!(!block.contains("mac-toolButton")); assert!(!block.contains("data-win=\"minimize\"")); @@ -843,6 +857,11 @@ mod tests { assert!(SETUP_HTML.contains("在这台 Mac 上开始使用")); assert_eq!(SETUP_HTML.matches("id=\"install\"").count(), 1); assert!(!SETUP_HTML.contains("class=\"choices\"")); + assert!(!SETUP_HTML.contains("border-top:1px solid")); + assert!(SETUP_HTML.contains("transform:scale(.97)")); + assert!(SETUP_HTML.contains("prefers-reduced-motion:reduce")); + assert!(SETUP_HTML.contains("prefers-reduced-transparency:reduce")); + assert!(SETUP_HTML.contains("prefers-contrast:more")); } #[test] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 45d705c0..02c651c5 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "HugAgentOS", - "version": "0.2.1", + "version": "0.2.2", "identifier": "com.hugagent.desktop", "build": { "frontendDist": "../../src/frontend/dist", From ff2651482bbd03ec28f494122497ff09f6305990 Mon Sep 17 00:00:00 2001 From: Luhaozhu Date: Wed, 22 Jul 2026 11:51:45 +0900 Subject: [PATCH 2/4] fix(desktop): support CRLF release manifests --- desktop/scripts/desktop-version.mjs | 4 ++-- desktop/scripts/desktop-version.test.mjs | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/desktop/scripts/desktop-version.mjs b/desktop/scripts/desktop-version.mjs index 7b6abd78..c2a30c96 100644 --- a/desktop/scripts/desktop-version.mjs +++ b/desktop/scripts/desktop-version.mjs @@ -27,7 +27,7 @@ export function readDesktopVersions(desktopDir) { ); const cargoVersion = cargoToml.match(/^version\s*=\s*"([^"]+)"/m)?.[1]; const cargoLockVersion = cargoLock.match( - /\[\[package\]\]\nname = "hugagent-desktop"\nversion = "([^"]+)"/, + /\[\[package\]\]\r?\nname = "hugagent-desktop"\r?\nversion = "([^"]+)"/, )?.[1]; return { @@ -112,7 +112,7 @@ export function setDesktopVersion(desktopDir, version) { writeFileSync(cargoPath, cargoToml, "utf8"); const cargoLock = readFileSync(cargoLockPath, "utf8").replace( - /(\[\[package\]\]\nname = "hugagent-desktop"\nversion = ")[^"]+("\n)/, + /(\[\[package\]\]\r?\nname = "hugagent-desktop"\r?\nversion = ")[^"]+("\r?\n)/, `$1${version}$2`, ); writeFileSync(cargoLockPath, cargoLock, "utf8"); diff --git a/desktop/scripts/desktop-version.test.mjs b/desktop/scripts/desktop-version.test.mjs index 06b1244b..6c7c725b 100644 --- a/desktop/scripts/desktop-version.test.mjs +++ b/desktop/scripts/desktop-version.test.mjs @@ -93,6 +93,20 @@ test("manual release derives its tag from the committed desktop version", () => } }); +test("reads and updates Cargo.lock with Windows CRLF line endings", () => { + const fixture = createDesktopFixture(); + try { + writeFileSync( + join(fixture.desktopDir, "src-tauri", "Cargo.lock"), + '[[package]]\r\nname = "hugagent-desktop"\r\nversion = "1.2.3"\r\n', + ); + assert.equal(readDesktopVersion(fixture.desktopDir), "1.2.3"); + assert.equal(setDesktopVersion(fixture.desktopDir, "1.3.0"), "1.3.0"); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +}); + test("version command synchronizes every desktop manifest", () => { const fixture = createDesktopFixture(); try { From 327a25b269f625ee46ed2dc2bddf692a3de1cb05 Mon Sep 17 00:00:00 2001 From: Luhaozhu Date: Wed, 22 Jul 2026 12:01:53 +0900 Subject: [PATCH 3/4] fix(desktop): match macOS reopen event safely --- desktop/src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8f75cccb..3a0b0b64 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -396,6 +396,7 @@ pub fn run() { #[cfg(target_os = "macos")] if let tauri::RunEvent::Reopen { has_visible_windows, + .. } = _event { if !has_visible_windows { From dc5c057e47a7c3605ed1ed261cc9c612e628491f Mon Sep 17 00:00:00 2001 From: Luhaozhu Date: Wed, 22 Jul 2026 15:21:55 +0900 Subject: [PATCH 4/4] feat: improve desktop runtime and user diagnostics --- .github/workflows/desktop-ci.yml | 33 +- .../server-bootstrap/install-local-server.ps1 | 72 ++- .../server-bootstrap/install-local-server.sh | 11 + desktop/scripts/mac-installer.test.mjs | 6 + desktop/src-tauri/src/local_server.rs | 19 +- src/backend/api/routes/v1/me_logs.py | 8 +- src/backend/core/llm/tools/write_tool.py | 5 +- .../services/script_runner_service/server.py | 220 +++++++-- src/backend/tests/api/test_me_logs.py | 23 +- .../tests/test_local_memory_defaults.py | 18 + .../test_script_runner_process_limits.py | 42 ++ src/frontend/src/api.ts | 82 +++- .../components/onboarding/FirstRunSetup.tsx | 47 +- .../src/components/settings/MyLogsPanel.tsx | 463 +++++++++++++++--- .../src/components/settings/SettingsModal.tsx | 37 +- .../src/components/sidebar/Sidebar.tsx | 46 +- src/frontend/src/i18n/en/adminSkills.ts | 1 + src/frontend/src/i18n/en/onboarding.ts | 1 + src/frontend/src/styles/onboarding.css | 40 +- 19 files changed, 1012 insertions(+), 162 deletions(-) diff --git a/.github/workflows/desktop-ci.yml b/.github/workflows/desktop-ci.yml index c2fc0823..acfe25ec 100644 --- a/.github/workflows/desktop-ci.yml +++ b/.github/workflows/desktop-ci.yml @@ -82,13 +82,30 @@ jobs: working-directory: desktop run: node scripts/prepare-bundle.mjs - - name: Install pinned uv for macOS dependency validation - if: matrix.os == 'macos-latest' + - name: Install pinned uv for local-server dependency validation + if: ${{ matrix.prepare_payload }} uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.11.30" enable-cache: false + - name: Resolve Windows local-server dependencies + if: matrix.os == 'windows-latest' + working-directory: desktop + shell: pwsh + run: | + uv pip install ` + --dry-run ` + --target "$env:RUNNER_TEMP\hugagent-deps-windows" ` + --python-version 3.11 ` + --python-platform x86_64-pc-windows-msvc ` + --requirements generated/server-ce/requirements.txt ` + --requirements generated/server-ce/requirements-mem0.txt ` + --requirements generated/server-ce/docker/requirements-script-runner.txt ` + "protobuf<7" ` + "pymilvus==2.5.18" ` + "milvus-lite==3.1.0" + - name: Validate macOS bootstrap on the native shell if: matrix.os == 'macos-latest' working-directory: desktop @@ -104,18 +121,26 @@ jobs: --python-version 3.11 \ --python-platform aarch64-apple-darwin \ --requirements generated/server-ce/requirements.txt \ + --requirements generated/server-ce/requirements-mem0.txt \ --requirements generated/server-ce/docker/requirements-script-runner.txt \ --overrides resources/server-bootstrap/requirements-macos-overrides.txt \ - --only-binary pikepdf + --only-binary pikepdf \ + "protobuf<7" \ + "pymilvus==2.5.18" \ + "milvus-lite==3.1.0" uv --system-certs pip install \ --dry-run \ --target "$RUNNER_TEMP/hugagent-deps-x86_64" \ --python-version 3.11 \ --python-platform x86_64-apple-darwin \ --requirements generated/server-ce/requirements.txt \ + --requirements generated/server-ce/requirements-mem0.txt \ --requirements generated/server-ce/docker/requirements-script-runner.txt \ --overrides resources/server-bootstrap/requirements-macos-overrides.txt \ - --only-binary pikepdf + --only-binary pikepdf \ + "protobuf<7" \ + "pymilvus==2.5.18" \ + "milvus-lite==3.1.0" - name: Run desktop Rust tests run: cargo test --manifest-path desktop/src-tauri/Cargo.toml diff --git a/desktop/resources/server-bootstrap/install-local-server.ps1 b/desktop/resources/server-bootstrap/install-local-server.ps1 index c75501c6..eb148cd6 100644 --- a/desktop/resources/server-bootstrap/install-local-server.ps1 +++ b/desktop/resources/server-bootstrap/install-local-server.ps1 @@ -103,12 +103,46 @@ function Resolve-Node { return $null } +function Resolve-Bash { + $Candidates = @( + (Join-Path $env:ProgramFiles "Git\bin\bash.exe"), + (Join-Path ${env:ProgramFiles(x86)} "Git\bin\bash.exe"), + (Join-Path $env:LOCALAPPDATA "Programs\Git\bin\bash.exe") + ) + $WinGetPackages = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Packages" + if (Test-Path $WinGetPackages) { + Get-ChildItem $WinGetPackages -Filter "bash.exe" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + if ($_.FullName -like "*Git*\bin\bash.exe") { + $Candidates += $_.FullName + } + } + } + foreach ($Candidate in $Candidates | Select-Object -Unique) { + if ($Candidate -and (Test-Path $Candidate)) { + try { + & $Candidate --version 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + return $Candidate + } + } + catch { + # Try the next native Git Bash candidate. + } + } + } + return $null +} + if (-not (Test-Path (Join-Path $BundleDir "pyproject.toml"))) { throw "The desktop package doesn't contain a valid CE server payload." } if (-not (Test-Path (Join-Path $BundleDir "src\frontend\dist\index.html"))) { throw "The bundled CE web application is missing." } +$MemoryRequirements = Join-Path $BundleDir "requirements-mem0.txt" +if (-not (Test-Path $MemoryRequirements)) { + throw "The desktop package doesn't contain the persistent-memory dependencies." +} New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null $SourceDir = Join-Path $InstallRoot "source" @@ -170,13 +204,49 @@ Invoke-Checked $VenvPython @( "-r", (Join-Path $SourceDir "requirements.txt") ) "Unable to install the server dependencies" +Write-ProgressLine 58 "正在安装永久记忆运行环境…" +Invoke-Checked $VenvPython @( + "-m", "pip", "install", "--disable-pip-version-check", "--prefer-binary", "--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 "正在安装本机脚本与文档处理能力…" Invoke-Checked $VenvPython @( "-m", "pip", "install", "--disable-pip-version-check", "--prefer-binary", "-r", (Join-Path $SourceDir "docker\requirements-script-runner.txt") ) "Unable to install the local tool dependencies" -Write-ProgressLine 77 "正在准备可选的 Node.js 文档能力…" +Write-ProgressLine 75 "正在准备本机 Bash 脚本能力…" +$BashExecutableFile = Join-Path $InstallRoot "bash-executable.txt" +$Bash = Resolve-Bash +if (-not $Bash) { + $Winget = Get-Command "winget.exe" -ErrorAction SilentlyContinue + if ($Winget) { + try { + Invoke-Checked $Winget.Source @( + "install", "--id", "Git.Git", "--exact", "--scope", "user", "--silent", + "--accept-package-agreements", "--accept-source-agreements", "--disable-interactivity" + ) "Unable to install Git Bash with winget" + $Bash = Resolve-Bash + } + catch { + Write-Warning "Git Bash couldn't be installed automatically. Python and JavaScript still work; Bash scripts remain unavailable. $($_.Exception.Message)" + } + } +} +if ($Bash) { + [System.IO.File]::WriteAllText( + $BashExecutableFile, + [string]$Bash, + [System.Text.UTF8Encoding]::new($false) + ) +} +elseif (Test-Path $BashExecutableFile) { + Remove-Item $BashExecutableFile -Force +} + +Write-ProgressLine 78 "正在准备可选的 Node.js 文档能力…" $NodeExecutableFile = Join-Path $InstallRoot "node-executable.txt" $Node = Resolve-Node if (-not $Node) { diff --git a/desktop/resources/server-bootstrap/install-local-server.sh b/desktop/resources/server-bootstrap/install-local-server.sh index f5142d15..16335241 100755 --- a/desktop/resources/server-bootstrap/install-local-server.sh +++ b/desktop/resources/server-bootstrap/install-local-server.sh @@ -40,6 +40,10 @@ if [[ ! -f "$BundleDir/src/frontend/dist/index.html" ]]; then echo "The bundled CE web application is missing." >&2 exit 3 fi +if [[ ! -f "$BundleDir/requirements-mem0.txt" ]]; then + echo "The desktop package doesn't contain the persistent-memory dependencies." >&2 + exit 3 +fi if [[ ! -f "$MacOverrides" ]]; then echo "The macOS dependency compatibility overrides are missing." >&2 exit 3 @@ -181,6 +185,13 @@ progress 42 "正在安装服务端依赖,首次安装需要数分钟…" uv_run pip install --python "$VenvPython" \ --requirements "$SourceDir/requirements.txt" +progress 58 "正在安装永久记忆运行环境…" +uv_run pip install --python "$VenvPython" --upgrade \ + --requirements "$SourceDir/requirements-mem0.txt" \ + "protobuf<7" \ + "pymilvus==2.5.18" \ + "milvus-lite==3.1.0" + progress 70 "正在安装本机脚本与文档处理能力…" uv_run pip install --python "$VenvPython" \ --requirements "$SourceDir/docker/requirements-script-runner.txt" \ diff --git a/desktop/scripts/mac-installer.test.mjs b/desktop/scripts/mac-installer.test.mjs index 624621b5..8c493aec 100644 --- a/desktop/scripts/mac-installer.test.mjs +++ b/desktop/scripts/mac-installer.test.mjs @@ -40,6 +40,7 @@ test("macOS bootstrap completes a clean CE install with an isolated runtime", () mkdirSync(join(bundle, "docker"), { recursive: true }); writeFileSync(join(bundle, "pyproject.toml"), "[project]\nname='test'\n"); writeFileSync(join(bundle, "requirements.txt"), ""); + writeFileSync(join(bundle, "requirements-mem0.txt"), "mem0ai>=0.1.50\n"); writeFileSync(join(bundle, "docker", "requirements-script-runner.txt"), ""); writeFileSync(join(bundle, "src", "frontend", "dist", "index.html"), "ok"); writeFileSync(join(bundle, "desktop-bundle.json"), '{"desktop_version":"test"}\n'); @@ -121,6 +122,9 @@ fi const uvCalls = readFileSync(uvLog, "utf8"); assert.match(uvCalls, /--overrides .*requirements-macos-overrides\.txt/); assert.match(uvCalls, /--only-binary pikepdf/); + assert.match(uvCalls, /--requirements .*requirements-mem0\.txt/); + assert.match(uvCalls, /pymilvus==2\.5\.18/); + assert.match(uvCalls, /milvus-lite==3\.1\.0/); assert.doesNotMatch(uvCalls, /--prefer-binary/); writeFileSync( @@ -221,6 +225,7 @@ test("macOS bootstrap leaves the previous release untouched after dependency fai mkdirSync(join(bundle, "docker"), { recursive: true }); writeFileSync(join(bundle, "pyproject.toml"), "[project]\nname='test'\n"); writeFileSync(join(bundle, "requirements.txt"), "broken>=1\n"); + writeFileSync(join(bundle, "requirements-mem0.txt"), "mem0ai>=0.1.50\n"); writeFileSync(join(bundle, "docker", "requirements-script-runner.txt"), ""); writeFileSync(join(bundle, "src", "frontend", "dist", "index.html"), "new"); writeFileSync(join(bundle, "desktop-bundle.json"), '{"desktop_version":"new"}\n'); @@ -295,6 +300,7 @@ test("macOS bootstrap stops before copying when free space is insufficient", () mkdirSync(join(bundle, "docker"), { recursive: true }); writeFileSync(join(bundle, "pyproject.toml"), "[project]\nname='test'\n"); writeFileSync(join(bundle, "requirements.txt"), ""); + writeFileSync(join(bundle, "requirements-mem0.txt"), "mem0ai>=0.1.50\n"); writeFileSync(join(bundle, "docker", "requirements-script-runner.txt"), ""); writeFileSync(join(bundle, "src", "frontend", "dist", "index.html"), "ok"); writeFileSync(join(bundle, "desktop-bundle.json"), '{"desktop_version":"test"}\n'); diff --git a/desktop/src-tauri/src/local_server.rs b/desktop/src-tauri/src/local_server.rs index 7f1bc59e..9aa70325 100644 --- a/desktop/src-tauri/src/local_server.rs +++ b/desktop/src-tauri/src/local_server.rs @@ -356,15 +356,16 @@ impl LocalServerManager { } fn apply_tool_path(&self, command: &mut Command) { - let Ok(node_executable) = std::fs::read_to_string(self.root.join("node-executable.txt")) - else { - return; - }; - let node_executable = PathBuf::from(node_executable.trim()); - let Some(node_dir) = node_executable.parent() else { - return; - }; - let mut paths = vec![node_dir.to_path_buf()]; + let mut paths = Vec::new(); + for filename in ["node-executable.txt", "bash-executable.txt"] { + let Ok(executable) = std::fs::read_to_string(self.root.join(filename)) else { + continue; + }; + let executable = PathBuf::from(executable.trim()); + if let Some(parent) = executable.parent() { + paths.push(parent.to_path_buf()); + } + } if let Some(current) = std::env::var_os("PATH") { paths.extend(std::env::split_paths(¤t)); } diff --git a/src/backend/api/routes/v1/me_logs.py b/src/backend/api/routes/v1/me_logs.py index 90671c44..5d0728b2 100644 --- a/src/backend/api/routes/v1/me_logs.py +++ b/src/backend/api/routes/v1/me_logs.py @@ -230,7 +230,7 @@ def list_my_subagent_logs( return paginated_response(items=items, page=page, page_size=page_size, total_items=total) -def _collect_subagent_subtree_ids(db: Session, root_id: str) -> List[str]: +def _collect_subagent_subtree_ids(db: Session, root_id: str, user_id: str) -> List[str]: """BFS over parent_subagent_log_id (actual depth ≤ 2, no CTE needed).""" all_ids = [root_id] frontier = [root_id] @@ -238,6 +238,7 @@ def _collect_subagent_subtree_ids(db: Session, root_id: str) -> List[str]: rows = ( db.query(SubAgentCallLog.id) .filter(SubAgentCallLog.parent_subagent_log_id.in_(frontier)) + .filter(SubAgentCallLog.user_id == user_id) .all() ) next_ids = [r.id for r in rows] @@ -262,15 +263,17 @@ def get_my_subagent_log( child_steps = ( db.query(SubAgentCallLog) .filter(SubAgentCallLog.parent_subagent_log_id == log_id) + .filter(SubAgentCallLog.user_id == user.user_id) .order_by(SubAgentCallLog.step_index) .all() ) detail["child_steps"] = [_serialize(s) for s in child_steps] - subtree_ids = _collect_subagent_subtree_ids(db, log_id) + subtree_ids = _collect_subagent_subtree_ids(db, log_id, user.user_id) tool_logs = ( db.query(ToolCallLog) .filter(ToolCallLog.subagent_log_id.in_(subtree_ids)) + .filter(ToolCallLog.user_id == user.user_id) .order_by(ToolCallLog.created_at) .all() ) @@ -279,6 +282,7 @@ def get_my_subagent_log( skill_logs = ( db.query(SkillCallLog) .filter(SkillCallLog.subagent_log_id.in_(subtree_ids)) + .filter(SkillCallLog.user_id == user.user_id) .order_by(SkillCallLog.created_at) .all() ) diff --git a/src/backend/core/llm/tools/write_tool.py b/src/backend/core/llm/tools/write_tool.py index f777209e..bab58bb2 100644 --- a/src/backend/core/llm/tools/write_tool.py +++ b/src/backend/core/llm/tools/write_tool.py @@ -199,7 +199,10 @@ async def Write( # ── Create parent directory (inside the sandbox) ───────────────── pd = parent_dir(physical) - if pd and pd != "/workspace": + # The host-local script runner implements parent creation inside its + # native put_file endpoint. Asking it to run POSIX ``mkdir -p`` first + # breaks standard Windows installations before the actual write starts. + if pd and pd != "/workspace" and provider.name != "script_runner": mk_exit, _, mk_err = await sandbox_exec_bash( f"mkdir -p {shell_quote(pd)}", chat_id=_sess, timeout=10, diff --git a/src/backend/services/script_runner_service/server.py b/src/backend/services/script_runner_service/server.py index 61a3919f..58e1f9ae 100644 --- a/src/backend/services/script_runner_service/server.py +++ b/src/backend/services/script_runner_service/server.py @@ -13,9 +13,9 @@ import mimetypes import os import re -import resource import signal import shutil +import subprocess import sys import tempfile import time @@ -25,6 +25,11 @@ from fastapi import FastAPI, HTTPException from pydantic import BaseModel +try: + import resource +except ImportError: # Windows does not provide the POSIX resource module. + resource = None # type: ignore[assignment] + logging.basicConfig(level=logging.INFO) logger = logging.getLogger("script-runner") @@ -47,16 +52,74 @@ # once here (invariant: WORKSPACE_ROOT is read from env at import). None in Docker, # where the roots are equal and no rewrite is needed. Match /workspace only at a # path boundary so an unrelated substring like /workspaces is left alone. -_WS_REWRITE = ( - (re.compile(r'/workspace(?=/|$|["\'\s:;)&|])'), WORKSPACE_ROOT.rstrip("/")) - if WORKSPACE_ROOT != "/workspace" - else None -) +_WS_PATH_RE = re.compile(r'(? str: + """Map canonical workspace references without treating ``\\`` as regex escapes.""" + if not isinstance(value, str) or workspace_root == "/workspace": + return value + replacement = workspace_root.rstrip("/\\") + return _WS_PATH_RE.sub(lambda _match: replacement, value) + + +def _execution_workspace_root( + language: str, + workspace_root: str = WORKSPACE_ROOT, + platform: str = os.name, +) -> str: + """Return the path syntax understood by the selected host interpreter.""" + if language != "bash" or platform != "nt": + return workspace_root + match = re.match(r"^([A-Za-z]):[\\/](.*)$", workspace_root) + if not match: + return workspace_root.replace("\\", "/") + drive, rest = match.groups() + return f"/{drive.lower()}/{rest.replace(chr(92), '/')}" + + +def _rewrite_execution_paths(value: str, language: str) -> str: + target_root = _execution_workspace_root(language) + if target_root != WORKSPACE_ROOT: + # File tools may already have expanded /workspace to the native root. + value = value.replace(WORKSPACE_ROOT, target_root) + return _rewrite_workspace_refs(value, target_root) + + +def _resolve_bash_executable() -> Optional[str]: + """Find a native Bash, excluding Windows' WSL launcher stubs.""" + configured = os.getenv("SCRIPT_RUNNER_BASH", "").strip() + candidates = [configured, shutil.which("bash") or ""] + if os.name == "nt": + for root in ( + os.getenv("ProgramFiles", ""), + os.getenv("ProgramFiles(x86)", ""), + str(Path(os.getenv("LOCALAPPDATA", "")) / "Programs"), + ): + if root: + candidates.append(str(Path(root) / "Git" / "bin" / "bash.exe")) + + for candidate in candidates: + if not candidate or not Path(candidate).is_file(): + continue + normalized = candidate.replace("/", "\\").casefold() + if os.name == "nt" and ( + "\\windows\\system32\\bash.exe" in normalized + or "\\microsoft\\windowsapps\\bash.exe" in normalized + ): + continue + return candidate + return None + + +_BASH_EXECUTABLE = _resolve_bash_executable() INTERPRETERS = { - "python": ["python3", "-u"], - "bash": ["bash"], - "javascript": ["node"], + # Use the running venv on local Windows/macOS/Linux installations. A bare + # ``python3`` is not installed on a standard Windows machine. + "python": [sys.executable, "-u"], + "bash": [_BASH_EXECUTABLE or "hugagent-git-bash-not-installed"], + "javascript": [shutil.which("node") or "node"], } # ── Generated-file capture ── @@ -84,13 +147,14 @@ } # Clean environment variables — leak no sensitive information +_TEMP_ROOT = tempfile.gettempdir() SAFE_ENV = { - "PATH": "/usr/local/bin:/usr/bin:/bin", - "HOME": "/tmp", - "TMPDIR": "/tmp", - "XDG_CACHE_HOME": "/tmp/.cache", - "FONTCONFIG_PATH": "/etc/fonts", - "FONTCONFIG_FILE": "/etc/fonts/fonts.conf", + "PATH": "" if os.name == "nt" else "/usr/local/bin:/usr/bin:/bin", + "HOME": os.getenv("USERPROFILE", _TEMP_ROOT) if os.name == "nt" else "/tmp", + "TMPDIR": _TEMP_ROOT, + "TEMP": _TEMP_ROOT, + "TMP": _TEMP_ROOT, + "XDG_CACHE_HOME": str(Path(_TEMP_ROOT) / ".cache"), "LANG": "en_US.UTF-8", "PYTHONIOENCODING": "utf-8", "MPLBACKEND": "Agg", # matplotlib non-interactive backend @@ -100,6 +164,19 @@ "DOTNET_NOLOGO": "1", # suppress dotnet startup banner "DOTNET_EnableDiagnostics": "0", # stop dotnet from creating diagnostic pipes/core dump files } +if os.name != "nt": + SAFE_ENV.update( + { + "FONTCONFIG_PATH": "/etc/fonts", + "FONTCONFIG_FILE": "/etc/fonts/fonts.conf", + } + ) +else: + # These variables are required by CreateProcess and common Windows CLIs. + for _key in ("SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"): + _val = os.getenv(_key) + if _val: + SAFE_ENV[_key] = _val for _key in ("NODE_PATH", "PLAYWRIGHT_BROWSERS_PATH", "JX_FONT_DIR"): _val = os.getenv(_key) if _val: @@ -124,7 +201,30 @@ def _local_safe_path_entries() -> list[str]: str(Path(skills_root) / skill_id / "scripts") for skill_id in _LOCAL_SKILL_CLI_IDS ) - for binary in ("node", "npm", "npx"): + if os.name == "nt": + system_root = os.getenv("SYSTEMROOT") or os.getenv("WINDIR") + if system_root: + entries.extend( + [ + str(Path(system_root)), + str(Path(system_root) / "System32"), + str(Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0"), + ] + ) + if _BASH_EXECUTABLE: + git_bin = Path(_BASH_EXECUTABLE).parent + entries.extend( + [ + str(git_bin), + str(git_bin.parent / "usr" / "bin"), + str(git_bin.parent / "cmd"), + ] + ) + + binaries = ("node", "npm", "npx") + if os.name != "nt": + binaries += ("bash",) + for binary in binaries: path = shutil.which(binary) if path: entries.append(os.path.dirname(path)) @@ -150,12 +250,14 @@ def _local_safe_path_entries() -> list[str]: SAFE_ENV[_k] = _v _extra_path = _local_safe_path_entries() if _extra_path: - SAFE_ENV["PATH"] = os.pathsep.join(_extra_path + [SAFE_ENV["PATH"]]) + SAFE_ENV["PATH"] = os.pathsep.join( + _extra_path + ([SAFE_ENV["PATH"]] if SAFE_ENV["PATH"] else []) + ) # npm/vite need a writable HOME for cache/config; keep the real one locally. - SAFE_ENV["HOME"] = os.getenv("HOME", "/tmp") + SAFE_ENV["HOME"] = os.getenv("HOME") or os.getenv("USERPROFILE") or _TEMP_ROOT # Pre-create fontconfig cache dir once (avoids per-request mkdir) -Path("/tmp/.cache/fontconfig").mkdir(parents=True, exist_ok=True) +Path(SAFE_ENV["XDG_CACHE_HOME"], "fontconfig").mkdir(parents=True, exist_ok=True) class ExecuteRequest(BaseModel): @@ -355,15 +457,17 @@ async def execute(req: ExecuteRequest): # Local profile: the model writes container-canonical /workspace/... paths (from # the system prompt, skills, and plugin scripts). Alias them to the real root so - # bash/python that touch /workspace resolve. _WS_REWRITE is None in Docker. - if _WS_REWRITE is not None: - _re, _repl = _WS_REWRITE - _canon = lambda s: _re.sub(_repl, s) if isinstance(s, str) else s - req.script_content = _canon(req.script_content) + # bash/python that touch /workspace resolve. A callable replacement is + # essential on Windows because ``C:\\Users`` contains regex escape syntax. + if WORKSPACE_ROOT != "/workspace": + req.script_content = _rewrite_execution_paths(req.script_content, req.language) if isinstance(req.params, dict) and req.params: _args = req.params.get("_args") if isinstance(_args, list): - req.params["_args"] = [_canon(a) if isinstance(a, str) else a for a in _args] + req.params["_args"] = [ + _rewrite_execution_paths(a, req.language) if isinstance(a, str) else a + for a in _args + ] # ── Filename safety validation (prevent path traversal) ── _validate_filename(req.script_name) @@ -500,7 +604,20 @@ async def _execute_subprocess(cmd: list, stdin_data: str, timeout: int, cwd: str def _set_limits(): # Keep the post-fork callback minimal: non-async-safe Python work in a # multi-threaded server's preexec_fn can deadlock before exec(). - resource.setrlimit(resource.RLIMIT_NPROC, (nproc_limit, nproc_limit)) + if resource is not None and nproc_limit is not None: + resource.setrlimit(resource.RLIMIT_NPROC, (nproc_limit, nproc_limit)) + + if os.name == "nt": + spawn_options: Dict[str, Any] = { + "creationflags": subprocess.CREATE_NEW_PROCESS_GROUP, + } + else: + spawn_options = { + # Host-local quick installs intentionally pass no preexec_fn at all. + "preexec_fn": _set_limits if nproc_limit is not None else None, + # Give every execution its own process group for descendant cleanup. + "start_new_session": True, + } proc: Optional[asyncio.subprocess.Process] = None # Do not expose PIPE file descriptors to document-tool descendants. Some @@ -523,14 +640,7 @@ def _set_limits(): stderr=stderr_file, cwd=cwd, env=SAFE_ENV, - # Host-local quick installs intentionally pass no preexec_fn at all. - # Apart from avoiding the UID-wide limit, this also avoids a - # post-fork Python callback in the multi-threaded backend process. - preexec_fn=_set_limits if nproc_limit is not None else None, - # Give every execution its own process group. A document skill may - # fan out through bash -> Python/Node/LibreOffice; killing only bash - # on timeout otherwise leaves those descendants running forever. - start_new_session=True, + **spawn_options, ) await asyncio.wait_for(_wait_for_process_exit(proc), timeout=timeout) exit_code = proc.returncode or 0 @@ -558,7 +668,15 @@ def _set_limits(): except Exception as e: await _terminate_process_group(proc) logger.exception("subprocess execution failed") - return {"stdout": "", "stderr": str(e), "exit_code": -1} + detail = str(e) + if ( + isinstance(e, FileNotFoundError) + and os.name == "nt" + and cmd + and Path(str(cmd[0])).stem.lower() in {"bash", "hugagent-git-bash-not-installed"} + ): + detail = "Windows 本机未找到 Bash;请安装 Git for Windows 后重启桌面客户端" + return {"stdout": "", "stderr": detail, "exit_code": -1} def _subprocess_nproc_limit(cmd: list) -> Optional[int]: @@ -573,6 +691,8 @@ def _subprocess_nproc_limit(cmd: list) -> Optional[int]: UID namespace plus a cgroup ``pids_limit``, so retain the defence in depth there and skip only the unsafe host-local limit. """ + if resource is None or os.name == "nt": + return None if os.getenv("DEPLOY_PROFILE", "").strip().lower() == "local": return None @@ -590,6 +710,36 @@ async def _terminate_process_group( """Kill and reap one execution process together with all descendants.""" if proc is None: return + if os.name == "nt": + # Once the leader has exited Windows may immediately recycle its PID; + # taskkill on that stale PID could target an unrelated process. Timeout + # and cancellation reach this branch while the leader is still alive. + if proc.returncode is not None: + return + try: + await asyncio.to_thread( + subprocess.run, + ["taskkill.exe", "/PID", str(proc.pid), "/T", "/F"], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except (FileNotFoundError, OSError): + if proc.returncode is None: + try: + proc.kill() + except ProcessLookupError: + pass + if proc.returncode is None: + try: + await asyncio.wait_for(proc.wait(), timeout=2) + except asyncio.TimeoutError: + try: + proc.kill() + except ProcessLookupError: + pass + await proc.wait() + return try: os.killpg(proc.pid, signal.SIGKILL) except ProcessLookupError: diff --git a/src/backend/tests/api/test_me_logs.py b/src/backend/tests/api/test_me_logs.py index 20497154..a705d683 100644 --- a/src/backend/tests/api/test_me_logs.py +++ b/src/backend/tests/api/test_me_logs.py @@ -13,7 +13,7 @@ from fastapi import HTTPException from core.auth.backend import UserContext -from core.db.models import ChatMessage, ChatSession, SubAgentCallLog, ToolCallLog +from core.db.models import ChatMessage, ChatSession, SkillCallLog, SubAgentCallLog, ToolCallLog def _ctx(user_id: str) -> UserContext: @@ -39,6 +39,14 @@ def seeded(db_session): ToolCallLog(id="t_sub", user_id="alice", tool_name="read_file", status="success", source="subagent", subagent_log_id="sa_child", created_at=now), + SkillCallLog(id="sk_alice", user_id="alice", skill_id="reports", + skill_name="报告生成", invocation_type="run_script", + script_args={"format": "docx"}, script_stdout="done", + status="success", source="subagent", + subagent_log_id="sa_child", created_at=now), + SkillCallLog(id="sk_bob", user_id="bob", skill_id="private", + skill_name="他人技能", invocation_type="view", + status="success", source="main_agent", created_at=now), ChatSession(chat_id="c1", user_id="alice", title="会话一"), ChatSession(chat_id="c2", user_id="bob", title="别人的"), ChatMessage(message_id="m1", chat_id="c1", role="assistant", content="", model="deepseek", @@ -84,6 +92,18 @@ def test_tool_log_detail_owner_ok_foreign_404(db_session, seeded): assert exc.value.status_code == 404 +def test_skill_log_detail_owner_ok_foreign_404(db_session, seeded): + from api.routes.v1.me_logs import get_my_skill_log + + resp = get_my_skill_log("sk_alice", user=_ctx("alice"), db=db_session) + assert resp["data"]["skill_id"] == "reports" + assert resp["data"]["script_args"] == {"format": "docx"} + + with pytest.raises(HTTPException) as exc: + get_my_skill_log("sk_bob", user=_ctx("alice"), db=db_session) + assert exc.value.status_code == 404 + + # ── Subagent detail: subtree aggregation ──────────────────────────────────────────────── @@ -94,6 +114,7 @@ def test_subagent_detail_includes_subtree(db_session, seeded): data = resp["data"] assert [s["id"] for s in data["child_steps"]] == ["sa_child"] assert [t["id"] for t in data["tool_calls"]] == ["t_sub"] + assert [s["id"] for s in data["skill_calls"]] == ["sk_alice"] with pytest.raises(HTTPException) as exc: get_my_subagent_log("sa_root", user=_ctx("bob"), db=db_session) diff --git a/src/backend/tests/test_local_memory_defaults.py b/src/backend/tests/test_local_memory_defaults.py index d1e69f8a..fe628468 100644 --- a/src/backend/tests/test_local_memory_defaults.py +++ b/src/backend/tests/test_local_memory_defaults.py @@ -3,6 +3,7 @@ from pathlib import Path import cli +import pytest def test_local_profile_enables_memory_runtime_by_default(tmp_path, monkeypatch): @@ -29,3 +30,20 @@ def test_ce_installer_pins_compatible_milvus_lite_stack(): assert '"protobuf<7"' in installer assert "pymilvus>=2.5.0,<2.6.0" in requirements assert "pymilvus[milvus-lite]>=2.5.0" not in installer + + +@pytest.mark.parametrize( + "relative_path", + [ + "desktop/resources/server-bootstrap/install-local-server.ps1", + "desktop/resources/server-bootstrap/install-local-server.sh", + ], +) +def test_desktop_installer_includes_persistent_memory_runtime(relative_path): + repo_root = Path(__file__).resolve().parents[3] + installer = (repo_root / relative_path).read_text(encoding="utf-8-sig") + + assert "requirements-mem0.txt" in installer + assert "protobuf<7" in installer + assert "pymilvus==2.5.18" in installer + assert "milvus-lite==3.1.0" in installer diff --git a/src/backend/tests/test_script_runner_process_limits.py b/src/backend/tests/test_script_runner_process_limits.py index efd6e857..b1971955 100644 --- a/src/backend/tests/test_script_runner_process_limits.py +++ b/src/backend/tests/test_script_runner_process_limits.py @@ -37,6 +37,48 @@ def test_local_safe_path_exposes_venv_and_office_skill_shims(monkeypatch, tmp_pa assert str(skills_root / skill_id / "scripts") in entries +def test_python_execution_uses_the_running_virtualenv(): + assert server.INTERPRETERS["python"] == [sys.executable, "-u"] + + +def test_windows_installer_provisions_native_git_bash(): + repo_root = Path(__file__).resolve().parents[3] + installer = ( + repo_root / "desktop" / "resources" / "server-bootstrap" / "install-local-server.ps1" + ).read_text(encoding="utf-8-sig") + + assert "Git.Git" in installer + assert "bash-executable.txt" in installer + assert '"node-executable.txt", "bash-executable.txt"' in ( + repo_root / "desktop" / "src-tauri" / "src" / "local_server.rs" + ).read_text(encoding="utf-8") + + +def test_windows_workspace_rewrite_treats_backslashes_literally(): + workspace = r"C:\Users\Aaron\AppData\Local\com.hugagent.desktop\local-server\data\workspace" + + rewritten = server._rewrite_workspace_refs( + 'open("/workspace/myspace/report.txt")', workspace + ) + + assert rewritten == ( + 'open("C:\\Users\\Aaron\\AppData\\Local\\com.hugagent.desktop\\local-server' + '\\data\\workspace/myspace/report.txt")' + ) + assert server._rewrite_workspace_refs( + rf"{workspace}/myspace/report.txt", workspace + ) == rf"{workspace}/myspace/report.txt" + + +def test_windows_git_bash_receives_msys_workspace_path(): + workspace = r"C:\Users\Aaron\AppData\Local\com.hugagent.desktop\local-server\data\workspace" + + assert server._execution_workspace_root("bash", workspace, "nt") == ( + "/c/Users/Aaron/AppData/Local/com.hugagent.desktop/local-server/data/workspace" + ) + assert server._execution_workspace_root("python", workspace, "nt") == workspace + + def test_timeout_kills_the_whole_process_group(monkeypatch, tmp_path): """A timed-out bash tree must not leave document-tool descendants alive.""" monkeypatch.setenv("DEPLOY_PROFILE", "local") diff --git a/src/frontend/src/api.ts b/src/frontend/src/api.ts index 3a0cbf16..8a5a1b26 100644 --- a/src/frontend/src/api.ts +++ b/src/frontend/src/api.ts @@ -3624,40 +3624,97 @@ function logQueryString(q: MyLogQuery): string { export interface MyToolLogItem { id: string; + trace_id?: string | null; chat_id?: string | null; + message_id?: string | null; session_title?: string | null; + user_name?: string | null; tool_name: string; tool_display_name?: string | null; + tool_call_id?: string | null; + mcp_server?: string | null; + sandbox_id?: string | null; + tool_args?: unknown; + tool_result?: unknown; + result_truncated?: boolean; status: string; source: string; duration_ms?: number | null; error_message?: string | null; + subagent_log_id?: string | null; + skill_log_id?: string | null; + started_at?: string | null; created_at?: string | null; - [key: string]: unknown; } export interface MySkillLogItem { id: string; + trace_id?: string | null; chat_id?: string | null; + message_id?: string | null; session_title?: string | null; - skill_name: string; + user_name?: string | null; + skill_id: string; + skill_name?: string | null; + skill_version?: string | null; + skill_source?: string | null; invocation_type?: string | null; + script_name?: string | null; + script_language?: string | null; + script_args?: unknown; + script_stdin?: string | null; + script_stdout?: string | null; + script_stderr?: string | null; + output_truncated?: boolean; + exit_code?: number | null; status: string; + source?: string | null; duration_ms?: number | null; error_message?: string | null; + subagent_log_id?: string | null; + started_at?: string | null; created_at?: string | null; - [key: string]: unknown; } export interface MySubagentLogItem { id: string; + trace_id?: string | null; chat_id?: string | null; + message_id?: string | null; session_title?: string | null; + user_name?: string | null; + subagent_id?: string | null; subagent_name: string; + subagent_type?: string | null; + plan_id?: string | null; + step_id?: string | null; + step_index?: number | null; + step_title?: string | null; + model?: string | null; + input_messages?: unknown; + output_content?: string | null; + intermediate_steps?: unknown; + token_usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + llm_call_count?: number; + } | null; + tool_calls_count?: number; + skill_calls_count?: number; status: string; + error_message?: string | null; duration_ms?: number | null; + parent_subagent_log_id?: string | null; + started_at?: string | null; + completed_at?: string | null; created_at?: string | null; - [key: string]: unknown; +} + +export interface MySubagentLogDetail extends MySubagentLogItem { + child_steps: MySubagentLogItem[]; + tool_calls: MyToolLogItem[]; + skill_calls: MySkillLogItem[]; } export interface MyUsageItem { @@ -3690,14 +3747,31 @@ export function getMyToolLogs(q: MyLogQuery = {}): Promise('/v1/me/logs/tools', q); } +export async function getMyToolLog(logId: string): Promise { + const wrapped = await apiRequest(`/v1/me/logs/tools/${encodeURIComponent(logId)}`); + return unwrapData(wrapped); +} + export function getMySkillLogs(q: MyLogQuery = {}): Promise> { return fetchLogPage('/v1/me/logs/skills', q); } +export async function getMySkillLog(logId: string): Promise { + const wrapped = await apiRequest(`/v1/me/logs/skills/${encodeURIComponent(logId)}`); + return unwrapData(wrapped); +} + export function getMySubagentLogs(q: MyLogQuery = {}): Promise> { return fetchLogPage('/v1/me/logs/subagents', q); } +export async function getMySubagentLog(logId: string): Promise { + const wrapped = await apiRequest( + `/v1/me/logs/subagents/${encodeURIComponent(logId)}`, + ); + return unwrapData(wrapped); +} + export function getMyUsage(q: MyLogQuery = {}): Promise> { return fetchLogPage('/v1/me/logs/usage', q); } diff --git a/src/frontend/src/components/onboarding/FirstRunSetup.tsx b/src/frontend/src/components/onboarding/FirstRunSetup.tsx index 41c0d4db..2b1d9abb 100644 --- a/src/frontend/src/components/onboarding/FirstRunSetup.tsx +++ b/src/frontend/src/components/onboarding/FirstRunSetup.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CheckCircleFilled, + ExclamationCircleFilled, RobotOutlined, SafetyCertificateOutlined, ThunderboltOutlined, @@ -11,6 +12,7 @@ import { Button, Input, InputNumber, + Modal, Select, Skeleton, Space, @@ -129,11 +131,15 @@ function configuredSecret(value: string | null | undefined): boolean { export function FirstRunSetup({ user, onComplete }: FirstRunSetupProps) { const brandName = usePageConfig('branding.product_name', 'HugAgentOS'); const doLogout = useAuthStore((state) => state.doLogout); + const loggingOut = useAuthStore((state) => state.loggingOut); const [step, setStep] = useState(() => safeStoredStep(user.user_id)); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [testingGroup, setTestingGroup] = useState(null); const [loadError, setLoadError] = useState(''); + const [submitError, setSubmitError] = useState(''); + const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); + const cardBodyRef = useRef(null); const [language, setLanguage] = useState(getLang()); const [preferences, setPreferences] = useState({}); const [providers, setProviders] = useState([]); @@ -164,6 +170,7 @@ export function FirstRunSetup({ user, onComplete }: FirstRunSetupProps) { const [activeOntologyCount, setActiveOntologyCount] = useState(0); const persistStep = useCallback((next: number) => { + setSubmitError(''); setStep(next); try { window.localStorage.setItem(`${STEP_STORAGE_PREFIX}${user.user_id}`, String(next)); @@ -253,6 +260,10 @@ export function FirstRunSetup({ user, onComplete }: FirstRunSetupProps) { void load(); }, [load]); + useEffect(() => { + cardBodyRef.current?.scrollTo({ top: 0 }); + }, [step]); + const currentMainProvider = useMemo(() => { const mainRole = roles.find((role) => role.role_key === 'main_agent'); return providers.find( @@ -420,6 +431,7 @@ export function FirstRunSetup({ user, onComplete }: FirstRunSetupProps) { }; const handleNext = async () => { + setSubmitError(''); setBusy(true); try { if (step === 0) { @@ -457,7 +469,7 @@ export function FirstRunSetup({ user, onComplete }: FirstRunSetupProps) { } persistStep(Math.min(step + 1, STEP_META.length - 1)); } catch (error) { - message.error((error as Error).message || t('保存失败,请重试')); + setSubmitError((error as Error).message || t('保存失败,请重试')); } finally { setBusy(false); } @@ -955,7 +967,7 @@ export function FirstRunSetup({ user, onComplete }: FirstRunSetupProps) { /> Community Edition - @@ -1005,7 +1017,7 @@ export function FirstRunSetup({ user, onComplete }: FirstRunSetupProps) { {t(stepDescriptions[step][1])} -
+
+ {submitError && ( + setSubmitError('')} + /> + )} +
+ + {t('确认退出登录?')}} + open={logoutConfirmOpen} + okText={t('退出登录')} + cancelText={t('取消')} + okButtonProps={{ danger: true }} + confirmLoading={loggingOut} + maskClosable={!loggingOut} + closable={!loggingOut} + onCancel={() => setLogoutConfirmOpen(false)} + onOk={() => void doLogout()} + > + {t('退出登录不会丢失任何数据,你仍可以登录此账号。')} + ); } diff --git a/src/frontend/src/components/settings/MyLogsPanel.tsx b/src/frontend/src/components/settings/MyLogsPanel.tsx index c35b1564..cf862358 100644 --- a/src/frontend/src/components/settings/MyLogsPanel.tsx +++ b/src/frontend/src/components/settings/MyLogsPanel.tsx @@ -1,14 +1,31 @@ import { useCallback, useEffect, useState } from 'react'; -import { Segmented, Table, Tag, Typography, message } from 'antd'; +import { + Button, + Card, + Descriptions, + Drawer, + Segmented, + Space, + Table, + Tabs, + Tag, + Timeline, + Typography, + message, +} from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { + getMySkillLog, getMySkillLogs, + getMySubagentLog, getMySubagentLogs, + getMyToolLog, getMyToolLogs, getMyUsage, getMyUsageSummary, type MyLogPage, type MySkillLogItem, + type MySubagentLogDetail, type MySubagentLogItem, type MyToolLogItem, type MyUsageItem, @@ -16,58 +33,109 @@ import { } from '../../api'; import { t } from '../../i18n'; -const { Text } = Typography; +const { Paragraph, Text } = Typography; type LogKind = 'tools' | 'skills' | 'subagents' | 'usage'; +type DetailKind = Exclude; +type LogRow = MyToolLogItem | MySkillLogItem | MySubagentLogItem | MyUsageItem; +type LogDetail = + | { kind: 'tools'; data: MyToolLogItem } + | { kind: 'skills'; data: MySkillLogItem } + | { kind: 'subagents'; data: MySubagentLogDetail }; const PAGE_SIZE = 20; +const STATUS_COLORS: Record = { + running: 'processing', + success: 'success', + failed: 'error', + timeout: 'warning', + cancelled: 'default', +}; +const SOURCE_COLORS: Record = { + main_agent: 'blue', + subagent: 'purple', + skill: 'cyan', + automation: 'gold', +}; +const INVOCATION_COLORS: Record = { + view: 'blue', + run_script: 'green', + auto_load: 'geekblue', +}; + +function fmtTime(value?: string | null): string { + if (!value) return '—'; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) + ? value + : parsed.toLocaleString('zh-CN', { hour12: false }); +} + +function statusTag(status: string) { + return {status}; +} -function fmtTime(s?: string | null): string { - if (!s) return '—'; +function duration(value?: number | null): string { + return value != null ? `${value} ms` : '—'; +} + +function jsonText(value: unknown): string { + if (value == null) return '—'; try { - return new Date(s).toLocaleString('zh-CN', { hour12: false }); + return JSON.stringify(value, null, 2) ?? '—'; } catch { - return s; + return String(value); } } -function statusTag(status: string) { - const color = status === 'success' ? 'success' : status === 'running' ? 'processing' : 'error'; - return {status}; +function DetailCard({ title, value, maxHeight = 300 }: { + title: string; + value: unknown; + maxHeight?: number; +}) { + return ( + +
+        {typeof value === 'string' ? value || '—' : jsonText(value)}
+      
+
+ ); } -/** - * "Settings → System Management → My Logs" panel: shows the current user's own - * tool / skill / sub-agent invocation logs and model usage (/v1/me/logs; the - * backend forces user_id = the current user). - */ +/** Current-user-only log browser. Detail endpoints repeat the backend ownership check. */ export function MyLogsPanel() { const [kind, setKind] = useState('tools'); - const [rows, setRows] = useState>>([]); + const [rows, setRows] = useState([]); const [summary, setSummary] = useState([]); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [detailKind, setDetailKind] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); - const reload = useCallback(async (k: LogKind, p: number) => { + const reload = useCallback(async (targetKind: LogKind, targetPage: number) => { setLoading(true); try { - let resp: MyLogPage; - if (k === 'tools') resp = await getMyToolLogs({ page: p, pageSize: PAGE_SIZE }); - else if (k === 'skills') resp = await getMySkillLogs({ page: p, pageSize: PAGE_SIZE }); - else if (k === 'subagents') resp = await getMySubagentLogs({ page: p, pageSize: PAGE_SIZE }); - else { - const [usage, sum] = await Promise.all([ - getMyUsage({ page: p, pageSize: PAGE_SIZE }), + let response: MyLogPage; + if (targetKind === 'tools') { + response = await getMyToolLogs({ page: targetPage, pageSize: PAGE_SIZE }); + } else if (targetKind === 'skills') { + response = await getMySkillLogs({ page: targetPage, pageSize: PAGE_SIZE }); + } else if (targetKind === 'subagents') { + response = await getMySubagentLogs({ page: targetPage, pageSize: PAGE_SIZE }); + } else { + const [usage, usageSummary] = await Promise.all([ + getMyUsage({ page: targetPage, pageSize: PAGE_SIZE }), getMyUsageSummary('model'), ]); - setSummary(sum); - resp = usage; + setSummary(usageSummary); + response = usage; } - setRows(resp.items as Array>); - setTotal(resp.pagination.total_items); - } catch (e) { - message.error(t('加载日志失败:{msg}', { msg: (e as Error).message })); + setRows(response.items); + setTotal(response.pagination.total_items); + } catch (error) { + message.error(t('加载日志失败:{msg}', { msg: (error as Error).message })); } finally { setLoading(false); } @@ -75,61 +143,312 @@ export function MyLogsPanel() { useEffect(() => { void reload(kind, page); }, [kind, page, reload]); - const commonCols: ColumnsType> = [ + const openDetail = async (targetKind: DetailKind, logId: string) => { + setDetailKind(targetKind); + setDetail(null); + setDetailLoading(true); + try { + if (targetKind === 'tools') { + setDetail({ kind: targetKind, data: await getMyToolLog(logId) }); + } else if (targetKind === 'skills') { + setDetail({ kind: targetKind, data: await getMySkillLog(logId) }); + } else { + setDetail({ kind: targetKind, data: await getMySubagentLog(logId) }); + } + } catch (error) { + message.error(t('加载日志详情失败:{msg}', { msg: (error as Error).message })); + setDetailKind(null); + } finally { + setDetailLoading(false); + } + }; + + const commonColumns: ColumnsType = [ { - title: t('时间'), - dataIndex: 'created_at', - width: 165, - render: (v: string | null) => {fmtTime(v)}, + title: t('时间'), dataIndex: 'created_at', width: 165, + render: (value: string | null) => {fmtTime(value)}, }, { - title: t('会话'), - dataIndex: 'session_title', - ellipsis: true, - render: (v: string | null) => v || '—', + title: t('会话'), dataIndex: 'session_title', ellipsis: true, + render: (value: string | null) => value || '—', }, ]; - const columnsByKind: Record>> = { + const detailAction = (targetKind: DetailKind) => ({ + title: t('操作'), + key: 'action', + width: 72, + fixed: 'right' as const, + render: (_value: unknown, record: LogRow) => ( + 'id' in record && ( + + ) + ), + }); + + const columnsByKind: Record> = { tools: [ - ...commonCols, + ...commonColumns, { - title: t('工具'), - dataIndex: 'tool_name', - render: (v: string, r) => (r.tool_display_name as string) || v, + title: t('工具'), dataIndex: 'tool_name', + render: (value: string, record: LogRow) => ( + 'tool_display_name' in record ? record.tool_display_name || value : value + ), }, { title: t('来源'), dataIndex: 'source', width: 100 }, - { title: t('耗时'), dataIndex: 'duration_ms', width: 90, render: (v: number | null) => (v != null ? `${v}ms` : '—') }, - { title: t('状态'), dataIndex: 'status', width: 90, render: (v: string) => statusTag(v) }, + { title: t('耗时'), dataIndex: 'duration_ms', width: 90, render: duration }, + { title: t('状态'), dataIndex: 'status', width: 90, render: statusTag }, + detailAction('tools'), ], skills: [ - ...commonCols, - { title: t('技能'), dataIndex: 'skill_name' }, - { title: t('调用类型'), dataIndex: 'invocation_type', width: 110, render: (v: string | null) => v || '—' }, - { title: t('耗时'), dataIndex: 'duration_ms', width: 90, render: (v: number | null) => (v != null ? `${v}ms` : '—') }, - { title: t('状态'), dataIndex: 'status', width: 90, render: (v: string) => statusTag(v) }, + ...commonColumns, + { + title: t('技能'), dataIndex: 'skill_name', + render: (value: string | null, record: LogRow) => ( + value || ('skill_id' in record ? record.skill_id : '—') + ), + }, + { + title: t('调用类型'), dataIndex: 'invocation_type', width: 110, + render: (value: string | null) => value || '—', + }, + { title: t('耗时'), dataIndex: 'duration_ms', width: 90, render: duration }, + { title: t('状态'), dataIndex: 'status', width: 90, render: statusTag }, + detailAction('skills'), ], subagents: [ - ...commonCols, + ...commonColumns, { title: t('子智能体'), dataIndex: 'subagent_name' }, - { title: t('耗时'), dataIndex: 'duration_ms', width: 90, render: (v: number | null) => (v != null ? `${v}ms` : '—') }, - { title: t('状态'), dataIndex: 'status', width: 90, render: (v: string) => statusTag(v) }, + { title: t('耗时'), dataIndex: 'duration_ms', width: 90, render: duration }, + { title: t('状态'), dataIndex: 'status', width: 90, render: statusTag }, + detailAction('subagents'), ], usage: [ - ...commonCols, - { title: t('模型'), dataIndex: 'model', render: (v: string | null) => v || '—' }, + ...commonColumns, + { title: t('模型'), dataIndex: 'model', render: (value: string | null) => value || '—' }, { title: t('输入 Token'), dataIndex: 'prompt_tokens', width: 110 }, { title: t('输出 Token'), dataIndex: 'completion_tokens', width: 110 }, { title: t('合计'), dataIndex: 'total_tokens', width: 90 }, ], }; + const renderToolDetail = (item: MyToolLogItem) => ( + + + {item.tool_name} + {item.mcp_server || '—'} + {statusTag(item.status)} + {duration(item.duration_ms)} + + {item.source} + + {fmtTime(item.created_at)} + {item.session_title || '—'} + {item.sandbox_id || '—'} + + {item.trace_id || '—'} + + {item.error_message && ( + + {item.error_message} + + )} + + + + + ); + + const renderSkillDetail = (item: MySkillLogItem) => ( + + {item.skill_id} + {item.skill_name || '—'} + {item.skill_version || '—'} + {item.skill_source || '—'} + + + {item.invocation_type || '—'} + + + {statusTag(item.status)} + {item.script_name || '—'} + {item.script_language || '—'} + {duration(item.duration_ms)} + {item.exit_code ?? '—'} + {item.session_title || '—'} + + {item.trace_id || '—'} + + {item.error_message && ( + + {item.error_message} + + )} + + ), + }, + { + key: 'io', + label: t('入参 / 输出'), + children: ( + + + {item.script_stdin && } + + + + ), + }, + ]} /> + ); + + const renderSubagentDetail = (item: MySubagentLogDetail) => ( + + + {item.subagent_name} + {item.subagent_type || '—'} + {statusTag(item.status)} + {duration(item.duration_ms)} + {item.model || '—'} + {item.plan_id || '—'} + {item.tool_calls_count ?? 0} + {item.skill_calls_count ?? 0} + {item.session_title || '—'} + + {item.trace_id || '—'} + + {item.token_usage && ( + + prompt: {item.token_usage.prompt_tokens || 0}; completion:{' '} + {item.token_usage.completion_tokens || 0}; total:{' '} + {item.token_usage.total_tokens || 0}; LLM calls:{' '} + {item.token_usage.llm_call_count || 0} + + )} + {item.error_message && ( + + {item.error_message} + + )} + + + + + ), + }, + { + key: 'steps', + label: t('子步骤 ({n})', { n: item.child_steps.length }), + children: item.child_steps.length === 0 ? : ( + ({ + color: stepItem.status === 'success' ? 'green' : stepItem.status === 'failed' ? 'red' : 'blue', + children: ( + + + {stepItem.step_index != null ? t('步骤 {n}:', { n: stepItem.step_index }) : ''} + {stepItem.step_title || stepItem.subagent_name} + + + {statusTag(stepItem.status)} + {duration(stepItem.duration_ms)} + {stepItem.tool_calls_count ?? 0} {t('工具')} + + {stepItem.output_content && ( + + {stepItem.output_content.slice(0, 400)} + {stepItem.output_content.length > 400 ? '…' : ''} + + )} + + ), + }))} /> + ), + }, + { + key: 'tools', + label: t('内部工具调用 ({n})', { n: item.tool_calls.length }), + children: item.tool_calls.length === 0 ? : ( + + size="small" + dataSource={item.tool_calls} + rowKey="id" + pagination={false} + columns={[ + { title: t('时间'), dataIndex: 'created_at', width: 170, render: fmtTime }, + { title: t('工具'), dataIndex: 'tool_name' }, + { title: t('状态'), dataIndex: 'status', width: 80, render: statusTag }, + { title: t('耗时'), dataIndex: 'duration_ms', width: 90, render: duration }, + ]} + expandable={{ + expandedRowRender: (tool) => ( +
+                  {jsonText({ args: tool.tool_args, result: tool.tool_result })}
+                
+ ), + }} + /> + ), + }, + { + key: 'skills', + label: t('内部技能调用 ({n})', { n: item.skill_calls.length }), + children: item.skill_calls.length === 0 ? : ( + + size="small" + dataSource={item.skill_calls} + rowKey="id" + pagination={false} + columns={[ + { title: t('时间'), dataIndex: 'created_at', width: 170, render: fmtTime }, + { title: t('技能'), dataIndex: 'skill_name' }, + { title: t('脚本'), dataIndex: 'script_name' }, + { title: t('方式'), dataIndex: 'invocation_type', width: 100 }, + { title: t('状态'), dataIndex: 'status', width: 80, render: statusTag }, + ]} + /> + ), + }, + ]} /> + ); + + const drawerTitle = detail + ? detail.kind === 'tools' + ? `${detail.data.tool_display_name || detail.data.tool_name} · ${t('调用详情')}` + : detail.kind === 'skills' + ? `${detail.data.skill_name || detail.data.skill_id} · ${t('调用详情')}` + : t('{name} · 详情', { name: detail.data.subagent_name }) + : t('调用详情'); + return (
{ setKind(v as LogKind); setPage(1); }} + onChange={(value) => { + setKind(value as LogKind); + setPage(1); + setDetailKind(null); + setDetail(null); + }} options={[ { value: 'tools', label: t('工具调用') }, { value: 'skills', label: t('技能调用') }, @@ -140,27 +459,47 @@ export function MyLogsPanel() {
{kind === 'usage' && summary.length > 0 && (
- {summary.map((s) => ( - - {s.group_key}: {s.total_tokens.toLocaleString()} tokens / {s.total_requests} {t('次')} + {summary.map((item) => ( + + {item.group_key}: {item.total_tokens.toLocaleString()} tokens /{' '} + {item.total_requests} {t('次')} ))}
)} - > + size="small" - rowKey={(r) => (r.id as string) || (r.message_id as string)} + rowKey={(record) => ('id' in record ? record.id : record.message_id)} loading={loading} dataSource={rows} columns={columnsByKind[kind]} + scroll={{ x: kind === 'usage' ? 850 : 980 }} pagination={{ current: page, pageSize: PAGE_SIZE, total, showSizeChanger: false, - onChange: (p) => setPage(p), + showTotal: (count) => t('共 {n} 条', { n: count }), + onChange: setPage, }} /> + + { + setDetailKind(null); + setDetail(null); + }} + > + {detail?.kind === 'tools' && renderToolDetail(detail.data)} + {detail?.kind === 'skills' && renderSkillDetail(detail.data)} + {detail?.kind === 'subagents' && renderSubagentDetail(detail.data)} +
); } diff --git a/src/frontend/src/components/settings/SettingsModal.tsx b/src/frontend/src/components/settings/SettingsModal.tsx index 042289f0..52d2f868 100644 --- a/src/frontend/src/components/settings/SettingsModal.tsx +++ b/src/frontend/src/components/settings/SettingsModal.tsx @@ -126,6 +126,7 @@ export default function SettingsPage() { const isCE = useEditionStore((s) => (s.loaded ? s.edition === 'ce' : false)); const [sysAccess, setSysAccess] = useState(false); const [ontologyGovernanceAccess, setOntologyGovernanceAccess] = useState(false); + const [logoutConfirmOpen, setLogoutConfirmOpen] = useState(false); useEffect(() => { if (!isCE) { setSysAccess(false); return; } getMySystemAccess() @@ -883,17 +884,7 @@ export default function SettingsPage() { {activeSection === 'profile' && + + + + {cfgLogoutTitle} + + )} + open={logoutConfirmOpen} + okText={cfgLogoutOk} + cancelText={t('取消')} + okButtonProps={{ danger: true }} + confirmLoading={loggingOut} + maskClosable={!loggingOut} + closable={!loggingOut} + onCancel={() => setLogoutConfirmOpen(false)} + onOk={() => void doLogout()} + > + {cfgLogoutContent} + ); diff --git a/src/frontend/src/i18n/en/adminSkills.ts b/src/frontend/src/i18n/en/adminSkills.ts index 6a1462dd..a076608c 100644 --- a/src/frontend/src/i18n/en/adminSkills.ts +++ b/src/frontend/src/i18n/en/adminSkills.ts @@ -259,6 +259,7 @@ export const ADMIN_SKILLS_DICT: Record = { 'rebuild 已触发,状态会自动刷新': 'Rebuild triggered; status will refresh automatically', '触发失败:{msg}': 'Trigger failed: {msg}', '加载日志失败:{msg}': 'Failed to load log: {msg}', + '加载日志详情失败:{msg}': 'Failed to load log details: {msg}', '有依赖待应用 — 当前 hash {hash},已应用 {applied}': 'Dependencies pending — current hash {hash}, applied {applied}', '沙盒依赖已与启用技能保持同步': 'Sandbox dependencies are in sync with enabled skills', '正在 rebuild:{targets}({status})': 'Rebuilding: {targets} ({status})', diff --git a/src/frontend/src/i18n/en/onboarding.ts b/src/frontend/src/i18n/en/onboarding.ts index a9144227..975e2261 100644 --- a/src/frontend/src/i18n/en/onboarding.ts +++ b/src/frontend/src/i18n/en/onboarding.ts @@ -39,6 +39,7 @@ export const ONBOARDING_DICT: Record = { '请选择或添加一个主模型': 'Select or add a primary model', '初始化完成,欢迎使用 {name}': 'Setup complete. Welcome to {name}', '保存失败,请重试': 'Could not save. Try again.', + '无法继续': 'Could not continue', '已检测到可用主模型': 'An available primary model was detected', '使用已有模型': 'Use an existing model', '返回已有模型': 'Back to existing models', diff --git a/src/frontend/src/styles/onboarding.css b/src/frontend/src/styles/onboarding.css index 124c14e4..bb255d06 100644 --- a/src/frontend/src/styles/onboarding.css +++ b/src/frontend/src/styles/onboarding.css @@ -3,18 +3,19 @@ width:100%; height:100%; min-height:0; - overflow:auto; + display:flex; + flex-direction:column; + overflow:hidden; color:var(--color-text); background:#fff; } .jx-firstRun-topbar{ - position:absolute; - top:0; - right:0; - left:0; + position:relative; z-index:2; + width:100%; height:72px; + flex:0 0 72px; padding:0 max(32px,calc((100vw - 1200px) / 2)); display:flex; align-items:center; @@ -49,9 +50,10 @@ .jx-firstRun-shell{ width:min(1200px,calc(100% - 48px)); - height:calc(100vh - 72px); + height:auto; + flex:1 1 auto; min-height:0; - margin:72px auto 0; + margin:0 auto; display:grid; grid-template-columns:248px minmax(0,1fr); grid-template-rows:minmax(0,1fr); @@ -160,13 +162,14 @@ .jx-firstRun-card{ min-width:0; min-height:0; - padding:64px clamp(48px,7vw,96px) 32px; + padding:clamp(32px,6vh,64px) clamp(48px,7vw,96px) 24px; display:grid; - grid-template-rows:auto minmax(0,1fr) auto; + grid-template-rows:auto minmax(0,1fr) auto auto; overflow:hidden; } .jx-firstRun-cardHeader, .jx-firstRun-cardBody, +.jx-firstRun-submitError, .jx-firstRun-actions{ width:min(680px,100%); margin-right:auto; @@ -221,6 +224,11 @@ justify-content:space-between; border-top:1px solid #ececec; } +.jx-firstRun-submitError{ + margin-bottom:12px; + max-height:128px; + overflow:auto; +} .jx-firstRun-actions .ant-btn-lg{ min-width:88px; height:42px; @@ -487,7 +495,7 @@ } @media (max-width:900px){ - .jx-firstRun-root{min-height:0} + .jx-firstRun-root{min-height:0;overflow:auto} .jx-firstRun-topbar{ position:relative; height:66px; @@ -497,6 +505,7 @@ width:100%; height:auto; min-height:calc(100% - 66px); + flex:none; margin:0; grid-template-columns:1fr; } @@ -516,6 +525,17 @@ .jx-firstRun-cardBody{padding-top:34px} } +@media (max-height:760px) and (min-width:901px){ + .jx-firstRun-rail{padding-top:30px;padding-bottom:22px} + .jx-firstRun-railIntro{margin-bottom:22px} + .jx-firstRun-railFooter{bottom:22px} + .jx-firstRun-card{padding-top:24px;padding-bottom:16px} + .jx-firstRun-kicker{margin-bottom:8px} + .jx-firstRun-cardHeader h1.ant-typography{margin-bottom:6px;font-size:28px} + .jx-firstRun-cardBody{padding-top:20px;padding-bottom:16px} + .jx-firstRun-actions{padding-top:12px} +} + @media (max-width:620px){ .jx-firstRun-wordmark{width:150px} .jx-firstRun-editionLabel{display:none}