diff --git a/.github/workflows/desktop-release-package.yml b/.github/workflows/desktop-release-package.yml index 0728eda80..e0f0e47b1 100644 --- a/.github/workflows/desktop-release-package.yml +++ b/.github/workflows/desktop-release-package.yml @@ -23,34 +23,46 @@ name: Build Desktop Portable Packages - "macos" permissions: - contents: write + contents: read + +env: + NODE_VERSION: "22.23.2" + RUST_TOOLCHAIN: "1.95.0" + PBS_RELEASE: "20260814" + PBS_PYTHON_VERSION: "3.12.14" jobs: # ---------------------------------------------------------------------------- build-windows: name: Windows portable - if: ${{ startsWith(github.ref, 'refs/tags/') || github.event.inputs.target == 'all' || github.event.inputs.target == 'windows' }} - runs-on: windows-latest + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'windows')) }} + runs-on: windows-2025 + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: - node-version: 20 + node-version: ${{ env.NODE_VERSION }} - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Cache Rust build - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: workspaces: frontends/desktop/src-tauri -> target - name: Install desktop dependencies working-directory: frontends/desktop - run: npm install + run: npm ci - name: Build Windows desktop exe working-directory: frontends/desktop @@ -58,8 +70,6 @@ jobs: - name: Assemble self-contained portable bundle shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail @@ -77,18 +87,16 @@ jobs: cp frontends/desktop/packaging/scripts/windows/uninstall.bat "$PKG/uninstall.bat" cp frontends/desktop/packaging/scripts/windows/uninstall_windows.ps1 "$RUNTIME/uninstall_windows.ps1" - # Embedded Python (python-build-standalone, windows x86_64, 3.12 install_only). - # browser_download_url is URL-encoded ('+' -> '%2B'); match loosely with '.*'. - PBS_URL="$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest \ - | grep -o '"browser_download_url": *"[^"]*"' \ - | grep 'cpython-3\.12\.[0-9].*x86_64-pc-windows-msvc-install_only\.tar\.gz' \ - | head -1 | sed -E 's/.*"(https[^"]+)"/\1/')" - [[ -n "$PBS_URL" ]] || { echo "Could not resolve python-build-standalone download URL" >&2; exit 1; } + # Fixed python-build-standalone Windows x86_64 archive. + PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-pc-windows-msvc-install_only.tar.gz" + PBS_SHA256="7330282b47cd43a66b702d39078d2e5a88e580cee351d82f95045f21f5ee042a" + PBS_ARCHIVE="${RUNNER_TEMP}/pbs-windows-x86_64.tar.gz" echo "Python build-standalone: $PBS_URL" - curl -fsSL -o /tmp/pbs.tar.gz "$PBS_URL" - tar -xzf /tmp/pbs.tar.gz -C "$RUNTIME" # extracts a 'python/' dir (python.exe at top) + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL" + printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict - + tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir (python.exe at top) PY="$RUNTIME/python/python.exe" - "$PY" --version + "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine().lower() in ("amd64", "x86_64"); print(sys.version)' # App-local UCRT: python-build-standalone links the Universal CRT dynamically # and does NOT bundle it. Win10/11 ship UCRT in-box, but stripped/older images @@ -104,11 +112,11 @@ jobs: test -f "$RUNTIME/python/api-ms-win-crt-runtime-l1-1-0.dll" \ || { echo "UCRT DLLs missing after copy" >&2; exit 1; } - # Offline wheels (native win_amd64 wheels for the embedded python) + # Offline binary-only wheels. requirements.txt pins direct and transitive versions. mkdir -p "$RUNTIME/wheels" - "$PY" -m pip download --dest "$RUNTIME/wheels" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \ - fastapi uvicorn websockets pydantic setuptools wheel + cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt" + "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \ + --requirement "$RUNTIME/wheels/requirements.txt" # Runtime source (project root minus heavy/dev/build dirs) mkdir -p "$RUNTIME/app" @@ -116,6 +124,7 @@ jobs: --exclude='./.git' --exclude='./.github' \ --exclude='./frontends/desktop/src-tauri' \ --exclude='./frontends/desktop/node_modules' \ + --exclude='./frontends/desktop/dist' \ --exclude='./frontends/desktop/packaging' --exclude='./docs' \ --exclude='./assets/demo' --exclude='./assets/images' \ --exclude='./assets/GenericAgent_Technical_Report.pdf' \ @@ -127,6 +136,7 @@ jobs: test -f "$RUNTIME/app/agentmain.py" test -f "$RUNTIME/app/frontends/desktop_bridge.py" test -f "$RUNTIME/app/frontends/desktop/static/index.html" + test ! -e "$RUNTIME/app/frontends/desktop/dist" # Drop python debug symbols (.pdb) to slim the package (~80MB) find "$RUNTIME/python" -name '*.pdb' -delete 2>/dev/null || true @@ -192,7 +202,7 @@ jobs: Get-ChildItem artifacts/windows/out | Format-Table Name, Length - name: Upload workflow artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }} path: | @@ -200,69 +210,31 @@ jobs: artifacts/windows/out/SHA256SUMS-windows.txt if-no-files-found: error - - name: Publish into unified Release (tag push only) - if: ${{ startsWith(github.ref, 'refs/tags/') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG_NAME="${{ github.ref_name }}" - TITLE="GenericAgent Desktop ${TAG_NAME}" - cat > "${RUNNER_TEMP:-/tmp}/ga_notes.md" <<'EOF' - GenericAgent Desktop 桌面版 / Desktop - - 无需自行安装 Python、无需联网安装依赖、无需源码;首次启动会自动准备内置运行环境。 - No separate Python install, no internet for dependencies, no source checkout required; the bundled runtime is prepared on first launch. - - ## 安装方法 / Installation - - Windows: 下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe` 启动。卸载时先退出应用,再双击包内 `uninstall.bat`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Windows-Portable.zip`, extract it, then double-click `GenericAgent.exe`. To uninstall, quit the app, double-click `uninstall.bat`, then delete the extracted folder. - - Linux: 下载 `GenericAgent-Desktop-Linux-Portable.tar.gz`,解压后执行 `chmod +x GenericAgent.AppImage`,再运行 `./GenericAgent.AppImage`。卸载时先退出应用,再运行 `./uninstall.sh`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Linux-Portable.tar.gz`, extract it, run `chmod +x GenericAgent.AppImage`, then launch `./GenericAgent.AppImage`. To uninstall, quit the app, run `./uninstall.sh`, then delete the extracted folder. - - macOS: 下载 `GenericAgent-Desktop-macOS.dmg`,双击打开 DMG,把 `GenericAgent.app` 拖入 `Applications`,之后在“应用程序 / Applications”中启动。若出现“无法验证开发者”等提示,请右键应用选择“打开”,或在“系统设置 → 隐私与安全性”中允许打开。卸载时先退出应用,再从 Applications 删除 `GenericAgent.app`。 - Download `GenericAgent-Desktop-macOS.dmg`, open it, drag `GenericAgent.app` to `Applications`, then launch it from Applications. If macOS says the developer cannot be verified, right-click the app and choose Open, or allow it in System Settings → Privacy & Security. To uninstall, quit the app, then delete `GenericAgent.app` from Applications. - - ## 首次启动 / First launch - 首次启动会自动离线准备运行环境(安装依赖),有进度条,完成后进入主界面;之后启动会更快。 - The first launch prepares the runtime offline (installs deps) with a progress bar, then opens the main window; later launches are faster. - - ## 说明 / Notes - - 想真正对话需在程序内配置模型 / API Key。Configure a model / API key in-app to start chatting. - - Windows Defender 防火墙可能会拦截 `127.0.0.1` / `localhost` 回环连接,导致程序无法启动或连接本机服务;请允许 `GenericAgent.exe` 和随包的 `python.exe` 通过防火墙后重启。Windows Defender Firewall may block the local loopback connection; allow `GenericAgent.exe` and the bundled `python.exe` through the firewall, then restart the app. - - Windows/Linux 为便携包,可整体移动到任意目录后继续使用。Windows/Linux packages are portable and can be moved as a whole folder. - - Windows/Linux 包内附 `readme.txt`(中英)。Windows/Linux packages include a bilingual `readme.txt`. - EOF - # Create the shared release if it does not exist yet (another OS job may win the race). - # Race-safe: the 3 OS jobs share ONE release. Retry create until the release - # actually exists (a simultaneous create from another job can fail), so the - # upload below never races a not-yet-created release. - for _ in $(seq 1 15); do - gh release view "$TAG_NAME" >/dev/null 2>&1 && break - gh release create "$TAG_NAME" --target "${{ github.sha }}" --title "$TITLE" --notes-file "${RUNNER_TEMP:-/tmp}/ga_notes.md" --prerelease 2>/dev/null || true - sleep 3 - done - gh release upload "$TAG_NAME" artifacts/windows/out/* --clobber - # ---------------------------------------------------------------------------- build-linux: name: Linux portable - if: ${{ startsWith(github.ref, 'refs/tags/') || github.event.inputs.target == 'all' || github.event.inputs.target == 'linux' }} + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'linux')) }} runs-on: ubuntu-24.04 + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: - node-version: 20 + node-version: ${{ env.NODE_VERSION }} - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Cache Rust build - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: workspaces: frontends/desktop/src-tauri -> target @@ -278,7 +250,7 @@ jobs: - name: Install desktop dependencies working-directory: frontends/desktop - run: npm install + run: npm ci - name: Build Linux AppImage working-directory: frontends/desktop @@ -286,8 +258,6 @@ jobs: - name: Assemble self-contained portable bundle shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail APPIMAGE_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/appimage -maxdepth 1 -type f -name '*.AppImage' | head -n 1)" @@ -308,23 +278,22 @@ jobs: cp frontends/desktop/packaging/scripts/linux/uninstall.sh "$PKG/uninstall.sh" chmod +x "$PKG/uninstall.sh" - # Embedded Python (python-build-standalone, linux x86_64, 3.12 install_only). - PBS_URL="$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest \ - | grep -o '"browser_download_url": *"[^"]*"' \ - | grep 'cpython-3\.12\.[0-9].*x86_64-unknown-linux-gnu-install_only\.tar\.gz' \ - | head -1 | sed -E 's/.*"(https[^"]+)"/\1/')" - [[ -n "$PBS_URL" ]] || { echo "Could not resolve python-build-standalone download URL" >&2; exit 1; } + # Fixed python-build-standalone Linux x86_64 archive. + PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-unknown-linux-gnu-install_only.tar.gz" + PBS_SHA256="3297691ae34f75fed81ac424e040145fccb0bafe8e581cd5cadbddfa1c0766c0" + PBS_ARCHIVE="${RUNNER_TEMP}/pbs-linux-x86_64.tar.gz" echo "Python build-standalone: $PBS_URL" - curl -fsSL -o /tmp/pbs.tar.gz "$PBS_URL" - tar -xzf /tmp/pbs.tar.gz -C "$RUNTIME" # extracts a 'python/' dir + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL" + printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict - + tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir PY="$RUNTIME/python/bin/python3" - "$PY" --version + "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "x86_64"; print(sys.version)' - # Offline wheels (native linux wheels for the embedded python) + # Offline binary-only wheels. requirements.txt pins direct and transitive versions. mkdir -p "$RUNTIME/wheels" - "$PY" -m pip download --dest "$RUNTIME/wheels" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \ - fastapi uvicorn websockets pydantic setuptools wheel + cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt" + "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \ + --requirement "$RUNTIME/wheels/requirements.txt" # Runtime source (project root minus heavy/dev/build dirs) mkdir -p "$RUNTIME/app" @@ -332,6 +301,7 @@ jobs: --exclude='./.git' --exclude='./.github' \ --exclude='./frontends/desktop/src-tauri' \ --exclude='./frontends/desktop/node_modules' \ + --exclude='./frontends/desktop/dist' \ --exclude='./frontends/desktop/packaging' --exclude='./docs' \ --exclude='./assets/demo' --exclude='./assets/images' \ --exclude='./assets/GenericAgent_Technical_Report.pdf' \ @@ -343,6 +313,7 @@ jobs: test -f "$RUNTIME/app/agentmain.py" test -f "$RUNTIME/app/frontends/desktop_bridge.py" test -f "$RUNTIME/app/frontends/desktop/static/index.html" + test ! -e "$RUNTIME/app/frontends/desktop/dist" # Drop python debug/cache bits to slim the package find "$RUNTIME/python" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true @@ -405,7 +376,7 @@ jobs: ls -lh artifacts/linux/out - name: Upload workflow artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }} path: | @@ -413,136 +384,91 @@ jobs: artifacts/linux/out/SHA256SUMS-linux.txt if-no-files-found: error - - name: Publish into unified Release (tag push only) - if: ${{ startsWith(github.ref, 'refs/tags/') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG_NAME="${{ github.ref_name }}" - TITLE="GenericAgent Desktop ${TAG_NAME}" - cat > "${RUNNER_TEMP:-/tmp}/ga_notes.md" <<'EOF' - GenericAgent Desktop 桌面版 / Desktop - - 无需自行安装 Python、无需联网安装依赖、无需源码;首次启动会自动准备内置运行环境。 - No separate Python install, no internet for dependencies, no source checkout required; the bundled runtime is prepared on first launch. - - ## 安装方法 / Installation - - Windows: 下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe` 启动。卸载时先退出应用,再双击包内 `uninstall.bat`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Windows-Portable.zip`, extract it, then double-click `GenericAgent.exe`. To uninstall, quit the app, double-click `uninstall.bat`, then delete the extracted folder. - - Linux: 下载 `GenericAgent-Desktop-Linux-Portable.tar.gz`,解压后执行 `chmod +x GenericAgent.AppImage`,再运行 `./GenericAgent.AppImage`。卸载时先退出应用,再运行 `./uninstall.sh`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Linux-Portable.tar.gz`, extract it, run `chmod +x GenericAgent.AppImage`, then launch `./GenericAgent.AppImage`. To uninstall, quit the app, run `./uninstall.sh`, then delete the extracted folder. - - macOS: 下载 `GenericAgent-Desktop-macOS.dmg`,双击打开 DMG,把 `GenericAgent.app` 拖入 `Applications`,之后在“应用程序 / Applications”中启动。若出现“无法验证开发者”等提示,请右键应用选择“打开”,或在“系统设置 → 隐私与安全性”中允许打开。卸载时先退出应用,再从 Applications 删除 `GenericAgent.app`。 - Download `GenericAgent-Desktop-macOS.dmg`, open it, drag `GenericAgent.app` to `Applications`, then launch it from Applications. If macOS says the developer cannot be verified, right-click the app and choose Open, or allow it in System Settings → Privacy & Security. To uninstall, quit the app, then delete `GenericAgent.app` from Applications. - - ## 首次启动 / First launch - 首次启动会自动离线准备运行环境(安装依赖),有进度条,完成后进入主界面;之后启动会更快。 - The first launch prepares the runtime offline (installs deps) with a progress bar, then opens the main window; later launches are faster. - - ## 说明 / Notes - - 想真正对话需在程序内配置模型 / API Key。Configure a model / API key in-app to start chatting. - - Windows Defender 防火墙可能会拦截 `127.0.0.1` / `localhost` 回环连接,导致程序无法启动或连接本机服务;请允许 `GenericAgent.exe` 和随包的 `python.exe` 通过防火墙后重启。Windows Defender Firewall may block the local loopback connection; allow `GenericAgent.exe` and the bundled `python.exe` through the firewall, then restart the app. - - Windows/Linux 为便携包,可整体移动到任意目录后继续使用。Windows/Linux packages are portable and can be moved as a whole folder. - - Windows/Linux 包内附 `readme.txt`(中英)。Windows/Linux packages include a bilingual `readme.txt`. - EOF - # Race-safe: the 3 OS jobs share ONE release. Retry create until the release - # actually exists (a simultaneous create from another job can fail), so the - # upload below never races a not-yet-created release. - for _ in $(seq 1 15); do - gh release view "$TAG_NAME" >/dev/null 2>&1 && break - gh release create "$TAG_NAME" --target "${{ github.sha }}" --title "$TITLE" --notes-file "${RUNNER_TEMP:-/tmp}/ga_notes.md" --prerelease 2>/dev/null || true - sleep 3 - done - gh release upload "$TAG_NAME" artifacts/linux/out/* --clobber - # ---------------------------------------------------------------------------- build-macos: - name: macOS DMG - if: ${{ startsWith(github.ref, 'refs/tags/') || github.event.inputs.target == 'all' || github.event.inputs.target == 'macos' }} - runs-on: macos-latest + name: macOS arm64 DMG + if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'macos')) }} + runs-on: macos-15 + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: - node-version: 20 + node-version: ${{ env.NODE_VERSION }} - name: Set up Rust - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} - name: Cache Rust build - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2 with: workspaces: frontends/desktop/src-tauri -> target - name: Install desktop dependencies working-directory: frontends/desktop - run: npm install + run: npm ci + + - name: Set up Python packaging runtime + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PBS_PYTHON_VERSION }} - name: Install Python packaging tools - run: python3 -m pip install --break-system-packages ds_store + run: >- + python3 -m pip install --require-hashes --only-binary=:all: + --requirement frontends/desktop/packaging/dmg-build-requirements.txt + + - name: Assert macOS runner architecture + run: test "$(uname -m)" = arm64 - name: Build macOS .app working-directory: frontends/desktop run: npm run tauri build -- --bundles app - - name: Assemble self-contained portable bundle and DMG app + - name: Assemble self-contained DMG app shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail APP_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -name '*.app' -type d | head -n 1)" [[ -n "$APP_SRC" ]] || { echo "No .app found" >&2; exit 1; } - # Build one runtime/ tree, then reuse it for both macOS deliverables: - # 1) Portable zip: GenericAgent.app + runtime/ + helper scripts. - # 2) Standard DMG: GenericAgent.app with runtime embedded in Contents/Resources/runtime. + # Build one runtime tree and embed it only in the final DMG application. RUNTIME_SRC="artifacts/macos/runtime-src" mkdir -p "$RUNTIME_SRC" cp frontends/desktop/packaging/scripts/macos/install_macos.sh "$RUNTIME_SRC/install_macos.sh" chmod +x "$RUNTIME_SRC/install_macos.sh" - # Embedded Python (python-build-standalone, macOS, 3.12 install_only; aarch64 preferred). - PBS_URL="$(python3 - <<'PY' - import json, os, urllib.request - api='https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest' - req=urllib.request.Request(api) - token=os.environ.get('GH_TOKEN') - if token: - req.add_header('Authorization', 'Bearer ' + token) - data=json.load(urllib.request.urlopen(req, timeout=60)) - assets=data.get('assets', []) - for arch in ('aarch64-apple-darwin', 'x86_64-apple-darwin'): - for a in assets: - name=a.get('name','') - if 'cpython-3.12' in name and arch in name and name.endswith('install_only.tar.gz') and 'stripped' not in name: - print(a['browser_download_url']); raise SystemExit - raise SystemExit('No suitable python-build-standalone macOS asset found') - PY - )" + # Fixed python-build-standalone macOS arm64 archive. + PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-aarch64-apple-darwin-install_only.tar.gz" + PBS_SHA256="4572133a5542f306b9bdb155da5800f9e38950cd0a98d469b832ce256fe299ea" + PBS_ARCHIVE="${RUNNER_TEMP}/pbs-macos-aarch64.tar.gz" echo "Python build-standalone: $PBS_URL" - curl -L --fail --retry 3 -o /tmp/pbs-macos.tar.gz "$PBS_URL" - tar -xzf /tmp/pbs-macos.tar.gz -C "$RUNTIME_SRC" # extracts a 'python/' dir + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL" + printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | shasum -a 256 --check - + tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME_SRC" # extracts a 'python/' dir PY="$RUNTIME_SRC/python/bin/python3" - "$PY" --version + "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "arm64"; print(sys.version)' - # Offline wheels + # Offline binary-only wheels. requirements.txt pins direct and transitive versions. mkdir -p "$RUNTIME_SRC/wheels" - PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --dest "$RUNTIME_SRC/wheels" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \ - fastapi uvicorn websockets pydantic setuptools wheel + cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME_SRC/wheels/requirements.txt" + PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME_SRC/wheels" \ + --requirement "$RUNTIME_SRC/wheels/requirements.txt" # macOS applications must be immutable at first launch. Install from the downloaded # wheelhouse before the runtime enters the signed .app, then carry a durable marker. - # setuptools/wheel stay in the offline wheelhouse for recovery, but are build tools and - # are not needed in the prepared runtime. Bytecode writes are disabled at runtime too. + # The binary-only offline repair path needs neither setuptools nor wheel. Remove any + # copies shipped in the base interpreter; bytecode writes are disabled at runtime too. PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip install --no-compile --no-index --find-links "$RUNTIME_SRC/wheels" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \ - fastapi uvicorn websockets pydantic + --requirement "$RUNTIME_SRC/wheels/requirements.txt" PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip uninstall --yes setuptools wheel PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip check @@ -572,6 +498,7 @@ jobs: --exclude='.git' --exclude='.github' \ --exclude='frontends/desktop/src-tauri' \ --exclude='frontends/desktop/node_modules' \ + --exclude='frontends/desktop/dist' \ --exclude='frontends/desktop/packaging' --exclude='docs' \ --exclude='assets/demo' --exclude='assets/images' \ --exclude='assets/GenericAgent_Technical_Report.pdf' \ @@ -583,55 +510,11 @@ jobs: test -f "$RUNTIME_SRC/app/agentmain.py" test -f "$RUNTIME_SRC/app/frontends/desktop_bridge.py" test -f "$RUNTIME_SRC/app/frontends/desktop/static/index.html" + test ! -e "$RUNTIME_SRC/app/frontends/desktop/dist" - PORTABLE="artifacts/macos/GenericAgent-Desktop-macOS-Portable" DMG_STAGE="artifacts/macos/dmg-stage" DMG_APP="$DMG_STAGE/GenericAgent.app" - mkdir -p "$PORTABLE" "$DMG_STAGE" - - # Portable zip: keep the existing release shape and helper scripts. - ditto "$APP_SRC" "$PORTABLE/GenericAgent.app" - ditto "$RUNTIME_SRC" "$PORTABLE/runtime" - test -s "$PORTABLE/runtime/.prepared" - cp frontends/desktop/packaging/scripts/macos/uninstall.command "$PORTABLE/uninstall.command" - chmod +x "$PORTABLE/uninstall.command" - cat > "$PORTABLE/Start GenericAgent.command" <<'EOF' - #!/bin/bash - DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - xattr -cr "$DIR/GenericAgent.app" 2>/dev/null || true - open "$DIR/GenericAgent.app" - EOF - chmod +x "$PORTABLE/Start GenericAgent.command" - - cat > "$PORTABLE/readme.txt" <<'EOF' - ================ 中文 ================ - GenericAgent Desktop — macOS 便携版(自包含) - - 无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。 - - 使用 - 推荐:双击 “Start GenericAgent.command” 启动。也可直接双击 GenericAgent.app。 - 请保持目录结构不变:GenericAgent.app 与 runtime/ 必须在同一目录下。 - - 卸载 - 双击 uninstall.command,或退出程序后删除整个便携文件夹。 - - ================ English ================ - GenericAgent Desktop — macOS Portable (self-contained) - - No Python install, no internet for dependencies, no source checkout — everything is bundled. - - Usage - Recommended: double-click "Start GenericAgent.command". You can also double-click GenericAgent.app. - Keep the folder layout intact: GenericAgent.app and runtime/ must stay in the same directory. - - Uninstall - Double-click uninstall.command, or quit the app and delete the whole portable folder. - EOF - sed -i '' 's/^ //' "$PORTABLE/readme.txt" - - codesign --force --deep --sign - "$PORTABLE/GenericAgent.app" - codesign --verify --deep --strict "$PORTABLE/GenericAgent.app" || true + mkdir -p "$DMG_STAGE" # Standard DMG: GenericAgent.app + Applications alias. The post-build # repackager below writes the curated two-icon Finder layout. @@ -651,8 +534,9 @@ jobs: echo "Python bytecode cache remains in final DMG app" >&2 exit 1 fi + # Ad-hoc signing only: this is not Developer ID signing or notarization. codesign --force --deep --sign - "$DMG_APP" - codesign --verify --deep --strict "$DMG_APP" || true + codesign --verify --deep --strict "$DMG_APP" ln -s /Applications "$DMG_STAGE/Applications" mkdir -p artifacts/macos/out @@ -661,67 +545,149 @@ jobs: -srcfolder "$DMG_STAGE" \ -ov \ -format UDZO \ - "artifacts/macos/out/GenericAgent-Desktop-macOS.dmg" + "artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg" # Restore the intentionally minimal Finder presentation: only the app # and Applications shortcut, with the established window/icon layout. - bash frontends/desktop/scripts/post-dmg.sh "artifacts/macos/out/GenericAgent-Desktop-macOS.dmg" + bash frontends/desktop/scripts/post-dmg.sh "artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg" ( cd artifacts/macos/out - shasum -a 256 "GenericAgent-Desktop-macOS.dmg" \ - > "GenericAgent-Desktop-macOS.dmg.sha256" + shasum -a 256 "GenericAgent-Desktop-macOS-aarch64.dmg" \ + > "GenericAgent-Desktop-macOS-aarch64.dmg.sha256" ) echo "DMG stage tree:" find "$DMG_STAGE" -maxdepth 2 -print | sort - name: Upload workflow artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: GenericAgent-Desktop-macOS-DMG-${{ github.run_number }} - path: artifacts/macos/out/* + name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }} + path: | + artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg + artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg.sha256 if-no-files-found: error - - name: Publish into unified Release (tag push only) - if: ${{ startsWith(github.ref, 'refs/tags/') }} + # ---------------------------------------------------------------------------- + publish-release: + name: Publish one atomic prerelease + needs: [build-windows, build-linux, build-macos] + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-') && needs.build-windows.result == 'success' && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' }} + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Download Windows candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }} + path: ${{ runner.temp }}/release-assets + + - name: Download Linux candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }} + path: ${{ runner.temp }}/release-assets + + - name: Download macOS arm64 candidate + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }} + path: ${{ runner.temp }}/release-assets + + - name: Validate six assets and publish prerelease env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail + + ASSET_DIR="${RUNNER_TEMP}/release-assets" + EXPECTED=( + GenericAgent-Desktop-Linux-Portable.tar.gz + GenericAgent-Desktop-Windows-Portable.zip + GenericAgent-Desktop-macOS-aarch64.dmg + GenericAgent-Desktop-macOS-aarch64.dmg.sha256 + SHA256SUMS-linux.txt + SHA256SUMS-windows.txt + ) + + if find "$ASSET_DIR" -mindepth 1 -maxdepth 1 ! -type f -print -quit | grep -q .; then + echo "Release staging contains a non-regular entry" >&2 + find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -print >&2 + exit 1 + fi + mapfile -t ACTUAL < <(find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort) + if ! diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${ACTUAL[@]}"); then + echo "Release staging must contain exactly the six expected files" >&2 + exit 1 + fi + for filename in "${EXPECTED[@]}"; do + test -s "$ASSET_DIR/$filename" || { echo "Missing or empty release asset: $filename" >&2; exit 1; } + done + + verify_checksum_manifest() { + local manifest="$1" + local payload="$2" + local normalized="${RUNNER_TEMP}/${manifest}.normalized" + tr -d '\r' < "$ASSET_DIR/$manifest" > "$normalized" + awk -v expected="$payload" ' + NF == 2 && $1 ~ /^[0-9a-fA-F]{64}$/ && $2 == expected { valid += 1 } + END { exit !(NR == 1 && valid == 1) } + ' "$normalized" + (cd "$ASSET_DIR" && sha256sum --check --strict "$normalized") + } + verify_checksum_manifest SHA256SUMS-windows.txt GenericAgent-Desktop-Windows-Portable.zip + verify_checksum_manifest SHA256SUMS-linux.txt GenericAgent-Desktop-Linux-Portable.tar.gz + verify_checksum_manifest GenericAgent-Desktop-macOS-aarch64.dmg.sha256 GenericAgent-Desktop-macOS-aarch64.dmg + TAG_NAME="${{ github.ref_name }}" TITLE="GenericAgent Desktop ${TAG_NAME}" - cat > "${RUNNER_TEMP:-/tmp}/ga_notes.md" <<'EOF' + NOTES_FILE="${RUNNER_TEMP}/ga-release-notes.md" + cat > "$NOTES_FILE" <<'EOF' GenericAgent Desktop 桌面版 / Desktop - 无需自行安装 Python、无需联网安装依赖、无需源码;首次启动会自动准备内置运行环境。 - No separate Python install, no internet for dependencies, no source checkout required; the bundled runtime is prepared on first launch. + 三个平台产物来自同一提交,并在公开发布前统一验证。All three platform artifacts come from one commit and are validated before publication. ## 安装方法 / Installation - - Windows: 下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe` 启动。卸载时先退出应用,再双击包内 `uninstall.bat`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Windows-Portable.zip`, extract it, then double-click `GenericAgent.exe`. To uninstall, quit the app, double-click `uninstall.bat`, then delete the extracted folder. - - Linux: 下载 `GenericAgent-Desktop-Linux-Portable.tar.gz`,解压后执行 `chmod +x GenericAgent.AppImage`,再运行 `./GenericAgent.AppImage`。卸载时先退出应用,再运行 `./uninstall.sh`,最后删除整个解压目录。 - Download `GenericAgent-Desktop-Linux-Portable.tar.gz`, extract it, run `chmod +x GenericAgent.AppImage`, then launch `./GenericAgent.AppImage`. To uninstall, quit the app, run `./uninstall.sh`, then delete the extracted folder. - - macOS: 下载 `GenericAgent-Desktop-macOS.dmg`,双击打开 DMG,把 `GenericAgent.app` 拖入 `Applications`,之后在“应用程序 / Applications”中启动。若出现“无法验证开发者”等提示,请右键应用选择“打开”,或在“系统设置 → 隐私与安全性”中允许打开。卸载时先退出应用,再从 Applications 删除 `GenericAgent.app`。 - Download `GenericAgent-Desktop-macOS.dmg`, open it, drag `GenericAgent.app` to `Applications`, then launch it from Applications. If macOS says the developer cannot be verified, right-click the app and choose Open, or allow it in System Settings → Privacy & Security. To uninstall, quit the app, then delete `GenericAgent.app` from Applications. - - ## 首次启动 / First launch - 首次启动会自动离线准备运行环境(安装依赖),有进度条,完成后进入主界面;之后启动会更快。 - The first launch prepares the runtime offline (installs deps) with a progress bar, then opens the main window; later launches are faster. - - ## 说明 / Notes - - 想真正对话需在程序内配置模型 / API Key。Configure a model / API key in-app to start chatting. - - Windows Defender 防火墙可能会拦截 `127.0.0.1` / `localhost` 回环连接,导致程序无法启动或连接本机服务;请允许 `GenericAgent.exe` 和随包的 `python.exe` 通过防火墙后重启。Windows Defender Firewall may block the local loopback connection; allow `GenericAgent.exe` and the bundled `python.exe` through the firewall, then restart the app. - - Windows/Linux 为便携包,可整体移动到任意目录后继续使用。Windows/Linux packages are portable and can be moved as a whole folder. - - Windows/Linux 包内附 `readme.txt`(中英)。Windows/Linux packages include a bilingual `readme.txt`. + - Windows x64:下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe`。卸载时运行 `uninstall.bat`,再删除解压目录。 + Windows x64: extract `GenericAgent-Desktop-Windows-Portable.zip`, then launch `GenericAgent.exe`. Run `uninstall.bat` before deleting the extracted directory. + - Linux x86_64:下载并解压 `GenericAgent-Desktop-Linux-Portable.tar.gz`,执行 `chmod +x GenericAgent.AppImage` 后运行它。卸载时运行 `uninstall.sh`,再删除解压目录。 + Linux x86_64: extract `GenericAgent-Desktop-Linux-Portable.tar.gz`, run `chmod +x GenericAgent.AppImage`, then launch it. Run `uninstall.sh` before deleting the extracted directory. + - macOS Apple silicon (arm64):打开 `GenericAgent-Desktop-macOS-aarch64.dmg`,把 `GenericAgent.app` 拖入 Applications。 + macOS Apple silicon (arm64): open `GenericAgent-Desktop-macOS-aarch64.dmg` and drag `GenericAgent.app` to Applications. + + ## 签名状态 / Signing status + macOS 应用仅使用 ad-hoc 签名,未使用 Apple Developer ID,也未 notarize。首次打开可能需要右键选择“打开”或在“隐私与安全性”中允许。 + The macOS app is ad-hoc signed only. It is neither Developer ID signed nor notarized; first launch may require Open from the context menu or approval in Privacy & Security. + + SHA-256 清单与各产物一同发布。SHA-256 manifests are included with the artifacts. EOF - # Race-safe: the 3 OS jobs share ONE release. Retry create until the release - # actually exists (a simultaneous create from another job can fail), so the - # upload below never races a not-yet-created release. - for _ in $(seq 1 15); do - gh release view "$TAG_NAME" >/dev/null 2>&1 && break - gh release create "$TAG_NAME" --target "${{ github.sha }}" --title "$TITLE" --notes-file "${RUNNER_TEMP:-/tmp}/ga_notes.md" --prerelease 2>/dev/null || true - sleep 3 + sed -i 's/^ //' "$NOTES_FILE" + + if gh release view "$TAG_NAME" >/dev/null 2>&1; then + echo "A release already exists for $TAG_NAME; refusing to overwrite it" >&2 + exit 1 + fi + + ASSETS=() + for filename in "${EXPECTED[@]}"; do + ASSETS+=("$ASSET_DIR/$filename") done - gh release upload "$TAG_NAME" artifacts/macos/out/* --clobber + # Keep the release invisible while all assets upload; only the final edit publishes it. + gh release create "$TAG_NAME" "${ASSETS[@]}" \ + --target "${{ github.sha }}" \ + --title "$TITLE" \ + --notes-file "$NOTES_FILE" \ + --draft \ + --prerelease + + mapfile -t REMOTE_ASSETS < <(gh release view "$TAG_NAME" --json assets --jq '.assets[].name' | LC_ALL=C sort) + diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${REMOTE_ASSETS[@]}") + test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = true + test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true + + gh release edit "$TAG_NAME" --draft=false --prerelease + test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = false + test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true diff --git a/frontends/cost_tracker.py b/frontends/cost_tracker.py index 0471129fb..c83ccfe7c 100644 --- a/frontends/cost_tracker.py +++ b/frontends/cost_tracker.py @@ -162,6 +162,11 @@ def init_ledger(root: str) -> None: def _append_ledger(thread_key: str, inp: int, out: int, cc: int, cr: int) -> None: global _ledger_uncompacted_bytes + # TUI and conductor processes install the in-memory tracker but never call + # init_ledger(). Keep their historical hot path to one lock-free branch: + # no clock lookup, JSON encoding, byte counting, or ledger lock acquisition. + if _ledger_fd is None: + return line = json.dumps( {"t": time.time(), "k": thread_key, "i": inp, "o": out, "cc": cc, "cr": cr}, separators=(",", ":"), diff --git a/frontends/data_backup.py b/frontends/data_backup.py index 4d711fff8..964d563e7 100644 --- a/frontends/data_backup.py +++ b/frontends/data_backup.py @@ -3,14 +3,17 @@ import contextlib import datetime as dt +import errno import json import os +import re import shutil import stat import tempfile +import uuid import zipfile from pathlib import Path, PurePosixPath -from typing import Iterator +from typing import Any, Callable, Iterable, Iterator BACKUP_SCHEMA = "genericagent.data-backup" @@ -24,6 +27,8 @@ PurePosixPath("temp/desktop_sessions"), ) +_DESKTOP_SESSION_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}") + class BackupFormatError(ValueError): """Raised when a backup cannot be trusted or is not compatible.""" @@ -210,6 +215,11 @@ def _validated_zip(archive: zipfile.ZipFile) -> tuple[dict, list[zipfile.ZipInfo def _legacy_inspection(root: Path) -> dict: files = _source_files(root) paths = [relative for _, relative in files] + has_data_folder = any( + (root.joinpath(*prefix.parts).is_dir() + and not root.joinpath(*prefix.parts).is_symlink()) + for prefix in _DATA_PREFIXES[:2] + ) legacy_session_files = [ root / "temp" / "desktop_sessions.json", root / "temp" / "desktop_sessions.json.migrated", @@ -217,8 +227,10 @@ def _legacy_inspection(root: Path) -> dict: legacy_session_count = sum( path.is_file() and not path.is_symlink() for path in legacy_session_files ) - if not files and not legacy_session_count: - raise BackupFormatError("no memory or session data found") + if not has_data_folder: + raise BackupFormatError( + "not a GA directory (no memory/ or temp/model_responses/)" + ) counts = _content_counts(paths) counts["sessions"] += legacy_session_count return { @@ -276,47 +288,381 @@ def materialize_import_source(source_path: str) -> Iterator[Path]: yield target_root -def merge_data_files(source_dir: str, ga_root: str) -> dict: +def _is_desktop_session_id(value: object) -> bool: + session_id = str(value or "") + return ( + not session_id.startswith("tui_") + and _DESKTOP_SESSION_ID_RE.fullmatch(session_id) is not None + ) + + +def _read_source_sessions(source: Path) -> tuple[list[dict], bool, int]: + """Read supported Desktop session stores without trusting filenames. + + Corrupt/non-Desktop records are skipped. The skipped count is intentionally + record-oriented; an unreadable file counts as one skipped source record. + """ + items: list[dict] = [] + found = False + skipped = 0 + sessions_dir = source / "temp" / "desktop_sessions" + if sessions_dir.is_dir() and not sessions_dir.is_symlink(): + for session_file in sorted(sessions_dir.glob("*.json")): + if session_file.is_symlink() or not session_file.is_file(): + continue + found = True + try: + item = json.loads(session_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError): + skipped += 1 + continue + if isinstance(item, dict): + items.append(item) + else: + skipped += 1 + + for legacy in ( + source / "temp" / "desktop_sessions.json", + source / "temp" / "desktop_sessions.json.migrated", + ): + if legacy.is_symlink() or not legacy.is_file(): + continue + found = True + try: + document = json.loads(legacy.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError): + skipped += 1 + continue + if not isinstance(document, list): + skipped += 1 + continue + for item in document: + if isinstance(item, dict): + items.append(item) + else: + skipped += 1 + return items, found, skipped + + +def _existing_session_ids(destination_root: Path) -> set[str]: + ids: set[str] = set() + sessions_dir = destination_root / "temp" / "desktop_sessions" + if not sessions_dir.is_dir() or sessions_dir.is_symlink(): + return ids + for session_file in sorted(sessions_dir.glob("*.json")): + if session_file.is_symlink() or not session_file.is_file(): + continue + if _is_desktop_session_id(session_file.stem): + ids.add(session_file.stem) + try: + item = json.loads(session_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError): + continue + if isinstance(item, dict) and _is_desktop_session_id(item.get("id")): + ids.add(str(item["id"])) + return ids + + +def _remove_path(path: Path) -> None: + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + + +def _copy_tree_strict(source: Path, destination: Path) -> None: + """Copy a complete tree while refusing links and special files.""" + if source.is_symlink() or not source.is_dir(): + raise ValueError(f"cannot safely copy data folder: {source}") + destination.mkdir(parents=True, exist_ok=False) + for item in sorted(source.iterdir(), key=lambda path: path.name): + if item.is_symlink(): + raise ValueError(f"data folder contains a symbolic link: {item}") + target = destination / item.name + if item.is_dir(): + _copy_tree_strict(item, target) + elif item.is_file(): + shutil.copy2(item, target) + else: + raise ValueError(f"data folder contains an unsupported file: {item}") + + +def _prepare_overlay_target(root: Path, relative: PurePosixPath) -> Path: + current = root + for part in relative.parts[:-1]: + current = current / part + if current.is_symlink(): + raise ValueError(f"memory destination contains a symbolic link: {current}") + if current.exists() and not current.is_dir(): + _remove_path(current) + current.mkdir(exist_ok=True) + target = root.joinpath(*relative.parts) + if target.is_symlink() or target.is_dir(): + _remove_path(target) + return target + + +def _assert_safe_new_target(root: Path, target: Path) -> None: + relative = target.relative_to(root) + current = root + for part in relative.parts[:-1]: + current = current / part + if current.is_symlink(): + raise ValueError(f"data destination contains a symbolic link: {current}") + if current.exists() and not current.is_dir(): + raise ValueError(f"data destination parent is not a directory: {current}") + + +def _new_backup_path(destination_root: Path) -> Path: + timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f") + backup_parent = destination_root / "temp" + for suffix in range(1000): + tail = "" if suffix == 0 else f"_{suffix}" + candidate = backup_parent / f"memory_import_backup_{timestamp}{tail}" + if not candidate.exists() and not candidate.is_symlink(): + return candidate + raise OSError("cannot allocate a unique memory backup directory") + + +def _create_memory_backup(destination_root: Path, memory_root: Path) -> Path: + backup_dir = _new_backup_path(destination_root) + backup_parent = backup_dir.parent + if backup_parent.is_symlink() or ( + backup_parent.exists() and not backup_parent.is_dir() + ): + raise ValueError("current backup destination is not a safe directory") + backup_parent.mkdir(parents=True, exist_ok=True) + staging = backup_parent / f".{backup_dir.name}.staging-{uuid.uuid4().hex}" + try: + staging.mkdir() + _copy_tree_strict(memory_root, staging / "memory") + os.replace(staging, backup_dir) + except Exception: + with contextlib.suppress(OSError): + _remove_path(staging) + raise + return backup_dir + + +def _install_file_add_only(staged: Path, target: Path) -> None: + """Install one staged file without ever replacing an existing path.""" + try: + os.link(staged, target, follow_symlinks=False) + except OSError as error: + unsupported_link_errors = { + errno.EINVAL, + errno.EPERM, + errno.EXDEV, + getattr(errno, "ENOTSUP", errno.EINVAL), + getattr(errno, "EOPNOTSUPP", errno.EINVAL), + } + if error.errno not in unsupported_link_errors: + raise + + # Some removable/network filesystems do not implement hard links. + # O_EXCL retains the add-only contract there; a failed copy removes + # only the path this call successfully created. + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + flags |= getattr(os, "O_BINARY", 0) + descriptor: int | None = None + created = False + try: + descriptor = os.open(target, flags, stat.S_IMODE(staged.stat().st_mode)) + created = True + with staged.open("rb") as reader, os.fdopen(descriptor, "wb") as writer: + descriptor = None + shutil.copyfileobj(reader, writer) + writer.flush() + os.fsync(writer.fileno()) + except Exception: + if descriptor is not None: + with contextlib.suppress(OSError): + os.close(descriptor) + if created: + with contextlib.suppress(OSError): + target.unlink() + raise + with contextlib.suppress(OSError): + staged.unlink() + + +def merge_data_files( + source_dir: str, + ga_root: str, + *, + existing_session_ids: Iterable[str] | None = None, + session_preparer: Callable[[dict], object] | None = None, +) -> dict[str, Any]: + """Transactionally merge a validated data tree into ``ga_root``. + + ``memory`` is source-wins after a durable full backup, responses and + Desktop sessions are add-only, and any activation error rolls back files + installed by this call. ``session_preparer`` lets the bridge validate and + construct its in-memory Session objects before any destination is changed. + """ source = Path(source_dir).expanduser().resolve() destination_root = Path(ga_root).expanduser().resolve() if not source.is_dir(): raise ValueError("source data folder is unavailable") + if not destination_root.is_dir(): + raise ValueError("current data destination is unavailable") if source == destination_root: raise ValueError("source is the same as current data") inspection = _legacy_inspection(source) - memory_copied = 0 - memory_skipped = 0 - responses_copied = 0 + source_memory = source.joinpath(*_DATA_PREFIXES[0].parts) + source_responses = source.joinpath(*_DATA_PREFIXES[1].parts) + destination_memory = destination_root.joinpath(*_DATA_PREFIXES[0].parts) + destination_responses = destination_root.joinpath(*_DATA_PREFIXES[1].parts) + has_source_memory = source_memory.is_dir() and not source_memory.is_symlink() + + memory_files = list(_iter_regular_files(source_memory)) if has_source_memory else [] + response_files: list[tuple[Path, PurePosixPath, Path]] = [] responses_skipped = 0 - - for prefix, copied_key, skipped_key in ( - (_DATA_PREFIXES[0], "memoryCopied", "memorySkipped"), - (_DATA_PREFIXES[1], "responsesCopied", "responsesSkipped"), - ): - copied = 0 - skipped = 0 - source_root = source.joinpath(*prefix.parts) - if source_root.is_dir() and not source_root.is_symlink(): - for item, relative in _iter_regular_files(source_root): - target = destination_root.joinpath(*prefix.parts, *relative.parts) - if target.exists(): - skipped += 1 - continue + if source_responses.is_dir() and not source_responses.is_symlink(): + for item, relative in _iter_regular_files(source_responses): + target = destination_responses.joinpath(*relative.parts) + if target.exists() or target.is_symlink(): + responses_skipped += 1 + continue + _assert_safe_new_target(destination_root, target) + response_files.append((item, relative, target)) + + source_session_items, sessions_found, sessions_skipped = _read_source_sessions(source) + known_session_ids = _existing_session_ids(destination_root) + if existing_session_ids is not None: + known_session_ids.update( + str(value) for value in existing_session_ids if _is_desktop_session_id(value) + ) + planned_session_ids: set[str] = set() + session_files: list[tuple[str, dict, Path, object]] = [] + for item in source_session_items: + session_id = item.get("id") + if not _is_desktop_session_id(session_id): + sessions_skipped += 1 + continue + session_id = str(session_id) + target = destination_root / "temp" / "desktop_sessions" / f"{session_id}.json" + if ( + session_id in known_session_ids + or session_id in planned_session_ids + or target.exists() + or target.is_symlink() + ): + sessions_skipped += 1 + continue + _assert_safe_new_target(destination_root, target) + try: + prepared = session_preparer(item) if session_preparer is not None else item + except Exception: + sessions_skipped += 1 + continue + planned_session_ids.add(session_id) + session_files.append((session_id, item, target, prepared)) + + backup_dir = "" + prepared_sessions = [entry[3] for entry in session_files] + with tempfile.TemporaryDirectory( + prefix=".genericagent-memory-import-", + dir=destination_root, + ) as temp_dir: + staging_root = Path(temp_dir) + staged_memory = staging_root / "memory" + if has_source_memory: + if destination_memory.is_symlink() or ( + destination_memory.exists() and not destination_memory.is_dir() + ): + raise ValueError("current memory destination is not a safe directory") + if destination_memory.is_dir(): + _copy_tree_strict(destination_memory, staged_memory) + else: + staged_memory.mkdir() + for item, relative in memory_files: + target = _prepare_overlay_target(staged_memory, relative) target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(item, target) - copied += 1 - if copied_key == "memoryCopied": - memory_copied, memory_skipped = copied, skipped - else: - responses_copied, responses_skipped = copied, skipped - return { + staged_responses: list[tuple[Path, Path]] = [] + for item, relative, target in response_files: + staged = staging_root / "responses" / Path(*relative.parts) + staged.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, staged) + staged_responses.append((staged, target)) + + staged_sessions: list[tuple[Path, Path]] = [] + for session_id, item, target, _prepared in session_files: + staged = staging_root / "sessions" / f"{session_id}.json" + staged.parent.mkdir(parents=True, exist_ok=True) + staged.write_text( + json.dumps(item, ensure_ascii=False, default=str), + encoding="utf-8", + ) + staged_sessions.append((staged, target)) + + memory_nonempty = ( + destination_memory.is_dir() + and next(destination_memory.iterdir(), None) is not None + ) + if has_source_memory and memory_nonempty: + backup_dir = str(_create_memory_backup(destination_root, destination_memory)) + + installed_files: list[Path] = [] + previous_memory = staging_root / "previous-memory" + memory_original_moved = False + memory_activated = False + memory_existed = destination_memory.is_dir() + try: + if has_source_memory: + if memory_existed: + os.replace(destination_memory, previous_memory) + memory_original_moved = True + os.replace(staged_memory, destination_memory) + memory_activated = True + + for staged, target in (*staged_responses, *staged_sessions): + if target.exists() or target.is_symlink(): + raise OSError(f"data destination changed during import: {target}") + _assert_safe_new_target(destination_root, target) + target.parent.mkdir(parents=True, exist_ok=True) + # Staging shares the destination filesystem. A hard link gives + # POSIX/Windows create-if-absent semantics: unlike replace(), it + # cannot overwrite a response/session created concurrently. + _install_file_add_only(staged, target) + installed_files.append(target) + except Exception as error: + rollback_errors: list[str] = [] + for target in reversed(installed_files): + try: + target.unlink() + except OSError as rollback_error: + rollback_errors.append(str(rollback_error)) + if memory_activated or memory_original_moved: + try: + if destination_memory.exists() or destination_memory.is_symlink(): + _remove_path(destination_memory) + if memory_original_moved: + os.replace(previous_memory, destination_memory) + except OSError as rollback_error: + rollback_errors.append(str(rollback_error)) + if rollback_errors: + raise OSError( + f"data import failed: {error}; rollback failed: " + + "; ".join(rollback_errors) + ) from error + raise + + result: dict[str, Any] = { "ok": True, - "memoryCopied": memory_copied, - "memorySkipped": memory_skipped, - "responsesCopied": responses_copied, + "memoryCopied": len(memory_files), + "memorySkipped": 0, + "responsesCopied": len(response_files), "responsesSkipped": responses_skipped, - "backupDir": "", + "sessionsAdded": len(session_files), + "sessionsSkipped": sessions_skipped, + "sessionsFileFound": sessions_found, + "backupDir": backup_dir, "sourceType": inspection["sourceType"], } + if session_preparer is not None: + result["_preparedSessions"] = prepared_sessions + return result diff --git a/frontends/desktop/e2e/macos/README.md b/frontends/desktop/e2e/macos/README.md index 8846c410d..42cad666e 100644 --- a/frontends/desktop/e2e/macos/README.md +++ b/frontends/desktop/e2e/macos/README.md @@ -6,11 +6,15 @@ binary, and later moves that exact app to a path containing spaces and Chinese c ```bash frontends/desktop/e2e/macos/Invoke-macOSUserJourney.sh \ - --artifact /path/GenericAgent-Desktop-macOS.dmg \ + --artifact /path/GenericAgent-Desktop-macOS-aarch64.dmg \ --expected-commit \ --keep-work-dir ``` +The release workflow builds this artifact on the explicit macOS 15 arm64 runner and names it +`aarch64`; it is not an Intel/universal binary. The app is ad-hoc signed only, not Developer ID +signed or notarized. + In addition to the shared chat/data/port/relocation checks, this journey hard-fails if the DMG does not contain the build-time `.prepared` marker or if any file inside the `.app` changes from first launch through restart and relocation. It verifies the package bridge remains inside the diff --git a/frontends/desktop/packaging/README.md b/frontends/desktop/packaging/README.md index 961e7319c..433e46392 100644 --- a/frontends/desktop/packaging/README.md +++ b/frontends/desktop/packaging/README.md @@ -2,7 +2,7 @@ 桌面端发布相关材料。本目录的内容**不会**整体打进发布包——CI (`.github/workflows/desktop-release-package.yml`)只从这里挑选 `scripts/` 下的 -安装/卸载脚本拷进各平台的发布产物,其余文件对构建打包过程是只读参考。 +安装/卸载脚本和两个 requirements lock;其余文件对构建打包过程是只读参考。 ## 目录结构 @@ -11,7 +11,9 @@ frontends/desktop/packaging/ ├── README.md # 本说明 ├── CHECKLIST.md # 发布前功能测试清单(测试协调用,不参与打包) ├── TODO.md # 各平台测试分工与计划(测试协调用,不参与打包) -└── scripts/ # ← 唯一被 CI 消费的内容 +├── python-runtime-requirements.txt # 三个平台的精确 Python runtime 依赖图 +├── dmg-build-requirements.txt # 仅 CI 使用、带 SHA-256 的 DMG 布局工具 +└── scripts/ # 平台安装/卸载脚本 ├── windows/ │ ├── install_windows.ps1 # 环境准备脚本 │ ├── uninstall.bat # 卸载入口(向用户确认后调用 ps1) @@ -28,12 +30,96 @@ frontends/desktop/packaging/ `desktop-release-package.yml` 在打各平台 portable 包时,把对应平台的脚本 `cp` 进发布目录(例如 Windows 包里放 `install_windows.ps1` / -`uninstall.bat` / `uninstall_windows.ps1`)。**修改安装/卸载行为只需改 -`scripts/` 下的文件,不需要动 workflow。** +`uninstall.bat` / `uninstall_windows.ps1`)。Python wheelhouse 同时带入 +`python-runtime-requirements.txt` 的副本,首次离线准备与 macOS 离线修复都必须从该 +精确版本清单安装。macOS 当前只发布 DMG;`uninstall.command` 保留为脚本材料,但不再 +复制到不会上传的 portable 临时目录。 > 说明:实际的桌面壳二进制(`GenericAgent.exe` / `.AppImage` / `.app`)由 CI > 构建生成并发布到 GitHub Release,不在本仓库内提交,也不在本目录占位。 +## Release 权限与发布流程 + +```text +desktop-portable-* tag push + ├─ Windows build (contents:read) ─┐ + ├─ Linux build (contents:read) ─┼─ Actions artifacts ─┐ + └─ macOS build (contents:read) ─┘ │ + ▼ + publish-release (contents:write) + 精确下载三个 artifact + 校验六个文件名/非空/SHA-256 + 创建不可见 draft 并一次上传 + 校验远端六个 assets + 公开为统一 prerelease +``` + +三个 build job 的 checkout 均设置 `persist-credentials: false`,不接收写 token,也不调用 +GitHub Release API。`publish-release` 显式 `needs` 三个平台,且只在 tag push、三个结果都为 +`success` 时运行;它不 checkout、不运行 npm/pip/Cargo 或仓库构建脚本。上传失败时最多留下 +不可见 draft,不会向用户暴露半套 Release;发布者也拒绝覆盖同 tag 的既有 Release。 + +`workflow_dispatch` 只运行所选平台(或 all)的候选 artifact 构建。即使未选择的 jobs 是 +`skipped`,发布 job 的事件与三个 `success` 条件也不会成立,因此手工运行不会创建 Release。 +应用 metadata 版本仍为 `0.2.0`;workflow 只消费维护者实际 push 的匹配 tag,不创建、移动或 +改写既有 tag,也不把 `V2.0.0` 等公开命名强行绑定到应用 metadata。 + +统一 prerelease 必须精确包含以下六个文件: + +- `GenericAgent-Desktop-Windows-Portable.zip` +- `SHA256SUMS-windows.txt` +- `GenericAgent-Desktop-Linux-Portable.tar.gz` +- `SHA256SUMS-linux.txt` +- `GenericAgent-Desktop-macOS-aarch64.dmg` +- `GenericAgent-Desktop-macOS-aarch64.dmg.sha256` + +## 固定输入与可复现边界 + +- npm 使用 `npm ci` 和已提交的 `package-lock.json`;Node 固定为 `22.23.2`。 +- Rust toolchain 固定为 `1.95.0`,crate 图由 `Cargo.lock` 固定。 +- `actions/checkout`、`setup-node`、`setup-python`、artifact upload/download、Rust toolchain + action 与 Rust cache action 均固定完整 commit SHA,workflow 行尾保留对应版本/来源注释。 +- python-build-standalone 固定 release `20260814`、CPython `3.12.14` 和明确架构;Windows + x86_64、Linux x86_64、macOS arm64 三个 tarball 均在解压前校验已记录的 SHA-256。 +- Python runtime 的 32 个直接/传递依赖全部在 `python-runtime-requirements.txt` 使用 `==`; + wheel 下载强制 `--only-binary=:all:`。因此离线准备不需要 `setuptools`/`wheel`,二者不再 + 放入 wheelhouse。macOS 的 `ds-store==1.3.3` 与 `mac-alias==2.2.3` 另行使用 wheel hash 锁。 +- runner label 使用 `windows-2025`、`ubuntu-24.04`、`macos-15`。GitHub 当前的 + `macos-15` 标准 runner 是 Apple silicon;workflow 仍以 `uname -m == arm64` 硬断言, + PBS 与最终 DMG 名称也明确为 `aarch64`。 + +这里不声称 bit-for-bit reproducible,仍有以下明确风险:GitHub runner label 内的 image +revision 会更新;Ubuntu apt 包与 Windows runner 提供的 UCRT SDK 版本未锁;Python runtime +依赖版本虽已固定,但三平台 wheel 文件 hash 尚未完整写入跨平台 lock;Tauri/系统打包工具 +也可能写入时间戳或平台元数据。以上变化必须由真包 SHA-256 与 L5 旅程留证,不能把当前约束 +描述成完整可复现构建。 + +## macOS 架构与签名事实 + +当前 macOS 产物只支持 Apple silicon arm64,文件名为 +`GenericAgent-Desktop-macOS-aarch64.dmg`。应用在嵌入 runtime 后只做 ad-hoc signing,且 +`codesign --verify --deep --strict` 失败会直接终止构建。当前没有 Apple Developer ID 签名, +也没有 notarization;Release notes 必须保留这一事实,不能描述为已公证或受 Apple 信任。 + +## 包体精简记录 + +下列实测基于从 commit `e09643142d445424d1d3cb779245da0e920e2339` 派生的本任务工作树, +在 2026-08-22 以 Node `22.23.2` 执行 `npm ci` + production build,并用 workflow 同等 +source excludes 对 runtime/app 源树做 gzip 对比: + +| 项目 | 结果 | 性质 | +|---|---:|---| +| 生成的 `frontends/desktop/dist` | 106 files / 4,687,796 bytes | 实测未压缩 payload | +| runtime source gzip(仍含 dist) | 4,410,077 bytes | 实测对照 | +| runtime source gzip(排除 dist) | 2,529,269 bytes | 实测新结果 | +| 单份 runtime source gzip 减少 | 1,880,808 bytes(约 1.79 MiB) | 实测;最终 ZIP/tar.gz/DMG 会因各自压缩器而不同 | + +`setuptools-84.0.0` 与 `wheel-0.48.0` 的当前 wheel 合计实测为 851,536 bytes(约 +0.81 MiB);这是对旧浮动解析在测量时点的估算,每个平台最终压缩包的实际差值会不同,不能 +当作历史产物精确差值。删除 macOS portable staging 对发布 DMG 的包体差值是 **0**(该目录 +从未上传);CI 峰值磁盘预计减少一份 `.app + runtime` 的重复副本,但未运行完整远端 macOS +job 测量,因此只记录为估算,不给出伪精确数字。 + ## 自动化测试体系 CI 会先运行零依赖的 `npm run test:ci-contract`,确认 workflow、npm 清单与锁文件、 diff --git a/frontends/desktop/packaging/dmg-build-requirements.txt b/frontends/desktop/packaging/dmg-build-requirements.txt new file mode 100644 index 000000000..677ed72bc --- /dev/null +++ b/frontends/desktop/packaging/dmg-build-requirements.txt @@ -0,0 +1,5 @@ +# Pure-Python tools used only to write the curated Finder layout. +ds-store==1.3.3 \ + --hash=sha256:b92a371efbf1b4ccce2a04d1ed13fceacc4736c81ba09cf5aefb74c088160a35 +mac-alias==2.2.3 \ + --hash=sha256:7362b521d2132ef92f606a37abfed5fcd849ceb2f28b6f9743e014b02af92f0d diff --git a/frontends/desktop/packaging/python-runtime-requirements.txt b/frontends/desktop/packaging/python-runtime-requirements.txt new file mode 100644 index 000000000..3fe5dd802 --- /dev/null +++ b/frontends/desktop/packaging/python-runtime-requirements.txt @@ -0,0 +1,37 @@ +# Desktop embedded-runtime lock for CPython 3.12.14. +# +# Direct and transitive versions are exact so all three builders resolve the same +# dependency graph. Wheel file hashes remain platform-specific and are not locked +# here; see README.md for that remaining reproducibility risk. +aiohappyeyeballs==2.7.1 +aiohttp==3.14.3 +aiosignal==1.4.0 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.14.2 +attrs==26.1.0 +beautifulsoup4==4.15.0 +bottle==0.13.4 +certifi==2026.7.22 +charset-normalizer==3.5.1 +click==8.4.2 +colorama==0.4.6 +fastapi==0.141.1 +frozenlist==1.8.0 +h11==0.16.0 +idna==3.19 +multidict==6.7.1 +propcache==0.5.2 +psutil==7.1.1 +pydantic==2.13.4 +pydantic-core==2.46.4 +requests==2.34.2 +simple-websocket-server==0.4.4 +soupsieve==2.9.2 +starlette==1.6.0 +typing-extensions==4.16.0 +typing-inspection==0.4.4 +urllib3==2.7.0 +uvicorn==0.52.1 +websockets==17.0.1 +yarl==1.24.5 diff --git a/frontends/desktop/packaging/scripts/linux/install_linux.sh b/frontends/desktop/packaging/scripts/linux/install_linux.sh index f8b14242c..efdf3c07b 100755 --- a/frontends/desktop/packaging/scripts/linux/install_linux.sh +++ b/frontends/desktop/packaging/scripts/linux/install_linux.sh @@ -199,13 +199,15 @@ install_dependencies() { if [[ -n "$WHEEL_DIR" ]]; then # Offline (portable bundle): install from local wheels only, no network, no pip self-upgrade. log_step "Install dependencies offline from $WHEEL_DIR" + local locked_requirements="$WHEEL_DIR/requirements.txt" + [[ -f "$locked_requirements" ]] || fail "Pinned offline requirements are missing: $locked_requirements" # Install deps directly (NOT an editable -e of the source): an editable install bakes the # project's absolute path into a .pth. Combined with --no-venv (deps go into the relocatable # embedded python) this keeps the portable bundle movable. The bridge adds the source to # sys.path itself (ensure_ga_import_path), so no install of the project is needed. # shellcheck disable=SC2086 "$py" -m pip install --no-index --find-links "$WHEEL_DIR" \ - "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil $EXTRA_PACKAGES \ + --requirement "$locked_requirements" $EXTRA_PACKAGES \ || fail "Offline pip install failed (check wheel dir)." else log_step "Install/refresh minimal Python dependencies" diff --git a/frontends/desktop/packaging/scripts/macos/install_macos.sh b/frontends/desktop/packaging/scripts/macos/install_macos.sh index 39c4652ab..56169d763 100755 --- a/frontends/desktop/packaging/scripts/macos/install_macos.sh +++ b/frontends/desktop/packaging/scripts/macos/install_macos.sh @@ -119,7 +119,10 @@ install_deps() { if [[ -n "$WHEEL_DIR" && -d "$WHEEL_DIR" ]]; then # A wheelhouse repair only needs pip itself. Keep the path fully offline and # do not install setuptools/wheel into the prepared runtime. - "$py" -m pip install --no-compile --no-index --find-links "$WHEEL_DIR" "${pkgs[@]}" + local locked_requirements="$WHEEL_DIR/requirements.txt" + [[ -f "$locked_requirements" ]] || fail "Pinned offline requirements are missing: $locked_requirements" + "$py" -m pip install --no-compile --no-index --find-links "$WHEEL_DIR" \ + --requirement "$locked_requirements" "${pkgs[@]:6}" else log_warn "No wheel dir supplied; falling back to online pip install" "$py" -m pip install --upgrade pip setuptools wheel diff --git a/frontends/desktop/packaging/scripts/windows/install_windows.ps1 b/frontends/desktop/packaging/scripts/windows/install_windows.ps1 index e501d5b30..1b1035301 100644 --- a/frontends/desktop/packaging/scripts/windows/install_windows.ps1 +++ b/frontends/desktop/packaging/scripts/windows/install_windows.ps1 @@ -159,6 +159,10 @@ function Install-Dependencies([string]$root, [string]$py) { # Offline mode (portable bundle): install from local wheels only, no network, no pip self-upgrade. if ($WheelDir) { $wd = (Resolve-Path $WheelDir -ErrorAction Stop).Path + $lockedRequirements = Join-Path $wd "requirements.txt" + if (-not (Test-Path $lockedRequirements -PathType Leaf)) { + Fail "Pinned offline requirements are missing: $lockedRequirements" + } Write-Ok "offline wheels: $wd" if ($extra.Count) { Write-Ok "extra packages: $($extra -join ', ')" } Write-Host "GAPROGRESS|deps" @@ -167,7 +171,7 @@ function Install-Dependencies([string]$root, [string]$py) { # project's absolute path into a .pth. With -NoVenv (deps go into the relocatable embedded # python) this keeps the portable bundle movable. The bridge adds the source to sys.path # itself (ensure_ga_import_path), so the project itself need not be installed. - & $py -m pip install --no-index --find-links $wd "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil @extra + & $py -m pip install --no-index --find-links $wd --requirement $lockedRequirements @extra if ($LASTEXITCODE -ne 0) { Fail "offline pip install failed (check wheels dir)." } Write-Host "GAPROGRESS|done" return diff --git a/frontends/desktop/public/assets/fonts/README.md b/frontends/desktop/public/assets/fonts/README.md deleted file mode 100644 index f8e5a57e8..000000000 --- a/frontends/desktop/public/assets/fonts/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Desktop Font Assets - -These font files are the offline Latin bundle for the vanilla desktop shell. - -## Files - -- `azonix-wordmark.woff2` - - Role: brand wordmark only - - Source: converted on 2026-05-25 from the local owner-installed file at `~/Library/Fonts/azonix/Azonix.otf` - - License: owner-provided / verify before external redistribution - - Note: this repo currently treats Azonix as a project-local brand asset - -- `lexend-latin.woff2` - - Role: English titles and navigation - - Source: Google Fonts CSS2 API, specimen page - - Downloaded: 2026-05-25 - - License: SIL Open Font License 1.1 - -- `noto-sans-latin.woff2` - - Role: English body copy and default Latin UI text - - Source: Google Fonts CSS2 API, specimen page - - Downloaded: 2026-05-25 - - License: SIL Open Font License 1.1 - -- `jetbrains-mono-latin.woff2` - - Role: config/value dense controls and numeric surfaces - - Source: Google Fonts CSS2 API, specimen page - - Downloaded: 2026-05-25 - - License: SIL Open Font License 1.1 - -## Notes - -- Only Latin subsets are bundled here. Chinese UI text continues to use the existing system fallback stack. -- Runtime does not fetch fonts from a CDN. `frontends/desktop/static/assets/fonts/fonts.css` is the only font entrypoint for the vanilla shell. diff --git a/frontends/desktop/public/assets/fonts/azonix-wordmark.woff2 b/frontends/desktop/public/assets/fonts/azonix-wordmark.woff2 deleted file mode 100644 index 947bfbebc..000000000 Binary files a/frontends/desktop/public/assets/fonts/azonix-wordmark.woff2 and /dev/null differ diff --git a/frontends/desktop/public/assets/fonts/fonts.css b/frontends/desktop/public/assets/fonts/fonts.css deleted file mode 100644 index 8103b351d..000000000 --- a/frontends/desktop/public/assets/fonts/fonts.css +++ /dev/null @@ -1,21 +0,0 @@ -@font-face { - font-family: "GA Azonix"; - src: - url("./azonix-wordmark.woff2") format("woff2"), - local("Azonix"), - local("Azonix Regular"), - local("Azonix-Regular"); - font-style: normal; - font-weight: 400; - font-display: swap; - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+20AC, U+2122, U+2212, U+FEFF, U+FFFD; -} - -@font-face { - font-family: "GA JetBrains Mono"; - src: url("./jetbrains-mono-latin.woff2") format("woff2"); - font-style: normal; - font-weight: 400 600; - font-display: swap; - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} diff --git a/frontends/desktop/public/assets/fonts/jetbrains-mono-latin.woff2 b/frontends/desktop/public/assets/fonts/jetbrains-mono-latin.woff2 deleted file mode 100644 index 2ca6ac605..000000000 Binary files a/frontends/desktop/public/assets/fonts/jetbrains-mono-latin.woff2 and /dev/null differ diff --git a/frontends/desktop/public/assets/fonts/lexend-latin.woff2 b/frontends/desktop/public/assets/fonts/lexend-latin.woff2 deleted file mode 100644 index 0968c152f..000000000 Binary files a/frontends/desktop/public/assets/fonts/lexend-latin.woff2 and /dev/null differ diff --git a/frontends/desktop/public/assets/fonts/noto-sans-latin.woff2 b/frontends/desktop/public/assets/fonts/noto-sans-latin.woff2 deleted file mode 100644 index e08293045..000000000 Binary files a/frontends/desktop/public/assets/fonts/noto-sans-latin.woff2 and /dev/null differ diff --git a/frontends/desktop/public/i18n.js b/frontends/desktop/public/i18n.js deleted file mode 100644 index 0c90a9f9e..000000000 --- a/frontends/desktop/public/i18n.js +++ /dev/null @@ -1,496 +0,0 @@ -(() => { - const I18N = { - zh: { - 'app.title': 'GenericAgent 桌面版', - 'brand.sub': '桌面终端', - 'nav.chat': '聊天', 'nav.services': '后台服务', 'nav.channels': '消息通道', 'nav.status': '状态面板', - 'nav.collab': '指挥家', 'nav.token': '用量', - 'foot.settings': '配置', 'foot.ver': 'GenericAgent · 桌面版', - 'chat.startTitle': '开始对话', 'chat.startSub': '直接输入,或点预设功能一键启动', - 'preset.butler.t': '指挥家', 'preset.butler.d': '复杂任务自动拆解,只需查看进度和简报', - 'preset.plan.t': 'Plan 模式', 'preset.plan.d': '加载 Plan SOP,按探索→规划→执行→验证流程', - 'preset.goal.t': 'Goal 模式', 'preset.goal.d': '设定目标,自主完成', - 'preset.autonomous.t': '自主行动', 'preset.autonomous.d': '按 SOP 规划/执行任务,产出报告(reflect/autonomous.py 同源)', - 'preset.hive.t': 'Hive 协作', 'preset.hive.d': '多 worker 协同攻坚', - 'preset.review.t': '深度复核', 'preset.review.d': '挑刺式质量把关', - 'preset.findwork.t': '找点事做', 'preset.findwork.d': '分析当前情况,推荐一批让你感兴趣的 TODO', - 'preset.mine.t': '我的·周报', 'preset.mine.d': '自定义:抓本周提交并写周报', - 'preset.add.t': '自定义', 'preset.add.d': '任意一句话存为功能', - 'composer.placeholder': 'GA 能帮你做些什么?', - 'search.placeholder': '搜索会话…', 'conv.new': '新对话', - 'ctx.pin': '置顶', 'ctx.unpin': '取消置顶', 'ctx.rename': '重命名', 'ctx.del': '删除', - 'common.close': '关闭', 'common.more': '更多', 'common.optional': '选填', 'common.save': '保存', - 'modal.preset': '预设功能', 'modal.addModel': '添加模型', 'modal.editModel': '编辑模型', 'modal.settings': '配置', - 'modal.customPreset': '自定义预设', - 'modal.editCustomPreset': '编辑任务', - 'customPreset.titlePh': '标题,例如「写周报」', - 'customPreset.promptPh': 'Prompt 内容,发送时会作为消息提交', - 'customPreset.empty': '标题和 Prompt 不能为空', - 'customPreset.removeTitle': '删除', - 'customPreset.editTitle': '编辑', - 'builtinPreset.restoreBtn': '恢复默认预设', - 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.importMemory': '导入已有记忆与会话记录(选择 GenericAgent 根目录)', 'set.gaSource': '接入独立 GenericAgent 源码(把桌面版当作壳)', 'set.gaSourceClear': '取消接入,改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理', - 'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式?', - 'appearance.light': '浅色', 'appearance.dark': '深色', - 'set.noModels': '暂无模型,点击下方添加', - 'lang.zh': '简体中文', 'lang.en': 'English', - 'model.name': '备注', 'model.namePh': '会显示在模型列表', - 'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': '留空则保持原 Key 不变', - 'model.apibase': 'API 地址', 'model.apibasePh': 'https://.../v1/messages', - 'model.protocol': '协议', 'model.protocolPick': '请选择…', 'model.protocolOai': 'OpenAI 兼容 (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)', - 'model.stream': '响应方式', 'model.streamOn': '流式', 'model.streamOff': '非流式', - 'model.model': '模型', 'model.modelPh': 'model 参数名', - 'model.modelHint': '须与中转站/官方文档中的 model 字段完全一致', - 'model.retries': '重试 (次)', 'model.connTimeout': '连接超时 (s)', 'model.readTimeout': '读取超时 (s)', - 'model.save': '保存', 'common.cancel': '取消', 'common.confirm': '确认', 'common.edit': '编辑', 'common.delete': '删除', - 'pq.title': '快速接入官方模型', 'pq.sub': '填好 API Key 即可使用', 'pq.toggle': '展开 / 收起', - 'pq.deepseekDesc': '官方 API · OpenAI 兼容', 'pq.qwenDesc': '通义千问 · 阿里云百炼', - 'guide.step1': '点击下方链接,登录后创建并复制 API Key', - 'guide.step2': '把 Key 粘贴到下方「API Key」输入框', - 'guide.step3': '点击保存,即可在模型列表中选用', - 'guide.prefillTip': '已为你预填 API 地址、协议与模型,可按需修改', - 'guide.getKey': '获取 {name} 的 API Key', 'guide.copy': '复制链接', 'guide.copied': '链接已复制', - 'err.modelSave': '保存失败', 'err.modelSwitch': '切换模型失败', 'err.modelRequired': '请填写模型、API Key 和 API 地址', - 'err.modelDelete': '删除失败', 'err.modelDeleteLast': '至少保留一个模型', - 'confirm.modelDelete': '确定删除该模型配置?', - 'model.aggregation': '渠道组(自动故障转移)', 'model.aggregationShort': '渠道组', 'model.aggregationDesc': '按顺序尝试,失败自动切换到下一个', - 'model.emptyMixin': '尚未加入模型', - 'model.addToMixin': '加入渠道组', 'model.inMixin': '已在渠道组', 'model.removeFromMixin': '移出渠道组', 'model.alreadyInMixin': '已在渠道组中', 'model.dragReorder': '拖拽调整顺序', - 'err.mixinFailed': '操作失败', - 'page.services.title': '后台服务', 'page.services.sub': 'IM 消息通道与后台进程,集中查看、启停与日志', - 'page.channels.title': '消息通道', 'page.channels.sub': '后台 IM 进程:列表、启停与日志(同 hub.pyw)', - 'page.status.title': '状态面板', 'page.status.sub': 'hub.pyw 管理的后台进程/服务,集中查看与启停', - 'page.collab.title': '指挥家', 'page.collab.sub': '交代目标,自动拆活与跟进', - 'collab.progressTitle': '分工进度', - 'collab.progressEmpty': '还没有任务在执行。告诉指挥家你的目标后,这里会显示拆分后的处理进度。', - 'collab.placeholder': '请对指挥家描述你想完成的目标', - 'collab.guideTitle': '把要完成的事告诉指挥家', - 'collab.guideWhen': '适合需要多步处理、要花一些时间才能完成的目标。日常聊天和快问快答,请用左侧「聊天」。', - 'collab.guideStep1t': '描述目标', - 'collab.guideStep1d': '在聊天框里写下你想做的事,发给指挥家', - 'collab.guideStep2t': '自动拆解', - 'collab.guideStep2d': '指挥家自动拆解、分配任务,实时监督和调度', - 'collab.guideStep3t': '交付摘要', - 'collab.guideStep3d': '指挥家根据执行状态,呈上任务简报', - 'collab.guideStep4t': '随时调整', - 'collab.guideStep4d': '随时补充要求或细节,指挥家都会处理', - 'collab.chipProgress': '现在进展如何?', - 'collab.chipPause': '先暂停当前任务', - 'collab.chipSummary': '总结一下目前的结果', - 'collab.showProgressTitle': '查看分工进度', - 'collab.statRunning': '进行中', - 'collab.statDone': '已完成', - 'collab.plusMenu': '更多操作', - 'collab.switchMode': '切换模式', - 'collab.typing': '指挥家正在处理', - 'collab.offline': '无法连接指挥家服务,请确认后端已启动。', - 'collab.retry': '重试', - 'collab.reconnect': '连接断开,正在重连… 已保留上次任务进度。', - 'collab.reconnectIn': '{n} 秒后重试', - 'collab.stRunning': '执行中', 'collab.stReported': '已回报', 'collab.stPaused': '已暂停', - 'collab.stFailed': '遇到问题', 'collab.stTerminated': '已终止', - 'collab.summaryRunning': '正在处理中…', 'collab.summaryWait': '等待回报', - 'collab.taskFallback': '任务 {n}', - 'collab.timeJust': '刚刚', - 'collab.timeSec': '{n} 秒前', - 'collab.timeMin': '{n} 分钟前', - 'collab.timeHr': '{n} 小时前', - 'collab.timeDay': '{n} 天前', - 'page.token.title': '用量', 'page.token.sub': '每会话与累计用量及缓存率', - 'status.connecting': '正在连接…', 'status.ready': '服务在线', 'status.running': '处理中', - 'status.disconnected': '服务离线', 'status.stopped': '已停止', 'status.idle': '待命', - 'conv.emptyList': '暂无会话,点「+ 新对话」开始', 'conv.defaultTitle': '新对话', - 'err.bridge': '服务未响应', 'err.newSession': '新建会话失败', 'err.poll': '轮询失败', 'err.stop': '停止失败', - 'err.interruptTimeout': '等待上一轮停止超时,请稍后再试', - 'sys.interruptPrev.hint': '已停止上一轮,正在处理新消息', - 'chat.interrupting': '正在停止上一轮…', - 'chat.sessionLoading': '正在加载会话…', - 'sys.stopRequested': '已请求停止', - 'slash.help': '可用命令:\n/new 新会话 /clear 清屏 /stop 停止 /settings 设置', - 'slash.unknown': '未知命令', - 'upload.hint': '上传文件:选择 / 拖拽 / 粘贴', - 'upload.button': '上传文件', - 'upload.tooLarge': '文件过大或数量超限', 'upload.empty': '跳过空文件', - 'upload.failed': '上传失败', - 'err.charLimit': '已达字数上限({n}),发送时将自动截断', 'err.charLimitReached': '已达字数上限({n})', 'err.numMax': '不能超过 {n}', - 'file.openFailed': '无法打开文件', - 'file.kindGeneric': '文件', - 'file.kindDoc': '文档', - 'file.kindSheet': '表格', - 'file.kindSlide': '幻灯片', - 'file.kindCode': '代码', - 'file.kindArchive': '压缩包', - 'file.kindAudio': '音频', - 'file.kindVideo': '视频', - 'upload.removeTitle': '移除', - 'upload.dropHint': '松开以上传文件', - 'lightbox.closeTitle': '关闭', - 'fold.thinking': '思考', 'fold.tool': '工具调用', 'fold.toolResult': '工具结果', 'fold.llm': 'LLM Running', 'fold.turn': '第 {n} 轮', - 'plan.header': '计划 ({done}/{total})', 'plan.complete': '✓ 计划完成 ({n}/{n})', - 'plan.running': '计划执行中', 'plan.completeTitle': '计划完成', - 'plan.placeholder': '计划模式已激活', 'plan.waiting': '等待写入 {path} …', 'plan.overflow': '还有 {n} 项', - 'plan.current': '当前', 'plan.fold': '折叠', 'plan.collapse': '收起', 'plan.expand': '展开', 'plan.details': '详情', 'plan.dismiss': '不再显示', - 'plan.capsuleRunning': '运行中', 'plan.capsuleComplete': '已完成', - 'timing.elapsed': '已运行 {t}', - 'model.auto': '自动选择', - 'model.menuLabel': '选择模型', - 'chip.plan': 'Plan', - 'chip.auto': 'Auto', - 'ch.wechat': '微信', 'ch.wecom': '企业微信', 'ch.lark': '飞书', 'ch.dingtalk': '钉钉', - 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', - 'ch.loading': '加载中…', 'ch.empty': '未发现 IM 进程脚本', - 'ch.logEmpty': '暂无日志', - 'err.channelLoad': '加载失败', 'err.channelStart': '启动失败', 'err.channelStop': '停止失败', - 'err.mykeyImport': '导入模型配置失败', - 'err.mykeyExport': '导出模型配置失败', - 'err.channelNotConfigured': '请先在 mykey.py 中配置该平台', - 'sys.channelStarted': '已启动', 'sys.channelStopped': '已停止', - 'modal.channelLogs': '进程日志', - 'modal.mykeyConfig': 'mykey.py 配置', - 'sys.configSaved': '配置已保存', - 'sys.mykeyImported': '模型配置已导入', - 'sys.mykeyExported': '模型配置已导出', - 'sys.memoryImported': '记忆已导入', - 'err.memoryImport': '导入记忆失败', - 'sys.memoryImportBackup': '原记忆已备份至', - 'sys.memorySessions': '会话', - 'sys.gaSourcePickTitle': '选择要接入的 GenericAgent 源码根目录(含 agentmain.py)', - 'sys.gaSourceSwitching': '正在切换并重启后端...', - 'sys.gaSourceSet': '已接入独立 GenericAgent', - 'sys.gaSourceCleared': '已取消接入,恢复内置版本', - 'err.gaSourceSet': '接入失败', - 'err.gaSourceDesktopOnly': '此功能仅在桌面版中可用', - 'sys.memoryPickTitle': '选择 GenericAgent 根目录(包含 memory 与 temp 的目录)', - 'sys.memoryImportPrompt': '请输入 GenericAgent 根目录的完整路径(包含 memory 与 temp 的目录,而非 memory 文件夹本身):', - 'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常', 'st.missingDeps': '待安装', - 'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出', 'act.installDeps': '安装依赖', 'act.installing': '安装中…', - 'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送', - 'sys.depsInstalled': '依赖安装完成', 'err.installDeps': '安装失败', - 'proc.imbotWechat': 'imbot · 微信', 'proc.imbotDing': 'imbot · 钉钉', 'proc.scheduler': '定时任务调度', 'proc.conductor': '指挥家', - 'cm.scheduling': '调度中', 'cm.running': '执行中', 'cm.idleSt': '空闲', - 'cm.master': '已派 3 子任务', 'cm.w1': '子任务:抓取数据', 'cm.w2': '子任务:复核结果', 'cm.sub': '等待派单', - 'tok.total': '累计', 'tok.cost': '缓存率', 'tok.today': '今日', 'tok.tabAll': '聊天', 'tok.tabConductor': '指挥家', 'tok.condTotal': '指挥家累计', 'tok.condCurrent': '指挥家本次', 'tok.condTip': '指挥家消耗不计入聊天累计', 'tok.condOffline': '指挥家服务离线', 'tok.disclaimer': '不同 API 网站的计费价格可能会有差异,请以实际网站为准。', - 'tok.colSession': '会话', 'tok.colIn': '输入', 'tok.colOut': '输出', 'tok.colCacheW': '缓存写入', 'tok.colCache': '缓存读取', 'tok.colCost': '成本', - 'tok.from': '从', 'tok.to': '到', 'tok.reset': '重置', 'tok.noData': '暂无记录', 'tok.deleted': '此会话已删除', - 'tok.pricingUnknown': '⚠ 此模型计费规则尚未明确,按默认估算', - 'tok.priceInput': '输入: $', 'tok.priceOutput': '输出: $', - 'tok.priceCacheW': '缓存写入: $', 'tok.priceCacheR': '缓存读取: $', - 'presetPrompt.goal': '进入 Goal 模式:读 L3 goal mode SOP,自主达成我接下来描述的目标。', - 'presetPrompt.plan': '进入 Plan 模式:先读 memory/plan_sop.md,按其中「探索→规划→执行→验证」流程,等我接下来描述要做的任务。', - 'presetPrompt.autonomous': '🤖 进入自主行动模式:阅读 memory/autonomous_operation_sop.md,按 SOP 选取或规划任务,独立执行并产出报告。', - 'presetPrompt.hive': '启动 Goal Hive 模式:按 hive SOP 拉起多个 worker 协同完成我接下来的目标。', - 'presetPrompt.review': '进入监察者模式:对刚才的产出严格挑刺、逐项复核并报告问题。', - 'presetPrompt.findwork': '按照自主行动的规划部分,充分分析我的情况,给我生成一批 TODO,务必让我感兴趣。', - 'presetPrompt.mine': '抓取本周的 git 提交并写一份周报。', - 'ask.banner': 'GA 等你回答', - 'ask.replyHint': '在下方输入框回复', - 'ask.placeholderOpen': '在此输入你的回答… (Enter 发送)', - }, - en: { - 'app.title': 'GenericAgent Desktop', - 'brand.sub': 'Desktop terminal', - 'nav.chat': 'Chat', 'nav.services': 'Services', 'nav.channels': 'Channels', 'nav.status': 'Status', - 'nav.collab': 'Conductor', 'nav.token': 'Usage', - 'foot.settings': 'Settings', 'foot.ver': 'GenericAgent · Desktop', - 'chat.startTitle': 'Start a conversation', 'chat.startSub': 'Type a message, or pick a preset', - 'preset.butler.t': 'Conductor', 'preset.butler.d': 'Auto-decompose complex tasks; just check progress and briefings', - 'preset.plan.t': 'Plan mode', 'preset.plan.d': 'Load Plan SOP — explore→plan→execute→verify', - 'preset.goal.t': 'Goal mode', 'preset.goal.d': 'Set a goal, run autonomously', - 'preset.autonomous.t': 'Autonomous mode', 'preset.autonomous.d': 'Plan/execute tasks per SOP and produce reports (same as reflect/autonomous.py)', - 'preset.hive.t': 'Hive', 'preset.hive.d': 'Multi-worker collaboration', - 'preset.review.t': 'Deep review', 'preset.review.d': 'Strict quality check', - 'preset.findwork.t': 'Find me work', 'preset.findwork.d': 'Analyze my context and suggest a batch of interesting TODOs', - 'preset.mine.t': 'My · Weekly', 'preset.mine.d': 'Custom: weekly report from commits', - 'preset.add.t': 'Custom', 'preset.add.d': 'Save any prompt as a function', - 'composer.placeholder': 'What can GA do for you?', - 'search.placeholder': 'Search chats…', 'conv.new': 'New chat', - 'ctx.pin': 'Pin', 'ctx.unpin': 'Unpin', 'ctx.rename': 'Rename', 'ctx.del': 'Delete', - 'common.close': 'Close', 'common.more': 'More', 'common.optional': 'Optional', 'common.save': 'Save', - 'modal.preset': 'Presets', 'modal.addModel': 'Add model', 'modal.editModel': 'Edit model', 'modal.settings': 'Settings', - 'modal.customPreset': 'Custom preset', - 'modal.editCustomPreset': 'Edit task', - 'customPreset.titlePh': 'Title, e.g. "Weekly report"', - 'customPreset.promptPh': 'Prompt body — sent as the message when clicked', - 'customPreset.empty': 'Title and Prompt cannot be empty', - 'customPreset.removeTitle': 'Delete', - 'customPreset.editTitle': 'Edit', - 'builtinPreset.restoreBtn': 'Restore defaults', - 'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.importMemory': 'Import existing memory & sessions (select GenericAgent root)', 'set.gaSource': 'Connect to a separate GenericAgent source (use desktop as a shell)', 'set.gaSourceClear': 'Disconnect, use the bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager', - 'shortcut.askConfirm': 'Create a desktop shortcut for GenericAgent?', - 'appearance.light': 'Light', 'appearance.dark': 'Dark', - 'set.noModels': 'No models yet — add one below', - 'lang.zh': '简体中文', 'lang.en': 'English', - 'model.name': 'Note', 'model.namePh': 'Shown in the model list', - 'model.apikey': 'API Key', 'model.apikeyPh': 'sk-...', 'model.apikeyKeep': 'Leave blank to keep the current key', - 'model.apibase': 'API base URL', 'model.apibasePh': 'https://.../v1/messages', - 'model.protocol': 'Protocol', 'model.protocolPick': 'Select…', 'model.protocolOai': 'OpenAI-compatible (chat/completions)', 'model.protocolClaude': 'Anthropic (Claude /v1/messages)', - 'model.stream': 'Response', 'model.streamOn': 'Stream', 'model.streamOff': 'Non-stream', - 'model.model': 'Model', 'model.modelPh': 'model parameter name', - 'model.modelHint': 'Must match the model field in your provider docs exactly', - 'model.retries': 'Retries (×)', 'model.connTimeout': 'Connect (s)', 'model.readTimeout': 'Read (s)', - 'model.save': 'Save', 'common.cancel': 'Cancel', 'common.confirm': 'Confirm', 'common.edit': 'Edit', 'common.delete': 'Delete', - 'pq.title': 'Quick connect a model', 'pq.sub': 'Add your API key to get started', 'pq.toggle': 'Expand / collapse', - 'pq.deepseekDesc': 'Official API · OpenAI-compatible', 'pq.qwenDesc': 'Tongyi Qwen · Aliyun Bailian', - 'guide.step1': 'Open the link, sign in, then create & copy your API key', - 'guide.step2': 'Paste the key into the “API Key” field below', - 'guide.step3': 'Click Save — then pick it from the model list', - 'guide.prefillTip': 'API base, protocol and model are pre-filled — edit if needed', - 'guide.getKey': 'Get your {name} API key', 'guide.copy': 'Copy link', 'guide.copied': 'Link copied', - 'err.modelSave': 'Save failed', 'err.modelSwitch': 'Failed to switch model', 'err.modelRequired': 'Model, API Key and base URL are required', - 'err.modelDelete': 'Delete failed', 'err.modelDeleteLast': 'At least one model is required', - 'confirm.modelDelete': 'Delete this model profile?', - 'model.aggregation': 'Channel group (auto failover)', 'model.aggregationShort': 'Channel group', 'model.aggregationDesc': 'Tries in order, switches to the next on failure', - 'model.emptyMixin': 'No models added yet', - 'model.addToMixin': 'Add to channel', 'model.inMixin': 'In channel', 'model.removeFromMixin': 'Remove from channel', 'model.alreadyInMixin': 'Already in the channel', 'model.dragReorder': 'Drag to reorder', - 'err.mixinFailed': 'Operation failed', - 'page.services.title': 'Services', 'page.services.sub': 'IM channels and background processes — view, start/stop, logs', - 'page.channels.title': 'Channels', 'page.channels.sub': 'Background IM processes: list, start/stop, logs (hub.pyw style)', - 'page.status.title': 'Status', 'page.status.sub': 'Background processes/services managed by hub.pyw', - 'page.collab.title': 'Conductor', 'page.collab.sub': 'Describe a goal — split, delegate, and follow up', - 'collab.progressTitle': 'Progress', - 'collab.progressEmpty': 'No tasks running yet. After you describe a goal to Conductor, split tasks will appear here.', - 'collab.placeholder': 'Describe the goal you want to accomplish', - 'collab.guideTitle': 'Tell Conductor what you want done', - 'collab.guideWhen': 'Best for multi-step goals that take a while. For everyday chat and quick questions, use Chat in the sidebar.', - 'collab.guideStep1t': 'Describe your goal', - 'collab.guideStep1d': 'Write what you want done in the chat box and send it to Conductor', - 'collab.guideStep2t': 'Auto breakdown', - 'collab.guideStep2d': 'Conductor breaks down, assigns, monitors, and coordinates', - 'collab.guideStep3t': 'Summary', - 'collab.guideStep3d': 'Conductor delivers a briefing based on execution status', - 'collab.guideStep4t': 'Adjust anytime', - 'collab.guideStep4d': 'Add requirements or details anytime — Conductor handles them', - 'collab.chipProgress': 'How is it going?', - 'collab.chipPause': 'Pause current tasks', - 'collab.chipSummary': 'Summarize progress so far', - 'collab.showProgressTitle': 'View task progress', - 'collab.statRunning': 'Running', - 'collab.statDone': 'Done', - 'collab.plusMenu': 'More actions', - 'collab.switchMode': 'Switch mode', - 'collab.typing': 'Conductor is working', - 'collab.offline': 'Cannot reach the service. Make sure the backend is running.', - 'collab.retry': 'Retry', - 'collab.reconnect': 'Disconnected — reconnecting… Your last progress is kept.', - 'collab.reconnectIn': 'Retry in {n}s', - 'collab.stRunning': 'Running', 'collab.stReported': 'Reported', 'collab.stPaused': 'Paused', - 'collab.stFailed': 'Issue', 'collab.stTerminated': 'Ended', - 'collab.summaryRunning': 'Working…', 'collab.summaryWait': 'Awaiting report', - 'collab.taskFallback': 'Task {n}', - 'collab.timeJust': 'just now', - 'collab.timeSec': '{n}s ago', - 'collab.timeMin': '{n}m ago', - 'collab.timeHr': '{n}h ago', - 'collab.timeDay': '{n}d ago', - 'page.token.title': 'Usage', 'page.token.sub': 'Per-session and total usage & cache rate', - 'status.connecting': 'Connecting…', 'status.ready': 'Service online', 'status.running': 'Working…', - 'status.disconnected': 'Service offline', 'status.stopped': 'Stopped', 'status.idle': 'Standby', - 'conv.emptyList': 'No chats yet — click “+ New chat”', 'conv.defaultTitle': 'New chat', - 'err.bridge': 'Service not responding', 'err.newSession': 'Failed to create session', 'err.poll': 'Polling failed', 'err.stop': 'Stop failed', - 'err.interruptTimeout': 'Timed out waiting for the previous reply to stop — try again', - 'sys.interruptPrev.hint': 'Previous reply stopped — processing new message', - 'chat.interrupting': 'Stopping previous reply…', - 'chat.sessionLoading': 'Loading conversation…', - 'sys.stopRequested': 'Stop requested', - 'slash.help': 'Commands:\n/new new chat /clear clear /stop stop /settings settings', - 'slash.unknown': 'Unknown command', - 'upload.hint': 'Upload file: pick / drag / paste', - 'upload.button': 'Upload file', - 'upload.tooLarge': 'File too large or limit reached', 'upload.empty': 'Skipped empty file', - 'upload.failed': 'Upload failed', - 'err.charLimit': 'Character limit reached ({n}), text will be truncated on send', 'err.charLimitReached': 'Character limit reached ({n})', 'err.numMax': 'Cannot exceed {n}', - 'file.openFailed': 'Cannot open file', - 'file.kindGeneric': 'File', - 'file.kindDoc': 'Document', - 'file.kindSheet': 'Spreadsheet', - 'file.kindSlide': 'Slides', - 'file.kindCode': 'Code', - 'file.kindArchive': 'Archive', - 'file.kindAudio': 'Audio', - 'file.kindVideo': 'Video', - 'upload.removeTitle': 'Remove', - 'upload.dropHint': 'Drop to upload files', - 'lightbox.closeTitle': 'Close', - 'fold.thinking': 'Thinking', 'fold.tool': 'Tool call', 'fold.toolResult': 'Tool result', 'fold.llm': 'LLM Running', 'fold.turn': 'Turn {n}', - 'plan.header': 'Plan ({done}/{total})', 'plan.complete': '✓ Plan complete ({n}/{n})', - 'plan.running': 'Running plan', 'plan.completeTitle': 'Plan complete', - 'plan.placeholder': 'Plan mode activated', 'plan.waiting': 'waiting for {path} …', 'plan.overflow': '+{n} more', - 'plan.current': 'Now', 'plan.fold': 'Fold', 'plan.collapse': 'Collapse', 'plan.expand': 'Expand', 'plan.details': 'Details', 'plan.dismiss': 'Hide', - 'plan.capsuleRunning': 'Running', 'plan.capsuleComplete': 'Done', - 'timing.elapsed': 'Elapsed {t}', - 'model.auto': 'Auto', - 'model.menuLabel': 'Select model', - 'chip.plan': 'Plan', - 'chip.auto': 'Auto', - 'ch.wechat': 'WeChat', 'ch.wecom': 'WeCom', 'ch.lark': 'Lark', 'ch.dingtalk': 'DingTalk', - 'ch.qq': 'QQ', 'ch.telegram': 'Telegram', 'ch.discord': 'Discord', - 'ch.loading': 'Loading…', 'ch.empty': 'No IM process scripts found', - 'ch.logEmpty': 'No log output yet', - 'err.channelLoad': 'Failed to load', 'err.channelStart': 'Start failed', 'err.channelStop': 'Stop failed', - 'err.mykeyImport': 'Failed to import model config', - 'err.mykeyExport': 'Failed to export model config', - 'err.channelNotConfigured': 'Configure this platform in mykey.py first', - 'sys.channelStarted': 'Started', 'sys.channelStopped': 'Stopped', - 'modal.channelLogs': 'Process logs', - 'modal.mykeyConfig': 'mykey.py', - 'sys.configSaved': 'Configuration saved', - 'sys.mykeyImported': 'Model config imported', - 'sys.mykeyExported': 'Model config exported', - 'sys.memoryImported': 'Memory imported', - 'err.memoryImport': 'Failed to import memory', - 'sys.memoryImportBackup': 'Previous memory backed up to', - 'sys.memorySessions': 'sessions', - 'sys.gaSourcePickTitle': 'Select the GenericAgent source root to connect to (contains agentmain.py)', - 'sys.gaSourceSwitching': 'Switching and restarting the backend...', - 'sys.gaSourceSet': 'Connected to the external GenericAgent', - 'sys.gaSourceCleared': 'Disconnected, restored the bundled version', - 'err.gaSourceSet': 'Failed to connect', - 'err.gaSourceDesktopOnly': 'This feature is only available in the desktop app', - 'sys.memoryPickTitle': 'Select the GenericAgent root directory (the folder containing memory and temp)', - 'sys.memoryImportPrompt': 'Enter the full path of the GenericAgent root directory (the folder containing memory and temp, not the memory folder itself):', - 'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error', 'st.missingDeps': 'Needs Setup', - 'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit', 'act.installDeps': 'Install', 'act.installing': 'Installing…', - 'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send', - 'sys.depsInstalled': 'Dependencies installed', 'err.installDeps': 'Install failed', - 'proc.imbotWechat': 'imbot · WeChat', 'proc.imbotDing': 'imbot · DingTalk', 'proc.scheduler': 'Scheduler', 'proc.conductor': 'Conductor', - 'cm.scheduling': 'Scheduling', 'cm.running': 'Running', 'cm.idleSt': 'Idle', - 'cm.master': 'Dispatched 3 subtasks', 'cm.w1': 'Subtask: fetch data', 'cm.w2': 'Subtask: review results', 'cm.sub': 'Waiting for tasks', - 'tok.total': 'Total', 'tok.cost': 'Cache rate', 'tok.today': 'Today', 'tok.tabAll': 'Chat', 'tok.tabConductor': 'Conductor', 'tok.condTotal': 'Conductor Total', 'tok.condCurrent': 'Conductor Current', 'tok.condTip': 'Conductor usage is not included in chat totals', 'tok.condOffline': 'Service offline', 'tok.disclaimer': 'Pricing may vary by API provider. Please refer to the actual website.', - 'tok.colSession': 'Session', 'tok.colIn': 'Input', 'tok.colOut': 'Output', 'tok.colCacheW': 'Cache write', 'tok.colCache': 'Cache read', 'tok.colCost': 'Cost', - 'tok.from': 'From', 'tok.to': 'To', 'tok.reset': 'Reset', 'tok.noData': 'No records', 'tok.deleted': 'Session deleted', - 'tok.pricingUnknown': '⚠ Pricing not confirmed, using defaults', - 'tok.priceInput': 'Input: $', 'tok.priceOutput': 'Output: $', - 'tok.priceCacheW': 'Cache write: $', 'tok.priceCacheR': 'Cache read: $', - 'presetPrompt.goal': 'Enter Goal mode: read the L3 goal-mode SOP and autonomously achieve the goal I describe next.', - 'presetPrompt.plan': 'Enter Plan mode: first read memory/plan_sop.md, follow its explore→plan→execute→verify flow, and wait for the task I describe next.', - 'presetPrompt.autonomous': '🤖 Enter autonomous mode: read memory/autonomous_operation_sop.md, follow the SOP to pick or plan a task, execute independently, and produce a report.', - 'presetPrompt.hive': 'Start Goal Hive mode: per the hive SOP, spawn multiple workers to collaboratively achieve the goal I describe next.', - 'presetPrompt.review': 'Enter reviewer mode: strictly scrutinize the previous output, review item by item and report issues.', - 'presetPrompt.findwork': 'Following the autonomous planning section, analyze my situation thoroughly and generate a batch of TODOs that genuinely interest me.', - 'presetPrompt.mine': 'Collect this week\'s git commits and write a weekly report.', - 'ask.banner': 'GA is waiting for your answer', - 'ask.replyHint': 'Reply in the input below', - 'ask.placeholderOpen': 'Type your answer here… (Enter to send)', - }, -}; - - const BOOT_I18N = { - zh: { - 'loading.starting_app': '正在启动 GenericAgent...', - 'loading.start': '首次运行:正在准备运行环境...', - 'loading.venv': '正在创建运行环境...', - 'loading.deps': '正在安装依赖...', - 'loading.done': '依赖安装完成', - 'loading.starting': '正在启动服务...', - 'setup.title': 'GenericAgent - 设置', - 'setup.heading': '需要设置', - 'setup.backend_failed': '后端未能启动,请配置下方路径。', - 'setup.prepare_error': '安装失败,错误详情:', - 'setup.py_label': 'Python 解释器路径', - 'setup.py_placeholder': 'python / 解释器路径', - 'setup.py_hint': '例如 C:/Python312/python.exe 或 ~/miniconda3/envs/myenv/bin/python', - 'setup.project_label': '项目目录', - 'setup.project_placeholder': 'GenericAgent 文件夹路径', - 'setup.project_hint': '包含 frontends/desktop_bridge.py 的文件夹', - 'setup.start': '启动', - 'setup.err_py': '请输入 Python 路径', - 'setup.err_project': '请输入项目目录', - 'setup.starting_bridge': '正在启动 bridge...', - 'setup.connected': '已连接,正在打开主窗口...', - 'setup.failed': '启动失败:{error}', - 'setup.limit': '已达字符数上限({n})' - }, - en: { - 'loading.starting_app': 'Starting GenericAgent...', - 'loading.start': 'First run: preparing the runtime...', - 'loading.venv': 'Creating the runtime environment...', - 'loading.deps': 'Installing dependencies...', - 'loading.done': 'Dependencies installed', - 'loading.starting': 'Starting services...', - 'setup.title': 'GenericAgent - Setup', - 'setup.heading': 'Setup Required', - 'setup.backend_failed': 'The backend could not start. Please configure the paths below.', - 'setup.prepare_error': 'Setup failed. Details:', - 'setup.py_label': 'Python interpreter path', - 'setup.py_placeholder': 'python / path to interpreter', - 'setup.py_hint': 'e.g. C:/Python312/python.exe or ~/miniconda3/envs/myenv/bin/python', - 'setup.project_label': 'Project directory', - 'setup.project_placeholder': 'path to GenericAgent folder', - 'setup.project_hint': 'The folder containing frontends/desktop_bridge.py', - 'setup.start': 'Start', - 'setup.err_py': 'Please enter Python path', - 'setup.err_project': 'Please enter project directory', - 'setup.starting_bridge': 'Starting bridge...', - 'setup.connected': 'Connected. Opening the main window...', - 'setup.failed': 'Failed: {error}', - 'setup.limit': 'Character limit reached ({n})' - } -}; - - for (const [code, messages] of Object.entries(BOOT_I18N)) { - I18N[code] = Object.assign(I18N[code] || {}, messages); - } - - const LANGS = Object.freeze(Object.keys(I18N)); - - function normalizeLang(code) { - return LANGS.includes(code) ? code : 'zh'; - } - - function browserLang() { - return (navigator.language || '').toLowerCase().startsWith('zh') ? 'zh' : 'en'; - } - - function bootLang() { - try { - const saved = localStorage.getItem('ga_lang'); - if (LANGS.includes(saved)) return saved; - } catch (_) {} - return browserLang(); - } - - function tFor(lang, key, vars) { - const active = normalizeLang(lang); - let text = (I18N[active] && I18N[active][key]) || (I18N.zh && I18N.zh[key]) || (I18N.en && I18N.en[key]) || key; - if (vars) { - for (const [name, value] of Object.entries(vars)) { - text = text.replaceAll(`{${name}}`, String(value)); - } - } - return text; - } - - function apply(root, activeLang) { - const lang = normalizeLang(activeLang || bootLang()); - const scope = root || document; - if (scope === document) document.documentElement.lang = lang === 'en' ? 'en' : 'zh-CN'; - scope.querySelectorAll('[data-i18n]').forEach(el => { - el.textContent = tFor(lang, el.dataset.i18n); - }); - scope.querySelectorAll('[data-i18n-ph]').forEach(el => { - el.setAttribute('data-ph', tFor(lang, el.dataset.i18nPh)); - }); - scope.querySelectorAll('[data-i18n-placeholder]').forEach(el => { - el.setAttribute('placeholder', tFor(lang, el.dataset.i18nPlaceholder)); - }); - scope.querySelectorAll('[data-i18n-title]').forEach(el => { - el.setAttribute('title', tFor(lang, el.dataset.i18nTitle)); - el.setAttribute('aria-label', tFor(lang, el.dataset.i18nTitle)); - }); - } - - window.GA_I18N = { - dict: I18N, - languages: LANGS, - t: tFor, - apply, - bootLang, - browserLang, - }; - - window.GA_BOOT_I18N = { - get lang() { return bootLang(); }, - t(key, vars) { return tFor(bootLang(), key, vars); }, - apply(root) { return apply(root || document, bootLang()); }, - }; -})(); diff --git a/frontends/desktop/public/phosphor-icons.js b/frontends/desktop/public/phosphor-icons.js deleted file mode 100644 index 101df095b..000000000 --- a/frontends/desktop/public/phosphor-icons.js +++ /dev/null @@ -1,101 +0,0 @@ -(() => { - const PATHS = { - chatTeardropText: - 'M172,112a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h68A8,8,0,0,1,172,112Zm-8,24H96a8,8,0,0,0,0,16h68a8,8,0,0,0,0-16Zm68-12A100.11,100.11,0,0,1,132,224H48a16,16,0,0,1-16-16V124a100,100,0,0,1,200,0Zm-16,0a84,84,0,0,0-168,0v84h84A84.09,84.09,0,0,0,216,124Z', - broadcast: - 'M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z', - chartBar: - 'M224,200h-8V40a8,8,0,0,0-8-8H152a8,8,0,0,0-8,8V80H96a8,8,0,0,0-8,8v40H48a8,8,0,0,0-8,8v64H32a8,8,0,0,0,0,16H224a8,8,0,0,0,0-16ZM160,48h40V200H160ZM104,96h40V200H104ZM56,144H88v56H56Z', - usersThree: - 'M244.8,150.4a8,8,0,0,1-11.2-1.6A51.6,51.6,0,0,0,192,128a8,8,0,0,1-7.37-4.89,8,8,0,0,1,0-6.22A8,8,0,0,1,192,112a24,24,0,1,0-23.24-30,8,8,0,1,1-15.5-4A40,40,0,1,1,219,117.51a67.94,67.94,0,0,1,27.43,21.68A8,8,0,0,1,244.8,150.4ZM190.92,212a8,8,0,1,1-13.84,8,57,57,0,0,0-98.16,0,8,8,0,1,1-13.84-8,72.06,72.06,0,0,1,33.74-29.92,48,48,0,1,1,58.36,0A72.06,72.06,0,0,1,190.92,212ZM128,176a32,32,0,1,0-32-32A32,32,0,0,0,128,176ZM72,120a8,8,0,0,0-8-8A24,24,0,1,1,87.24,82a8,8,0,1,0,15.5-4A40,40,0,1,0,37,117.51,67.94,67.94,0,0,0,9.6,139.19a8,8,0,1,0,12.8,9.61A51.6,51.6,0,0,1,64,128,8,8,0,0,0,72,120Z', - coins: - 'M184,89.57V84c0-25.08-37.83-44-88-44S8,58.92,8,84v40c0,20.89,26.25,37.49,64,42.46V172c0,25.08,37.83,44,88,44s88-18.92,88-44V132C248,111.3,222.58,94.68,184,89.57ZM232,132c0,13.22-30.79,28-72,28-3.73,0-7.43-.13-11.08-.37C170.49,151.77,184,139,184,124V105.74C213.87,110.19,232,122.27,232,132ZM72,150.25V126.46A183.74,183.74,0,0,0,96,128a183.74,183.74,0,0,0,24-1.54v23.79A163,163,0,0,1,96,152,163,163,0,0,1,72,150.25Zm96-40.32V124c0,8.39-12.41,17.4-32,22.87V123.5C148.91,120.37,159.84,115.71,168,109.93ZM96,56c41.21,0,72,14.78,72,28s-30.79,28-72,28S24,97.22,24,84,54.79,56,96,56ZM24,124V109.93c8.16,5.78,19.09,10.44,32,13.57v23.37C36.41,141.4,24,132.39,24,124Zm64,48v-4.17c2.63.1,5.29.17,8,.17,3.88,0,7.67-.13,11.39-.35A121.92,121.92,0,0,0,120,171.41v23.46C100.41,189.4,88,180.39,88,172Zm48,26.25V174.4a179.48,179.48,0,0,0,24,1.6,183.74,183.74,0,0,0,24-1.54v23.79a165.45,165.45,0,0,1-48,0Zm64-3.38V171.5c12.91-3.13,23.84-7.79,32-13.57V172C232,180.39,219.59,189.4,200,194.87Z', - sidebarSimple: - 'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM40,56H80V200H40ZM216,200H96V56H216V200Z', - pencilSimple: - 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z', - magnifyingGlass: - 'M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z', - books: - 'M231.65,194.55,198.46,36.75a16,16,0,0,0-19-12.39L132.65,34.42a16.08,16.08,0,0,0-12.3,19l33.19,157.8A16,16,0,0,0,169.16,224a16.25,16.25,0,0,0,3.38-.36l46.81-10.06A16.09,16.09,0,0,0,231.65,194.55ZM136,50.15c0-.06,0-.09,0-.09l46.8-10,3.33,15.87L139.33,66Zm6.62,31.47,46.82-10.05,3.34,15.9L146,97.53Zm6.64,31.57,46.82-10.06,13.3,63.24-46.82,10.06ZM216,197.94l-46.8,10-3.33-15.87L212.67,182,216,197.85C216,197.91,216,197.94,216,197.94ZM104,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16h48a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32ZM56,48h48V64H56Zm0,32h48v96H56Zm48,128H56V192h48v16Z', - gridFour: - 'M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z', - robot: - 'M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z', - dotsThree: - 'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,196,116ZM60,116a12,12,0,1,0,12,12A12,12,0,0,0,60,116Z', - folderSimplePlus: - 'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Zm-56-56a8,8,0,0,1-8,8H136v16a8,8,0,0,1-16,0V152H104a8,8,0,0,1,0-16h16V120a8,8,0,0,1,16,0v16h16A8,8,0,0,1,160,144Z', - folderSimple: - 'M216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33L123.2,86.4A8,8,0,0,0,128,88h88Z', - bracketsCurly: - 'M43.18,128a29.78,29.78,0,0,1,8,10.26c4.8,9.9,4.8,22,4.8,33.74,0,24.31,1,36,24,36a8,8,0,0,1,0,16c-17.48,0-29.32-6.14-35.2-18.26-4.8-9.9-4.8-22-4.8-33.74,0-24.31-1-36-24-36a8,8,0,0,1,0-16c23,0,24-11.69,24-36,0-11.72,0-23.84,4.8-33.74C50.68,38.14,62.52,32,80,32a8,8,0,0,1,0,16C57,48,56,59.69,56,84c0,11.72,0,23.84-4.8,33.74A29.78,29.78,0,0,1,43.18,128ZM240,120c-23,0-24-11.69-24-36,0-11.72,0-23.84-4.8-33.74C205.32,38.14,193.48,32,176,32a8,8,0,0,0,0,16c23,0,24,11.69,24,36,0,11.72,0,23.84,4.8,33.74a29.78,29.78,0,0,0,8,10.26,29.78,29.78,0,0,0-8,10.26c-4.8,9.9-4.8,22-4.8,33.74,0,24.31-1,36-24,36a8,8,0,0,0,0,16c17.48,0,29.32-6.14,35.2-18.26,4.8-9.9,4.8-22,4.8-33.74,0-24.31,1-36,24-36a8,8,0,0,0,0-16Z', - gear: - 'M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z', - caretLeft: - 'M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z', - caretDown: - 'M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z', - caretRight: - 'M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z', - paperPlaneTilt: - 'M227.32,28.68a16,16,0,0,0-15.66-4.08l-.15,0L19.57,82.84a16,16,0,0,0-2.49,29.8L102,154l41.3,84.87A15.86,15.86,0,0,0,157.74,248q.69,0,1.38-.06a15.88,15.88,0,0,0,14-11.51l58.2-191.94c0-.05,0-.1,0-.15A16,16,0,0,0,227.32,28.68ZM157.83,231.85l-.05.14,0-.07-40.06-82.3,48-48a8,8,0,0,0-11.31-11.31l-48,48L24.08,98.25l-.07,0,.14,0L216,40Z', - paperclip: - 'M209.66,122.34a8,8,0,0,1,0,11.32l-82.05,82a56,56,0,0,1-79.2-79.21L147.67,35.73a40,40,0,1,1,56.61,56.55L105,193A24,24,0,1,1,71,159L154.3,74.38A8,8,0,1,1,165.7,85.6L82.39,170.31a8,8,0,1,0,11.27,11.36L192.93,81A24,24,0,1,0,159,47L59.76,147.68a40,40,0,1,0,56.53,56.62l82.06-82A8,8,0,0,1,209.66,122.34Z', - lightning: - 'M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z', - pushPinSimple: - 'M216,168h-9.29L185.54,48H192a8,8,0,0,0,0-16H64a8,8,0,0,0,0,16h6.46L49.29,168H40a8,8,0,0,0,0,16h80v56a8,8,0,0,0,16,0V184h80a8,8,0,0,0,0-16ZM86.71,48h82.58l21.17,120H65.54Z', - trash: - 'M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z', - x: - 'M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z', - dotsThreeVertical: - 'M140,128a12,12,0,1,1-12-12A12,12,0,0,1,140,128Zm0-56a12,12,0,1,1-12-12A12,12,0,0,1,140,72Zm0,112a12,12,0,1,1-12-12A12,12,0,0,1,140,184Z', - plus: - 'M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z', - copy: - 'M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z', - check: - 'M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z', - crosshair: - 'M232,120h-8.34A96.14,96.14,0,0,0,136,32.34V24a8,8,0,0,0-16,0v8.34A96.14,96.14,0,0,0,32.34,120H24a8,8,0,0,0,0,16h8.34A96.14,96.14,0,0,0,120,223.66V232a8,8,0,0,0,16,0v-8.34A96.14,96.14,0,0,0,223.66,136H232a8,8,0,0,0,0-16Zm-96,87.6V200a8,8,0,0,0-16,0v7.6A80.15,80.15,0,0,1,48.4,136H56a8,8,0,0,0,0-16H48.4A80.15,80.15,0,0,1,120,48.4V56a8,8,0,0,0,16,0V48.4A80.15,80.15,0,0,1,207.6,120H200a8,8,0,0,0,0,16h7.6A80.15,80.15,0,0,1,136,207.6ZM128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Z', - compass: - 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216ZM172.42,72.84l-64,32a8.05,8.05,0,0,0-3.58,3.58l-32,64A8,8,0,0,0,80,184a8.1,8.1,0,0,0,3.58-.84l64-32a8.05,8.05,0,0,0,3.58-3.58l32-64a8,8,0,0,0-10.74-10.74ZM138,138,97.89,158.11,118,118l40.15-20.07Z', - listChecks: - 'M224,128a8,8,0,0,1-8,8H128a8,8,0,0,1,0-16h88A8,8,0,0,1,224,128ZM128,72h88a8,8,0,0,0,0-16H128a8,8,0,0,0,0,16Zm88,112H128a8,8,0,0,0,0,16h88a8,8,0,0,0,0-16ZM82.34,42.34,56,68.69,45.66,58.34A8,8,0,0,0,34.34,69.66l16,16a8,8,0,0,0,11.32,0l32-32A8,8,0,0,0,82.34,42.34Zm0,64L56,132.69,45.66,122.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Zm0,64L56,196.69,45.66,186.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32,0l32-32a8,8,0,0,0-11.32-11.32Z', - star: - 'M239.18,97.26A16.38,16.38,0,0,0,224.92,86l-59-4.76L143.14,26.15a16.36,16.36,0,0,0-30.27,0L90.11,81.23,31.08,86a16.46,16.46,0,0,0-9.37,28.86l45,38.83L53,211.75a16.38,16.38,0,0,0,24.5,17.82L128,198.49l50.53,31.08A16.4,16.4,0,0,0,203,211.75l-13.76-58.07,45-38.83A16.43,16.43,0,0,0,239.18,97.26Zm-15.34,5.47-48.7,42a8,8,0,0,0-2.56,7.91l14.88,62.8a.37.37,0,0,1-.17.48c-.18.14-.23.11-.38,0l-54.72-33.65a8,8,0,0,0-8.38,0L69.09,215.94c-.15.09-.19.12-.38,0a.37.37,0,0,1-.17-.48l14.88-62.8a8,8,0,0,0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16A8,8,0,0,0,103,91.86l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153,91.86a8,8,0,0,0,6.75,4.92l63.92,5.16c.15,0,.24,0,.33.29S224,102.63,223.84,102.73Z', - fileText: - 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-32-80a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,136Zm0,32a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,168Z', - gitFork: - 'M224,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H80a8,8,0,0,1-8-8V95a32,32,0,1,0-16,0v17a24,24,0,0,0,24,24h40v25a32,32,0,1,0,16,0V136h40a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,224,64ZM48,64A16,16,0,1,1,64,80,16,16,0,0,1,48,64Zm96,128a16,16,0,1,1-16-16A16,16,0,0,1,144,192ZM192,80a16,16,0,1,1,16-16A16,16,0,0,1,192,80Z', - hexagon: - 'M223.68,66.15,135.68,18h0a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM216,175.82,128,224,40,175.82V80.18L128,32h0l88,48.17Z', - }; - - function gaIcon(name, className = '') { - const d = PATHS[name]; - if (!d) return ''; - const cls = className ? ` class="${className}"` : ''; - return `