diff --git a/.github/workflows/desktop-release-package.yml b/.github/workflows/desktop-release-package.yml new file mode 100644 index 000000000..f8f751b6d --- /dev/null +++ b/.github/workflows/desktop-release-package.yml @@ -0,0 +1,716 @@ +name: Build Desktop Portable Packages + +# Triggers: +# - workflow_dispatch -> build CI artifacts for testing (choose a target platform) +# - push tag desktop-portable-* -> build all three platforms and publish them +# together into ONE GitHub Release (tag = the pushed tag) +# Plain pushes to branches do NOT build (avoids 3 heavy Rust builds per commit). +"on": + push: + tags: + - "desktop-portable-*" + workflow_dispatch: + inputs: + target: + description: "Which platform(s) to build (artifacts only; release happens on tag push)" + required: false + default: "all" + type: choice + options: + - "all" + - "windows" + - "linux" + - "macos" + +permissions: + contents: write + +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 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 20 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: frontends/desktop/src-tauri -> target + + - name: Install desktop dependencies + working-directory: frontends/desktop + run: npm install + + - name: Build Windows desktop exe + working-directory: frontends/desktop + run: npm run tauri build -- --bundles nsis + + - name: Assemble self-contained portable bundle + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + EXE_SRC="$(find frontends/desktop/src-tauri/target/release -maxdepth 1 -type f -name 'ga-desktop.exe' | head -n 1)" + [[ -n "$EXE_SRC" ]] || { echo "ga-desktop.exe not found" >&2; exit 1; } + + PKG="artifacts/windows/GenericAgent-Desktop-Windows-Portable" + RUNTIME="$PKG/runtime" + mkdir -p "$RUNTIME" + + # exe at the top level, named GenericAgent (typical portable form) + cp "$EXE_SRC" "$PKG/GenericAgent.exe" + cp frontends/desktop/packaging/scripts/windows/install_windows.ps1 "$RUNTIME/install_windows.ps1" + # Uninstaller: double-click entry at top level, worker script under runtime/. + 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; } + 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) + PY="$RUNTIME/python/python.exe" + "$PY" --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 + # may not -> python.exe fails to load (missing api-ms-win-crt-*.dll). Copy the + # redistributable UCRT DLLs next to python.exe (Microsoft-supported app-local + # deployment). Pure SDK source, no System32 fallback, hard-fail if absent so we + # never ship a bundle from an unexpected source. + UCRT_SRC="$(ls -d "/c/Program Files (x86)/Windows Kits/10/Redist/"*/ucrt/DLLs/x64 2>/dev/null | sort -V | tail -1)" + [[ -n "$UCRT_SRC" ]] || { echo "Windows SDK UCRT redist dir not found on runner" >&2; exit 1; } + echo "UCRT source: $UCRT_SRC" + cp "$UCRT_SRC"/api-ms-win-crt-*.dll "$RUNTIME/python/" + cp "$UCRT_SRC"/ucrtbase.dll "$RUNTIME/python/" + 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) + 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 + + # Runtime source (project root minus heavy/dev/build dirs) + mkdir -p "$RUNTIME/app" + tar \ + --exclude='./.git' --exclude='./.github' \ + --exclude='./frontends/desktop/src-tauri' \ + --exclude='./frontends/desktop/node_modules' \ + --exclude='./frontends/desktop/packaging' --exclude='./docs' \ + --exclude='./assets/demo' --exclude='./assets/images' \ + --exclude='./assets/GenericAgent_Technical_Report.pdf' \ + --exclude='./artifacts' \ + --exclude='*/node_modules' --exclude='*/target' \ + --exclude='*/.venv' --exclude='./.venv' \ + --exclude='*/__pycache__' --exclude='*.pyc' \ + -cf - . | tar -xf - -C "$RUNTIME/app" + test -f "$RUNTIME/app/agentmain.py" + test -f "$RUNTIME/app/frontends/desktop_bridge.py" + test -f "$RUNTIME/app/frontends/desktop/static/index.html" + + # Drop python debug symbols (.pdb) to slim the package (~80MB) + find "$RUNTIME/python" -name '*.pdb' -delete 2>/dev/null || true + + cat > "$PKG/readme.txt" <<'EOF' + ================ 中文 ================ + GenericAgent Desktop — Windows 便携版(自包含) + + 无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。 + + 前置条件 + - Windows 10/11 x64 + - Microsoft Edge WebView2 运行时(Win11 一般自带;缺失时首次运行会提示安装) + + 使用 + 1. 解压到任意目录(路径建议不含特殊字符)。 + 2. 双击 GenericAgent.exe。 + 3. 首次启动会自动离线准备运行环境(建虚拟环境、装依赖),界面显示进度,完成后进入主界面。 + 4. 之后启动直接秒进。 + + 说明 + - 想真正对话,仍需在程序里配置模型 / API Key。 + - 首次准备完成后不要移动本文件夹;若已移动,删除 runtime\app\.venv 后重新启动即可重建。 + - runtime\ 是内置运行环境与源码,正常使用无需改动。 + + ================ English ================ + GenericAgent Desktop — Windows Portable (self-contained) + + No Python install, no internet for dependencies, no source checkout — everything is bundled. + + Requirements + - Windows 10/11 x64 + - Microsoft Edge WebView2 runtime (usually preinstalled on Win11; you are prompted if missing) + + Usage + 1. Extract anywhere (a path without special characters is recommended). + 2. Double-click GenericAgent.exe. + 3. The first launch prepares the runtime offline (creates a venv, installs deps) with a + progress UI, then opens the main window. + 4. Subsequent launches start instantly. + + Notes + - To actually chat, configure a model / API key inside the app. + - Do not move this folder after the first setup; if you did, delete runtime\app\.venv and relaunch to rebuild. + - runtime\ holds the bundled runtime and source; no need to touch it. + EOF + sed -i 's/^ //' "$PKG/readme.txt" + + echo "Package tree (top levels):" + find "$PKG" -maxdepth 2 -printf '%y %p\n' | sort | head -40 + + - name: Zip portable package + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path artifacts/windows/out | Out-Null + Compress-Archive -Path artifacts/windows/GenericAgent-Desktop-Windows-Portable ` + -DestinationPath artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip -Force + $h = Get-FileHash artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip -Algorithm SHA256 + "{0} GenericAgent-Desktop-Windows-Portable.zip" -f $h.Hash.ToLowerInvariant() | + Set-Content -Encoding ASCII artifacts/windows/out/SHA256SUMS-windows.txt + Get-ChildItem artifacts/windows/out | Format-Table Name, Length + + - name: Upload workflow artifact + uses: actions/upload-artifact@v7 + with: + name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }} + path: | + artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip + 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”中启动。若出现“无法验证开发者”等提示,先双击 DMG 内的 `open_anyway.command`,再启动。卸载时先退出应用,再从 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, double-click `open_anyway.command` inside the DMG first, then launch again. 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/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' }} + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 20 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: frontends/desktop/src-tauri -> target + + - name: Install Tauri Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install desktop dependencies + working-directory: frontends/desktop + run: npm install + + - name: Build Linux AppImage + working-directory: frontends/desktop + run: npm run tauri build -- --bundles appimage + + - 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)" + [[ -n "$APPIMAGE_SRC" ]] || { echo "No AppImage found" >&2; exit 1; } + + PKG="artifacts/linux/GenericAgent-Desktop-Linux-Portable" + RUNTIME="$PKG/runtime" + mkdir -p "$RUNTIME" + + # Stable internal name so the generated .desktop Exec= never changes on upgrades. + cp "$APPIMAGE_SRC" "$PKG/GenericAgent.AppImage" + chmod +x "$PKG/GenericAgent.AppImage" + # Icon for the generated .desktop launcher (Icon= needs a real image file on Linux). + cp frontends/desktop/src-tauri/icons/icon.png "$PKG/GenericAgent.png" + cp frontends/desktop/packaging/scripts/linux/install_linux.sh "$RUNTIME/install_linux.sh" + chmod +x "$RUNTIME/install_linux.sh" + # Uninstaller: top-level entry next to the AppImage. + 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; } + 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 + PY="$RUNTIME/python/bin/python3" + "$PY" --version + + # Offline wheels (native linux wheels for the embedded python) + 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 + + # Runtime source (project root minus heavy/dev/build dirs) + mkdir -p "$RUNTIME/app" + tar \ + --exclude='./.git' --exclude='./.github' \ + --exclude='./frontends/desktop/src-tauri' \ + --exclude='./frontends/desktop/node_modules' \ + --exclude='./frontends/desktop/packaging' --exclude='./docs' \ + --exclude='./assets/demo' --exclude='./assets/images' \ + --exclude='./assets/GenericAgent_Technical_Report.pdf' \ + --exclude='./artifacts' \ + --exclude='*/node_modules' --exclude='*/target' \ + --exclude='*/.venv' --exclude='./.venv' \ + --exclude='*/__pycache__' --exclude='*.pyc' \ + -cf - . | tar -xf - -C "$RUNTIME/app" + test -f "$RUNTIME/app/agentmain.py" + test -f "$RUNTIME/app/frontends/desktop_bridge.py" + test -f "$RUNTIME/app/frontends/desktop/static/index.html" + + # Drop python debug/cache bits to slim the package + find "$RUNTIME/python" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true + + cat > "$PKG/readme.txt" <<'EOF' + ================ 中文 ================ + GenericAgent Desktop — Linux 便携版(自包含) + + 无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。 + + 前置条件 + - Linux x86_64(glibc,常见发行版即可) + - 桌面环境 + FUSE(运行 AppImage 所需;多数发行版自带) + + 使用 + 1. 解压到任意目录(路径建议不含特殊字符)。 + 2. 给 AppImage 执行权限并运行: + chmod +x GenericAgent.AppImage + ./GenericAgent.AppImage + 3. 首次启动会自动离线准备运行环境(建虚拟环境、装依赖),界面显示进度,完成后进入主界面。 + 4. 之后启动直接秒进。 + + 说明 + - 想真正对话,仍需在程序里配置模型 / API Key。 + - 首次准备完成后不要移动本文件夹;若已移动,删除 runtime/app/.venv 后重新启动即可重建。 + - runtime/ 是内置运行环境与源码,正常使用无需改动。 + + ================ English ================ + GenericAgent Desktop — Linux Portable (self-contained) + + No Python install, no internet for dependencies, no source checkout — everything is bundled. + + Requirements + - Linux x86_64 (glibc; common distributions work) + - A desktop environment + FUSE (required to run the AppImage; preinstalled on most distros) + + Usage + 1. Extract anywhere (a path without special characters is recommended). + 2. Make the AppImage executable and run it: + chmod +x GenericAgent.AppImage + ./GenericAgent.AppImage + 3. The first launch prepares the runtime offline (creates a venv, installs deps) with a + progress UI, then opens the main window. + 4. Subsequent launches start instantly. + + Notes + - To actually chat, configure a model / API key inside the app. + - Do not move this folder after the first setup; if you did, delete runtime/app/.venv and relaunch to rebuild. + - runtime/ holds the bundled runtime and source; no need to touch it. + EOF + sed -i 's/^ //' "$PKG/readme.txt" + + mkdir -p artifacts/linux/out + tar -C artifacts/linux -czf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz GenericAgent-Desktop-Linux-Portable + ( cd artifacts/linux/out && sha256sum GenericAgent-Desktop-Linux-Portable.tar.gz > SHA256SUMS-linux.txt ) + + tar -tzf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz > /tmp/pkglist.txt + echo "Package contents (first 40 of $(wc -l < /tmp/pkglist.txt) entries):" + head -40 /tmp/pkglist.txt + ls -lh artifacts/linux/out + + - name: Upload workflow artifact + uses: actions/upload-artifact@v7 + with: + name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }} + path: | + artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz + 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”中启动。若出现“无法验证开发者”等提示,先双击 DMG 内的 `open_anyway.command`,再启动。卸载时先退出应用,再从 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, double-click `open_anyway.command` inside the DMG first, then launch again. 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/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 + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 20 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build + uses: Swatinem/rust-cache@v2 + with: + workspaces: frontends/desktop/src-tauri -> target + + - name: Install desktop dependencies + working-directory: frontends/desktop + run: npm install + + - name: Build macOS .app + working-directory: frontends/desktop + run: npm run tauri build -- --bundles app + + - name: Assemble self-contained portable bundle and 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. + 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 + )" + 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 + PY="$RUNTIME_SRC/python/bin/python3" + "$PY" --version + + # Offline wheels + mkdir -p "$RUNTIME_SRC/wheels" + "$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 + + # Runtime source (project root minus heavy/dev/build dirs) — bsdtar exclude syntax. + mkdir -p "$RUNTIME_SRC/app" + tar \ + --exclude='.git' --exclude='.github' \ + --exclude='frontends/desktop/src-tauri' \ + --exclude='frontends/desktop/node_modules' \ + --exclude='frontends/desktop/packaging' --exclude='docs' \ + --exclude='assets/demo' --exclude='assets/images' \ + --exclude='assets/GenericAgent_Technical_Report.pdf' \ + --exclude='artifacts' \ + --exclude='*/node_modules' --exclude='*/target' \ + --exclude='*/.venv' --exclude='.venv' \ + --exclude='*/__pycache__' --exclude='*.pyc' \ + -cf - . | tar -xf - -C "$RUNTIME_SRC/app" + 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" + + 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" + 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 + + # Standard DMG: GenericAgent.app + Applications alias + first-launch helper/guide. + ditto "$APP_SRC" "$DMG_APP" + mkdir -p "$DMG_APP/Contents/Resources" + ditto "$RUNTIME_SRC" "$DMG_APP/Contents/Resources/runtime" + codesign --force --deep --sign - "$DMG_APP" + codesign --verify --deep --strict "$DMG_APP" || true + ln -s /Applications "$DMG_STAGE/Applications" + + cat > "$DMG_STAGE/open_anyway.command" <<'EOF' + #!/bin/bash + set -e + DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + TARGET="/Applications/GenericAgent.app" + if [[ ! -d "$TARGET" ]]; then + TARGET="$DIR/GenericAgent.app" + fi + xattr -cr "$TARGET" 2>/dev/null || true + osascript -e 'display dialog "GenericAgent 已处理完成。请将 GenericAgent.app 拖入 Applications 后,从 Applications 中启动;如果已经安装,可以直接重新打开。" buttons {"OK"} default button "OK"' >/dev/null 2>&1 || true + EOF + chmod +x "$DMG_STAGE/open_anyway.command" + + cat > "$DMG_STAGE/readme.txt" <<'EOF' + ================ 中文 ================ + GenericAgent Desktop — macOS DMG 安装版 + + 安装 + 1. 双击打开 GenericAgent-Desktop-macOS.dmg。 + 2. 将 GenericAgent.app 拖入 Applications。 + 3. 打开“应用程序 / Applications”,双击 GenericAgent.app 启动。 + + 若出现“无法验证开发者”“Apple 无法检查其是否包含恶意软件”或无法打开: + 1. 回到 DMG 窗口。 + 2. 双击“open_anyway.command”。 + 3. 再从 Applications 中打开 GenericAgent.app。 + 也可以右键 GenericAgent.app,选择“打开”。 + + 卸载 + 先退出 GenericAgent,再从 Applications 删除 GenericAgent.app。 + + ================ English ================ + GenericAgent Desktop — macOS DMG installer + + Installation + 1. Open GenericAgent-Desktop-macOS.dmg. + 2. Drag GenericAgent.app to Applications. + 3. Open Applications and launch GenericAgent.app. + + If macOS says the developer cannot be verified, Apple cannot check it for malicious software, or the app cannot be opened: + 1. Go back to the DMG window. + 2. Double-click "open_anyway.command". + 3. Launch GenericAgent.app from Applications again. + You can also right-click GenericAgent.app and choose Open. + + Uninstall + Quit GenericAgent, then delete GenericAgent.app from Applications. + EOF + sed -i '' 's/^ //' "$DMG_STAGE/readme.txt" + + mkdir -p artifacts/macos/out + hdiutil create \ + -volname "GenericAgent Desktop" \ + -srcfolder "$DMG_STAGE" \ + -ov \ + -format UDZO \ + "artifacts/macos/out/GenericAgent-Desktop-macOS.dmg" + shasum -a 256 "artifacts/macos/out/GenericAgent-Desktop-macOS.dmg" \ + > "artifacts/macos/out/GenericAgent-Desktop-macOS.dmg.sha256" + + echo "DMG stage tree:" + find "$DMG_STAGE" -maxdepth 2 -print | sort + + - name: Upload workflow artifact + uses: actions/upload-artifact@v7 + with: + name: GenericAgent-Desktop-macOS-DMG-${{ github.run_number }} + path: artifacts/macos/out/* + 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”中启动。若出现“无法验证开发者”等提示,先双击 DMG 内的 `open_anyway.command`,再启动。卸载时先退出应用,再从 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, double-click `open_anyway.command` inside the DMG first, then launch again. 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/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/macos/out/* --clobber diff --git a/agentmain.py b/agentmain.py index be7514625..d5565b932 100644 --- a/agentmain.py +++ b/agentmain.py @@ -241,7 +241,7 @@ def run(self): histfile = histfile or os.path.join(d, '_history.json') elif args.func: infile = args.func; outfile = os.path.splitext(args.func)[0] + '.out.txt' - + if histfile and os.path.isfile(histfile): agent.llmclient.backend.history = json.loads(open(histfile, encoding='utf-8').read()) if args.func or args.task: @@ -249,8 +249,8 @@ def run(self): with open(infile, encoding='utf-8') as f: raw = f.read() while True: dq = agent.put_task(raw, source='func' if args.func else 'task') - while 'done' not in (item := dq.get(timeout=2200)): - if 'next' in item: + while 'done' not in (item := dq.get(timeout=2200)): + if 'next' in item: with open(outfile, 'w', encoding='utf-8') as f: f.write(item.get('next', '')) with open(outfile, 'w', encoding='utf-8') as f: f.write(item['done'] + '\n\n[ROUND END]\n') if not args.task: break diff --git a/docs/installation.md b/docs/installation.md index dab72278b..f372b0e3c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -160,6 +160,12 @@ type ga If it resolves to something unexpected, do not rely on the shortcut. Run GA from the install directory with `python launch.pyw` or `python frontends/tuiapp_v2.py`. +#### Windows Defender blocks local loopback + +The desktop app talks to its local bridge over `127.0.0.1` / `localhost`. On some Windows machines, Windows Defender Firewall may block this loopback connection, making the desktop app look like it cannot start or cannot connect to the service. + +If this happens, allow `GenericAgent.exe` and the bundled `python.exe` through Windows Defender Firewall, then restart the desktop app. + #### Windows TUI rendering issues TUI rendering on Windows depends on terminal, font, and `textual` version. diff --git a/docs/installation_zh.md b/docs/installation_zh.md index c124cc7b7..485d26884 100644 --- a/docs/installation_zh.md +++ b/docs/installation_zh.md @@ -160,6 +160,12 @@ type ga 如果解析到意料之外的位置,就不要依赖这个快捷命令。请进入安装目录运行 `python launch.pyw` 或 `python frontends/tuiapp_v2.py`。 +#### Windows Defender 拦截本机回环网络 + +桌面端会通过 `127.0.0.1` / `localhost` 连接本机 bridge 服务。部分 Windows 机器上,Windows Defender 防火墙可能会拦截这个本机回环连接,导致桌面端看起来无法启动,或一直连不上服务。 + +如果遇到这种情况,请在 Windows Defender 防火墙中允许 `GenericAgent.exe` 和随包的 `python.exe` 通过,然后重启桌面端。 + #### Windows 上 TUI 显示异常 TUI 在 Windows 上依赖终端、字体和 `textual` 版本。 diff --git a/frontends/conductor.py b/frontends/conductor.py index 7b739e84b..b282eecbe 100644 --- a/frontends/conductor.py +++ b/frontends/conductor.py @@ -1,14 +1,24 @@ import os, sys, re, time, json, uuid, queue, asyncio, threading from dataclasses import dataclass, field from typing import Dict, Any, Optional, List -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +def _resolve_ga_root() -> str: + """External core dir when launched as the bundle's conductor (design 三). + Prefer GA_ROOT env (set by the desktop bridge), else derive from own location.""" + val = (os.environ.get("GA_ROOT", "") or "").strip() + if val: + root = os.path.abspath(os.path.expanduser(val)) + if os.path.exists(os.path.join(root, "agentmain.py")): + return root + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +ROOT = _resolve_ga_root() if ROOT not in sys.path: sys.path.insert(0, ROOT) from agentmain import GenericAgent @@ -18,28 +28,60 @@ HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor.html") -def _desktop_llm_no() -> Optional[int]: - """Read the model index the user picked in the desktop UI. - Persisted by the bridge at ~/.ga_desktop_settings.json under ui.llmNo. - Returns None when unavailable, so callers keep the agent's default model.""" +def _settings_doc() -> dict: try: from pathlib import Path doc = json.loads((Path.home() / ".ga_desktop_settings.json").read_text(encoding="utf-8")) - no = (doc.get("ui") or {}).get("llmNo") - return int(no) if no is not None else None + return doc if isinstance(doc, dict) else {} except Exception: - return None + return {} -def _apply_desktop_model(agent: "GenericAgent") -> None: - """Switch a freshly built agent to the desktop-selected model (if any).""" - no = _desktop_llm_no() - if no is None: +def _conductor_llm_no() -> Optional[int]: + """Read the model index bound to the conductor session. + Falls back to the legacy desktop default ui.llmNo for existing installs.""" + doc = _settings_doc() + for section in (doc.get("conductor"), doc.get("ui")): + if isinstance(section, dict) and section.get("llmNo") is not None: + try: + return int(section.get("llmNo")) + except (TypeError, ValueError): + pass + return None + + +def _client_usable(agent: "GenericAgent") -> bool: + return hasattr(getattr(agent, "llmclient", None), "backend") + + +def _fallback_usable_model(agent: "GenericAgent") -> None: + for i, client in enumerate(getattr(agent, "llmclients", []) or []): + if not hasattr(client, "backend"): + continue + agent.llm_no = i + agent.llmclient = client + with suppress(Exception): + agent.llmclient.last_tools = '' return - try: - agent.next_llm(int(no)) - except Exception as e: - print(f"[conductor] failed to apply desktop model #{no}: {e}", file=sys.stderr) + + +def _apply_desktop_model(agent: "GenericAgent") -> None: + """Make the conductor's session reflect the current desktop config before a task: + switch to its bound model if one is set, otherwise still refresh sessions from + mykey so live key/model edits (e.g. importing keys) take effect without a restart. + next_llm() already reloads internally; the no-bound-model branch must reload too, + or a conductor started on an empty/stale mykey would never pick up imported keys.""" + no = _conductor_llm_no() + if no is not None: + try: + agent.next_llm(int(no)) + except Exception as e: + print(f"[conductor] failed to apply conductor model #{no}: {e}", file=sys.stderr) + else: + agent.load_llm_sessions() # mtime-guarded; rebuilds only when mykey changed + if not _client_usable(agent): + print("[conductor] selected model is unavailable; falling back to first usable model", file=sys.stderr) + _fallback_usable_model(agent) def _select_llm(agent: "GenericAgent", llm: Any) -> bool: @@ -396,7 +438,9 @@ def _run(self): _apply_desktop_model(self.agent) dq = self.agent.put_task(prompt, source="conductor") self._drain(dq, events) - except Exception as e: print(f"Conductor error: {e}") + except Exception as e: + print(f"Conductor error: {e}") + add_chat(f"⚠ 回复失败:{e}", role="error") def start(self): threading.Thread(target=self._run, name="conductor-loop", daemon=True).start() @@ -404,7 +448,17 @@ def start(self): threading.Thread(target=self._run, name="conductor-loop", daemo conductor = Conductor() # ---- IM poller: 探测conductor_im_plugins/下各插件,信号变化→唤醒总管 ---- -IM_DIR, IM_COOLDOWN = os.path.join(os.path.dirname(__file__), "conductor_im_plugins"), 300 +def _resolve_im_dir() -> str: + # 方案三: conductor.py itself is bundle-owned, but IM plugins are user/environment + # integrations. Prefer the external core's plugins when GA_ROOT points at one; fall back + # to the bundle templates/examples when the external core has no plugin directory. + external = os.path.join(ROOT, "frontends", "conductor_im_plugins") + if os.path.isdir(external): + return external + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor_im_plugins") + + +IM_DIR, IM_COOLDOWN = _resolve_im_dir(), 300 IM_PROMPTS: Dict[str, str] = {} # source -> 采集prompt(派采集subagent时按需取) def im_poll_loop(): diff --git a/frontends/desktop/packaging/CHECKLIST.md b/frontends/desktop/packaging/CHECKLIST.md new file mode 100644 index 000000000..c85bb6ab6 --- /dev/null +++ b/frontends/desktop/packaging/CHECKLIST.md @@ -0,0 +1,48 @@ +# GenericAgent Desktop 测试 CHECKLIST + +--- + +## 一、聊天页面 + +- [ ] 发消息能正常收到回复,顶栏状态在「运行中」「就绪」之间切换,中文输入法 Enter 不会误发,超长粘贴会被截断 +- [ ] 上传文件、拖拽图片到输入框,能发送且 agent 能识别,流式回复中可中断,悬停消息能复制全文 +- [ ] 代码块/数学公式/多轮折叠都正常,长回复(2000 字以上)末尾不丢字 +- [ ] 起始页预设卡片可跳转(指挥家)或触发对应模式(Plan / Goal 等),自定义预设可增删且刷新后仍在 +- [ ] 切换模型后用量页能记录新模型,设置页调字号后聊天字体实时变,ask_user 弹问题卡能正常答复 +- [ ] 切换到一个 300 轮对话以上的 session,确保页面不卡顿 + +--- + +## 二、会话列表 + +- [ ] 新对话能创建,空态文案正常显示,搜索框能按标题过滤 +- [ ] 置顶/取消置顶/重命名/删除(激活和非激活分别测),都即时生效且刷新后持久 +- [ ] 右栏可拖窄拖宽和整体折叠,跑任务的会话有运行中视觉标识 + +--- + +## 三、指挥家 + +- [ ] 派一个多步骤目标,顶栏状态进入运行中,右侧分工进度出现子任务卡片,任务跑完主区有简报回复 + +--- + +## 四、后台服务 + +- [ ] 确保修改的配置能保存到本地,除此之外都是原有功能,不需要测试 + +--- + +## 五、用量 + +- [ ] 在聊天和指挥家页面发送消息并收到回复,确保聊天和指挥家都能被统计用量,且模型无误 +- [ ] 表格列宽固定不跳动,会话名过长用省略号显示且悬停有 tooltip,点击会话行可展开按时间倒序的明细 +- [ ] 趋势图折叠展开后不变形,日期范围筛选 + 重置生效,已删除的会话标「此会话已删除」 + +--- + +## 六、设置页面(外观 / 字号 / 语言 / 模型管理) + +- [ ] 外观浅色/深色切换,浅色下「素色」开关可用,聊天字号 10-20 可调,关弹窗再开后所有设置仍生效 +- [ ] 中英文切换后整个 UI 文案跟着变,无中英混杂 +- [ ] 模型可添加/编辑/删除,添加弹窗里的 DeepSeek / 通义千问 快速接入会预填地址+协议+模型,用户只需粘贴 API key 就能保存使用 diff --git a/frontends/desktop/packaging/README.md b/frontends/desktop/packaging/README.md new file mode 100644 index 000000000..89e2a038b --- /dev/null +++ b/frontends/desktop/packaging/README.md @@ -0,0 +1,35 @@ +# packaging + +桌面端发布相关材料。本目录的内容**不会**整体打进发布包——CI +(`.github/workflows/desktop-release-package.yml`)只从这里挑选 `scripts/` 下的 +安装/卸载脚本拷进各平台的发布产物,其余文件对构建打包过程是只读参考。 + +## 目录结构 + +``` +frontends/desktop/packaging/ +├── README.md # 本说明 +├── CHECKLIST.md # 发布前功能测试清单(测试协调用,不参与打包) +├── TODO.md # 各平台测试分工与计划(测试协调用,不参与打包) +└── scripts/ # ← 唯一被 CI 消费的内容 + ├── windows/ + │ ├── install_windows.ps1 # 环境准备脚本 + │ ├── uninstall.bat # 卸载入口(向用户确认后调用 ps1) + │ └── uninstall_windows.ps1 + ├── linux/ + │ ├── install_linux.sh + │ └── uninstall.sh + └── macos/ + ├── install_macos.sh + └── uninstall.command +``` + +## CI 如何使用这些脚本 + +`desktop-release-package.yml` 在打各平台 portable 包时,把对应平台的脚本 +`cp` 进发布目录(例如 Windows 包里放 `install_windows.ps1` / +`uninstall.bat` / `uninstall_windows.ps1`)。**修改安装/卸载行为只需改 +`scripts/` 下的文件,不需要动 workflow。** + +> 说明:实际的桌面壳二进制(`GenericAgent.exe` / `.AppImage` / `.app`)由 CI +> 构建生成并发布到 GitHub Release,不在本仓库内提交,也不在本目录占位。 diff --git a/frontends/desktop/packaging/TODO.md b/frontends/desktop/packaging/TODO.md new file mode 100644 index 000000000..85cd6858d --- /dev/null +++ b/frontends/desktop/packaging/TODO.md @@ -0,0 +1,85 @@ +# GenericAgent Desktop 测试 TODO + +## 目标 + +目前打算采用 git action 构建发布版,本轮测试主要关注三个结果: + +1. 测试者优先验证已有 Release 产物是否能在目标系统运行。 +2. 若 Release 产物不可用,再记录失败原因,并考虑本地构建或调整 GitHub Action 构建方法。 +3. Release 产物能启动后,再做核心功能兼容性测试。 + +--- + +## 系统覆盖 + +重点测试 Windows 和 Linux; + +| 优先级 | 系统 | 架构/版本 | 测试员 | 状态 | 备注 | +|---|---|---|---|---|---| +| P0 | Windows 11 | x64 | 无 | 开发过程中已测试 | 当前最优先 Windows 环境 | +| P0 | Windows 10 | x64 | 杨航 | TODO | 常见 Windows 环境 | +| P0 | Ubuntu 24.04 LTS | x64 | 张景铭 | TODO | 当前常见 Linux LTS | +| P0 | Ubuntu 22.04 LTS | x64 | 张景铭 | TODO | 仍然常见的 Linux LTS | +| P1 | Debian 12 | x64 | 曹兮 | TODO | 可选,覆盖 Debian 系 | +| Owner | macOS | Apple Silicon 或 Intel | 杨航 | TODO | | + + +--- + +## 每个系统需要完成的任务 + +### 任务 1:下载并验证已有 Release 产物 + +测试员优先使用已有 Release,不要求先在本机生成程序壳。 + +当前测试 Release: + +```text +https://github.com/dd3xp/GenericAgent_Desktop/releases/tag/desktop-windows-test-3-1 +``` + +当前资产命名: + +```text +GenericAgent-Desktop-Windows.exe +GenericAgent-Desktop-Linux.AppImage +SHA256SUMS.txt +``` + +测试步骤: + +1. 下载对应系统的 Release 产物和 `SHA256SUMS.txt`; +2. 启动环境配置脚本 +3. 运行程序壳; +4. 记录是否能打开主界面、是否能连上本地 bridge/后端; + +环境配置脚本 Windows 可参考: + +```powershell +# 在仓库根目录执行 +.\frontends\desktop\packaging\scripts\windows\install_windows.ps1 +``` + +环境配置脚本 Linux 可参考: + +```bash +chmod +x frontends/desktop/packaging/scripts/linux/install_linux.sh +./frontends/desktop/packaging/scripts/linux/install_linux.sh --mode PrepareOnly +chmod +x GenericAgent-Desktop-Linux.AppImage +./GenericAgent-Desktop-Linux.AppImage +``` + +说明:Linux 脚本 `frontends/desktop/packaging/scripts/linux/install_linux.sh` 目前作为参考实现使用,已在 Ubuntu 24.04.4 LTS x64 上做过初步试用,但仍需按清单做完整验证。脚本核心做法是准备运行环境,并写入用户级桌面配置(如 `~/.ga_desktop_settings.json`),让程序壳能找到 `project_dir`、`python_path` 和 bridge 脚本。 + +### 任务 2:Release 不可用时的升级路径 + +如果 Release 产物在目标系统不可用: +1. 如果只是目标机缺少运行环境,优先修正环境配置脚本; +2. 如果 Release 产物本身不兼容,尝试在目标系统本地构建; +3. 如果本地构建可行,再考虑修改 GitHub Action 的 Linux/Windows 构建方法,让 CI 产物与本地成功产物保持一致。 + +### 任务 3:核心功能兼容性测试 + +Release 产物可以启动并连上后端后,再根据 `frontends/desktop/packaging/CHECKLIST.md` 测试不同平台下的界面和核心功能兼容性。 + +--- diff --git a/frontends/desktop/packaging/scripts/linux/install_linux.sh b/frontends/desktop/packaging/scripts/linux/install_linux.sh new file mode 100755 index 000000000..f8b14242c --- /dev/null +++ b/frontends/desktop/packaging/scripts/linux/install_linux.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The desktop AppImage injects PYTHONHOME/PYTHONPATH/LD_LIBRARY_PATH pointing at its own +# read-only mount, which breaks the bundled python ("No module named 'encodings'"). Always +# run with a clean python env so the embedded interpreter uses its own stdlib/libs. +unset PYTHONHOME PYTHONPATH LD_LIBRARY_PATH + +# GenericAgent Desktop Linux installer. +# Expected normal location: GenericAgent/frontends/install_linux.sh +# Expected AppImage: GenericAgent/frontends/GenericAgent.AppImage +# +# Usage: +# ./install_linux.sh +# ./install_linux.sh --project-dir /path/to/GenericAgent +# ./install_linux.sh --mode PrepareOnly|Launch|BridgeOnly +# ./install_linux.sh --skip-pip-install +# +# What this script does: +# 1. Resolve the GenericAgent project root containing agentmain.py. +# 2. Resolve/create a Python runtime under project .venv unless --no-venv is used. +# 3. Install minimal desktop bridge dependencies unless --skip-pip-install is used. +# 4. Write ~/.ga_desktop_settings.json for the AppImage desktop shell. +# 5. chmod +x GenericAgent.AppImage. +# 6. Create/update desktop and application-menu .desktop launchers. +# 7. By default, do not launch the app; use --mode Launch if desired. + +PROJECT_DIR="" +PYTHON_PATH="" +MODE="PrepareOnly" +NO_VENV=0 +SKIP_PIP_INSTALL=0 +APPIMAGE_PATH="" +WHEEL_DIR="" +EXTRA_PACKAGES="" + +log_step() { printf '\n==> %s\n' "$*" >&2; } +log_ok() { printf '[OK] %s\n' "$*" >&2; } +log_warn() { printf '[WARN] %s\n' "$*" >&2; } +fail() { printf '[ERROR] %s\n' "$*" >&2; exit 1; } + +usage() { + sed -n '1,28p' "$0" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir|-p) + [[ $# -ge 2 ]] || fail "Missing value for $1" + PROJECT_DIR="$2"; shift 2 ;; + --python-path) + [[ $# -ge 2 ]] || fail "Missing value for $1" + PYTHON_PATH="$2"; shift 2 ;; + --mode|-m) + [[ $# -ge 2 ]] || fail "Missing value for $1" + MODE="$2"; shift 2 ;; + --appimage) + [[ $# -ge 2 ]] || fail "Missing value for $1" + APPIMAGE_PATH="$2"; shift 2 ;; + --no-venv) + NO_VENV=1; shift ;; + --skip-pip-install) + SKIP_PIP_INSTALL=1; shift ;; + --wheel-dir) + [[ $# -ge 2 ]] || fail "Missing value for $1" + WHEEL_DIR="$2"; shift 2 ;; + --extra-packages) + [[ $# -ge 2 ]] || fail "Missing value for $1" + EXTRA_PACKAGES="$2"; shift 2 ;; + --help|-h) + usage; exit 0 ;; + *) + fail "Unknown argument: $1" ;; + esac +done + +case "$MODE" in + PrepareOnly|Launch|BridgeOnly) ;; + *) fail "Unsupported --mode '$MODE'. Expected PrepareOnly, Launch, or BridgeOnly." ;; +esac + +resolve_script_root() { + local src="${BASH_SOURCE[0]}" + while [[ -L "$src" ]]; do + local dir + dir="$(cd -P "$(dirname "$src")" && pwd)" + src="$(readlink "$src")" + [[ "$src" != /* ]] && src="$dir/$src" + done + cd -P "$(dirname "$src")" && pwd +} + +abs_path() { + python3 - "$1" <<'PY' +import os, sys +print(os.path.abspath(os.path.expanduser(sys.argv[1]))) +PY +} + +has_agentmain() { [[ -f "$1/agentmain.py" ]]; } + +find_project_root() { + local script_root="$1" + if [[ -n "$PROJECT_DIR" ]]; then + local p + p="$(abs_path "$PROJECT_DIR")" + has_agentmain "$p" || fail "Project dir does not contain agentmain.py: $PROJECT_DIR" + printf '%s\n' "$p" + return + fi + + local candidates=( + "$script_root/.." + "$PWD/.." + "$PWD" + "$script_root" + "$script_root/../.." + "$HOME/GenericAgent" + "$HOME/GenericAgent_Desktop" + "$HOME/GenericAgent_Desktop_test" + ) + + local c p + for c in "${candidates[@]}"; do + [[ -e "$c" ]] || continue + p="$(abs_path "$c")" + if has_agentmain "$p"; then + printf '%s\n' "$p" + return + fi + done + + fail "Cannot locate GenericAgent project root. Put this script in GenericAgent/frontends or pass --project-dir /path/to/GenericAgent." +} + +python_supported() { + local py="$1" + "$py" - <<'PY' >/dev/null 2>&1 +import sys +raise SystemExit(0 if (3, 10) <= sys.version_info[:2] < (3, 14) else 1) +PY +} + +python_exe() { + local py="$1" + "$py" - <<'PY' +import sys +print(sys.executable) +PY +} + +find_python() { + if [[ -n "$PYTHON_PATH" ]]; then + python_supported "$PYTHON_PATH" || fail "Specified Python is not supported: $PYTHON_PATH. Need Python >=3.10,<3.14." + python_exe "$PYTHON_PATH" + return + fi + + local py + for py in python3.12 python3.11 python3.10 python3 python; do + if command -v "$py" >/dev/null 2>&1 && python_supported "$py"; then + python_exe "$py" + return + fi + done + fail "No supported Python found. Install Python 3.10-3.13 or pass --python-path." +} + +ensure_venv() { + local root="$1" + local base_py="$2" + if [[ "$NO_VENV" -eq 1 ]]; then + printf '%s\n' "$base_py" + return + fi + + local venv="$root/.venv" + local vpy="$venv/bin/python" + if [[ ! -x "$vpy" ]]; then + # NOTE: ensure_venv's stdout is captured by the caller as the python path + # (PY="$(ensure_venv ...)"), so progress markers must NOT be printed here — + # they go on the main-flow stdout instead. Only the venv path goes to stdout. + log_step "Create virtual environment: $venv" + "$base_py" -m venv "$venv" || fail "Failed to create venv. On Debian/Ubuntu install python3-venv." + fi + python_supported "$vpy" || fail "Venv Python is not supported: $vpy" + printf '%s\n' "$vpy" +} + +install_dependencies() { + local root="$1" + local py="$2" + if [[ "$SKIP_PIP_INSTALL" -eq 1 ]]; then + log_warn "Skipped pip install because --skip-pip-install was used." + return + fi + + printf 'GAPROGRESS|deps\n' + 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" + # 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 \ + || fail "Offline pip install failed (check wheel dir)." + else + log_step "Install/refresh minimal Python dependencies" + "$py" -m pip install --upgrade pip setuptools wheel || fail "pip bootstrap failed." + # shellcheck disable=SC2086 + "$py" -m pip install -e "$root" psutil $EXTRA_PACKAGES || fail "pip install failed." + fi + printf 'GAPROGRESS|done\n' +} + +ensure_mykey() { + local root="$1" + local mykey="$root/mykey.py" + local tpl="$root/mykey_template.py" + if [[ -f "$mykey" ]]; then + log_ok "mykey.py exists" + elif [[ -f "$tpl" ]]; then + cp "$tpl" "$mykey" + log_warn "Created mykey.py from mykey_template.py. Please fill API keys before model calls." + else + log_warn "mykey.py and mykey_template.py are missing. Model config may need manual setup." + fi +} + +write_desktop_settings() { + local root="$1" + local py="$2" + local bridge="$root/frontends/desktop_bridge.py" + local settings_path="$HOME/.ga_desktop_settings.json" + [[ -f "$bridge" ]] || fail "desktop_bridge.py not found: $bridge" + + "$py" - "$settings_path" "$py" "$root" "$bridge" <<'PY' +import json, pathlib, sys +settings_path, python_path, project_dir, bridge_script = sys.argv[1:5] +obj = { + "python_path": python_path, + "project_dir": project_dir, + "bridge_script": bridge_script, +} +pathlib.Path(settings_path).write_text(json.dumps(obj, indent=2), encoding="utf-8") +PY + log_ok "Wrote desktop settings: $settings_path" +} + +find_appimage() { + local root="$1" + local script_root="$2" + + if [[ -n "$APPIMAGE_PATH" ]]; then + local p + p="$(abs_path "$APPIMAGE_PATH")" + [[ -f "$p" ]] || fail "AppImage not found: $APPIMAGE_PATH" + printf '%s\n' "$p" + return + fi + + local candidates=( + "$script_root/GenericAgent.AppImage" + "$root/frontends/GenericAgent.AppImage" + ) + local p + for p in "${candidates[@]}"; do + if [[ -f "$p" ]]; then + printf '%s\n' "$(abs_path "$p")" + return + fi + done + + # Last-resort support for older versioned artifacts, but generated releases should use GenericAgent.AppImage. + find "$root/frontends" -maxdepth 1 -type f -name '*.AppImage' 2>/dev/null | sort | head -n 1 || true +} + +find_icon() { + local root="$1" + local candidates=( + "$root/frontends/desktop/src-tauri/icons/icon.png" + "$root/frontends/desktop/src-tauri/icons/128x128.png" + "$root/frontends/desktop/src-tauri/icons/128x128@2x.png" + ) + local p + for p in "${candidates[@]}"; do + if [[ -f "$p" ]]; then + printf '%s\n' "$p" + return + fi + done + printf '%s\n' "generic" +} + +shell_quote() { + python3 - "$1" <<'PY' +import shlex, sys +print(shlex.quote(sys.argv[1])) +PY +} + +write_desktop_file() { + local out="$1" + local root="$2" + local appimage="$3" + local icon="$4" + mkdir -p "$(dirname "$out")" + cat > "$out" </dev/null 2>&1; then + gio set "$desktop_file" metadata::trusted true >/dev/null 2>&1 || true + gio set "$menu_file" metadata::trusted true >/dev/null 2>&1 || true + fi + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$menu_dir" >/dev/null 2>&1 || true + fi + + log_ok "Desktop launcher: $desktop_file" + log_ok "If your file manager marks the desktop icon as untrusted, right-click it and choose Allow Launching." +} + +start_bridge() { + local root="$1" + local py="$2" + local bridge="$root/frontends/desktop_bridge.py" + [[ -f "$bridge" ]] || fail "desktop_bridge.py not found: $bridge" + log_step "Start bridge: $bridge" + export PYTHONPATH="$root:$root/frontends:${PYTHONPATH:-}" + exec "$py" "$bridge" +} + +launch_appimage() { + local appimage="$1" + log_step "Launch AppImage" + nohup "$appimage" >/tmp/genericagent-desktop-appimage.log 2>&1 & + log_ok "Started: $appimage" + log_ok "Log: /tmp/genericagent-desktop-appimage.log" +} + +SCRIPT_ROOT="$(resolve_script_root)" + +log_step "Resolve project root" +ROOT="$(find_project_root "$SCRIPT_ROOT")" +log_ok "Project root: $ROOT" + +# Portable self-prepare (offline, --wheel-dir set): the AppImage is already running and +# manages itself, so skip AppImage discovery and desktop-launcher creation. +PORTABLE_PREPARE=0 +[[ -n "$WHEEL_DIR" ]] && PORTABLE_PREPARE=1 + +APPIMAGE="" +if [[ "$PORTABLE_PREPARE" -eq 0 ]]; then + log_step "Resolve AppImage" + APPIMAGE="$(find_appimage "$ROOT" "$SCRIPT_ROOT")" + [[ -n "$APPIMAGE" ]] || fail "GenericAgent.AppImage not found. Put it next to install_linux.sh in GenericAgent/frontends/." + chmod +x "$APPIMAGE" + log_ok "AppImage: $APPIMAGE" +fi + +log_step "Resolve Python" +BASE_PY="$(find_python)" +log_ok "Base Python: $BASE_PY" + +# Progress marker on the real stdout (the Rust shell reads it); must be outside the +# command-substitution that captures ensure_venv's stdout as the python path. +printf 'GAPROGRESS|venv\n' +PY="$(ensure_venv "$ROOT" "$BASE_PY")" +log_ok "Runtime Python: $PY" + +install_dependencies "$ROOT" "$PY" +ensure_mykey "$ROOT" +write_desktop_settings "$ROOT" "$PY" +if [[ "$PORTABLE_PREPARE" -eq 0 ]]; then + install_desktop_launchers "$ROOT" "$APPIMAGE" +fi + +case "$MODE" in + PrepareOnly) + log_ok "Linux desktop setup finished. Start GenericAgent Desktop from the desktop icon or application menu." + ;; + Launch) + launch_appimage "$APPIMAGE" + ;; + BridgeOnly) + start_bridge "$ROOT" "$PY" + ;; +esac diff --git a/frontends/desktop/packaging/scripts/linux/uninstall.sh b/frontends/desktop/packaging/scripts/linux/uninstall.sh new file mode 100644 index 000000000..ed0c083c3 --- /dev/null +++ b/frontends/desktop/packaging/scripts/linux/uninstall.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# GenericAgent Desktop — portable uninstall (Linux). +# +# Removes everything THIS portable bundle put on the machine, then deletes the +# bundle folder itself: +# 1. Stop the bundle's processes (GUI + bridge/conductor/scheduler python) — +# only processes whose exe/cmdline live inside this bundle, so a second +# install on the same machine is left alone. +# 2. Remove the desktop shortcut (GenericAgent.desktop) — only when its Exec +# points into this bundle. +# 3. Remove ~/.ga_desktop_settings.json (shared settings; other bundles rebuild +# it on next launch). +# 4. Remove the WebKitGTK data dir (~/.local/share + ~/.cache for the app id). +# 5. Delete the bundle folder. +set -u + +BUNDLE="$(cd "$(dirname "$0")" && pwd)" +APP_ID="com.genericagent.app" + +echo "============================================================" +echo " GenericAgent Desktop - Uninstall" +echo "============================================================" +echo +echo "This will completely remove GenericAgent from this computer:" +echo " - stop its background services (bridge 14168 / conductor 8900)" +echo " - delete the desktop shortcut" +echo " - delete settings (~/.ga_desktop_settings.json)" +echo " - delete WebView data (~/.local/share/$APP_ID, ~/.cache/$APP_ID)" +echo " - delete THIS folder and everything in it:" +echo " $BUNDLE" +echo +echo "This cannot be undone." +echo +read -r -p "Type Y to uninstall, anything else to cancel: " CONFIRM +case "$CONFIRM" in + y|Y) ;; + *) echo; echo "Cancelled. Nothing was changed."; exit 0 ;; +esac + +echo +echo "==> Stopping GenericAgent backend services" +# Best-effort graceful exit first. +curl -fsS -m 3 -X POST "http://127.0.0.1:14168/services/bridge/exit" >/dev/null 2>&1 || true +sleep 1 + +# Kill any process whose exe or command line lives inside this bundle — never touch +# a second install (different path). Skip our own uninstall shell and its parent. +for pid in $(ls /proc 2>/dev/null | grep -E '^[0-9]+$'); do + [ "$pid" = "$$" ] && continue + [ "$pid" = "$PPID" ] && continue + exe="$(readlink -f "/proc/$pid/exe" 2>/dev/null || true)" + cl="$( (cat "/proc/$pid/cmdline" 2>/dev/null || true) | tr '\0' ' ')" + case "$exe $cl" in + *"$BUNDLE"*) kill -9 "$pid" 2>/dev/null && echo " killed PID $pid" ;; + esac +done +echo "[OK] backend stopped" + +echo "==> Removing desktop shortcut" +removed_shortcut=0 +for f in "$HOME/.local/share/applications/GenericAgent.desktop" \ + "${XDG_DESKTOP_DIR:-$HOME/Desktop}/GenericAgent.desktop" \ + "$HOME/桌面/GenericAgent.desktop"; do + [ -f "$f" ] || continue + if grep -qF "$BUNDLE" "$f" 2>/dev/null; then + rm -f "$f" && { echo "[OK] removed $f"; removed_shortcut=1; } + else + echo " $f points to another bundle; left in place" + fi +done +[ "$removed_shortcut" = 0 ] && echo " no desktop shortcut for this bundle found" + +echo "==> Removing settings file" +if [ -f "$HOME/.ga_desktop_settings.json" ]; then + rm -f "$HOME/.ga_desktop_settings.json" && echo "[OK] removed ~/.ga_desktop_settings.json" +else + echo " no settings file found" +fi + +echo "==> Removing WebView data" +for d in "$HOME/.local/share/$APP_ID" "$HOME/.cache/$APP_ID"; do + if [ -d "$d" ]; then rm -rf "$d" && echo "[OK] removed $d"; fi +done + +echo "==> Removing the bundle folder" +cd /tmp || cd / +if rm -rf "$BUNDLE" 2>/dev/null && [ ! -e "$BUNDLE" ]; then + echo "[OK] removed $BUNDLE" +else + # A still-mounted AppImage or busy file can block removal; retry detached after we exit. + nohup bash -c 'for i in $(seq 1 20); do rm -rf "'"$BUNDLE"'" 2>/dev/null; [ -e "'"$BUNDLE"'" ] || exit 0; sleep 1; done' >/dev/null 2>&1 & + echo "[OK] bundle folder will be removed after exit: $BUNDLE" +fi + +echo +echo "GenericAgent has been uninstalled." diff --git a/frontends/desktop/packaging/scripts/macos/install_macos.sh b/frontends/desktop/packaging/scripts/macos/install_macos.sh new file mode 100755 index 000000000..a03b74da7 --- /dev/null +++ b/frontends/desktop/packaging/scripts/macos/install_macos.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +set -euo pipefail + +# GenericAgent Desktop macOS portable installer/preparer. +# Intended bundle layout: +# GenericAgent-Desktop-macOS/ +# GenericAgent.app +# runtime/ +# python/ wheels/ install_macos.sh app/ +# +# Usage: +# ./install_macos.sh --python-path /path/to/python3 --project-dir /path/to/runtime/app --wheel-dir /path/to/wheels --mode PrepareOnly +# ./install_macos.sh --mode BridgeOnly + +PROJECT_DIR="" +PYTHON_PATH="" +MODE="PrepareOnly" +NO_VENV=0 +SKIP_PIP_INSTALL=0 +WHEEL_DIR="" +EXTRA_PACKAGES="" +APP_PATH="" + +log_step() { printf '\n==> %s\n' "$*" >&2; } +log_ok() { printf '[OK] %s\n' "$*" >&2; } +log_warn() { printf '[WARN] %s\n' "$*" >&2; } +fail() { printf '[ERROR] %s\n' "$*" >&2; exit 1; } +progress() { printf 'GAPROGRESS|%s\n' "$1"; } + +usage() { sed -n '1,18p' "$0"; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir|-p) [[ $# -ge 2 ]] || fail "Missing value for $1"; PROJECT_DIR="$2"; shift 2 ;; + --python-path) [[ $# -ge 2 ]] || fail "Missing value for $1"; PYTHON_PATH="$2"; shift 2 ;; + --mode|-m) [[ $# -ge 2 ]] || fail "Missing value for $1"; MODE="$2"; shift 2 ;; + --app|-a) [[ $# -ge 2 ]] || fail "Missing value for $1"; APP_PATH="$2"; shift 2 ;; + --no-venv) NO_VENV=1; shift ;; + --skip-pip-install) SKIP_PIP_INSTALL=1; shift ;; + --wheel-dir) [[ $# -ge 2 ]] || fail "Missing value for $1"; WHEEL_DIR="$2"; shift 2 ;; + --extra-packages) [[ $# -ge 2 ]] || fail "Missing value for $1"; EXTRA_PACKAGES="$2"; shift 2 ;; + --help|-h) usage; exit 0 ;; + *) fail "Unknown argument: $1" ;; + esac +done + +case "$MODE" in + PrepareOnly|Launch|BridgeOnly) ;; + *) fail "Unsupported --mode '$MODE'. Expected PrepareOnly, Launch, or BridgeOnly." ;; +esac + +resolve_script_root() { + local src="${BASH_SOURCE[0]}" + while [[ -L "$src" ]]; do + local dir + dir="$(cd -P "$(dirname "$src")" && pwd)" + src="$(readlink "$src")" + [[ "$src" != /* ]] && src="$dir/$src" + done + cd -P "$(dirname "$src")" && pwd +} + +abs_path() { + "$PYTHON_FOR_ABS" - "$1" <<'PY' +import os, sys +print(os.path.abspath(os.path.expanduser(sys.argv[1]))) +PY +} + +SCRIPT_ROOT="$(resolve_script_root)" +PYTHON_FOR_ABS="${PYTHON_PATH:-python3}" + +if [[ -z "$PROJECT_DIR" ]]; then + for c in "$SCRIPT_ROOT/app" "$SCRIPT_ROOT/../runtime/app" "$SCRIPT_ROOT/.." "$PWD"; do + if [[ -f "$c/agentmain.py" ]]; then PROJECT_DIR="$c"; break; fi + done +fi +[[ -n "$PROJECT_DIR" ]] || fail "Cannot resolve project dir; pass --project-dir" +PROJECT_DIR="$(abs_path "$PROJECT_DIR")" +[[ -f "$PROJECT_DIR/agentmain.py" ]] || fail "Project dir does not contain agentmain.py: $PROJECT_DIR" + +if [[ -z "$PYTHON_PATH" ]]; then + if [[ -x "$SCRIPT_ROOT/python/bin/python3" ]]; then PYTHON_PATH="$SCRIPT_ROOT/python/bin/python3"; else PYTHON_PATH="python3"; fi +fi +PYTHON_PATH="$(abs_path "$PYTHON_PATH")" +"$PYTHON_PATH" --version >&2 || fail "Python not runnable: $PYTHON_PATH" + +if [[ -z "$WHEEL_DIR" && -d "$SCRIPT_ROOT/wheels" ]]; then WHEEL_DIR="$SCRIPT_ROOT/wheels"; fi +if [[ -n "$WHEEL_DIR" ]]; then WHEEL_DIR="$(abs_path "$WHEEL_DIR")"; fi + +venv_python() { + printf '%s\n' "$PROJECT_DIR/.venv/bin/python" +} + +ensure_venv() { + if [[ "$NO_VENV" == "1" ]]; then return; fi + local vpy + vpy="$(venv_python)" + if [[ ! -x "$vpy" ]]; then + progress venv + log_step "Create venv: $PROJECT_DIR/.venv" + "$PYTHON_PATH" -m venv "$PROJECT_DIR/.venv" + fi +} + +install_deps() { + local py="$1" + [[ "$SKIP_PIP_INSTALL" == "1" ]] && return + progress deps + log_step "Install desktop bridge dependencies" + "$py" -m pip install --upgrade pip setuptools wheel + local pkgs=( + "requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil + ) + if [[ -n "$EXTRA_PACKAGES" ]]; then + # shellcheck disable=SC2206 + pkgs+=( $EXTRA_PACKAGES ) + fi + if [[ -n "$WHEEL_DIR" && -d "$WHEEL_DIR" ]]; then + "$py" -m pip install --no-index --find-links "$WHEEL_DIR" "${pkgs[@]}" + else + log_warn "No wheel dir supplied; falling back to online pip install" + "$py" -m pip install "${pkgs[@]}" + fi +} + +ensure_mykey() { + local mykey="$PROJECT_DIR/mykey.py" + local tpl="$PROJECT_DIR/mykey_template.py" + if [[ -f "$mykey" ]]; then + log_ok "mykey.py exists" + elif [[ -f "$tpl" ]]; then + cp "$tpl" "$mykey" + log_warn "Created mykey.py from mykey_template.py. Please fill API keys before model calls." + else + log_warn "mykey.py and mykey_template.py are missing. Model config may need manual setup." + fi +} + +write_settings() { + local py="$1" + local settings_path="$HOME/.ga_desktop_settings.json" + "$py" - "$settings_path" "$py" "$PROJECT_DIR" <<'PY' +import json, pathlib, sys +settings_path, python_path, project_dir = sys.argv[1:4] +pathlib.Path(settings_path).write_text(json.dumps({"python_path": python_path, "project_dir": project_dir}, indent=2), encoding="utf-8") +PY + log_ok "Wrote desktop settings: $settings_path" +} + +start_bridge() { + local py="$1" + local bridge="$PROJECT_DIR/frontends/desktop_bridge.py" + [[ -f "$bridge" ]] || fail "desktop_bridge.py not found: $bridge" + export PYTHONPATH="$PROJECT_DIR:$PROJECT_DIR/frontends:${PYTHONPATH:-}" + exec "$py" "$bridge" +} + +launch_app() { + if [[ -n "$APP_PATH" ]]; then + open "$APP_PATH" + elif [[ -d "$SCRIPT_ROOT/../GenericAgent.app" ]]; then + open "$SCRIPT_ROOT/../GenericAgent.app" + else + log_warn "GenericAgent.app not found next to runtime; skip launch" + fi +} + +ensure_venv +RUNTIME_PY="$(venv_python)" +if [[ "$NO_VENV" == "1" ]]; then RUNTIME_PY="$PYTHON_PATH"; fi +install_deps "$RUNTIME_PY" +ensure_mykey +write_settings "$RUNTIME_PY" +progress done + +case "$MODE" in + PrepareOnly) log_ok "Prepare complete: $PROJECT_DIR" ;; + BridgeOnly) start_bridge "$RUNTIME_PY" ;; + Launch) launch_app ;; +esac diff --git a/frontends/desktop/packaging/scripts/macos/uninstall.command b/frontends/desktop/packaging/scripts/macos/uninstall.command new file mode 100644 index 000000000..0898a03b6 --- /dev/null +++ b/frontends/desktop/packaging/scripts/macos/uninstall.command @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# GenericAgent Desktop — portable uninstall (macOS). Double-clickable in Finder (.command). +# +# Removes everything THIS portable bundle put on the machine, then deletes the +# bundle folder itself: +# 1. Stop the bundle's processes (GUI + bridge/conductor/scheduler python) — +# only processes whose command line lives inside this bundle. +# 2. Remove the desktop alias (~/Desktop/GenericAgent.app) — only when it links +# into this bundle. +# 3. Remove ~/.ga_desktop_settings.json (shared settings). +# 4. Remove the WKWebView data for the app id under ~/Library. +# 5. Delete the bundle folder. +set -u + +BUNDLE="$(cd "$(dirname "$0")" && pwd)" +APP_ID="com.genericagent.app" + +echo "============================================================" +echo " GenericAgent Desktop - Uninstall" +echo "============================================================" +echo +echo "This will completely remove GenericAgent from this computer:" +echo " - stop its background services (bridge 14168 / conductor 8900)" +echo " - delete the desktop alias" +echo " - delete settings (~/.ga_desktop_settings.json)" +echo " - delete WebView data (~/Library/.../$APP_ID)" +echo " - delete THIS folder and everything in it:" +echo " $BUNDLE" +echo +echo "This cannot be undone." +echo +read -r -p "Type Y to uninstall, anything else to cancel: " CONFIRM +case "$CONFIRM" in + y|Y) ;; + *) echo; echo "Cancelled. Nothing was changed."; exit 0 ;; +esac + +echo +echo "==> Stopping GenericAgent backend services" +curl -fsS -m 3 -X POST "http://127.0.0.1:14168/services/bridge/exit" >/dev/null 2>&1 || true +sleep 1 + +# Kill any process whose command line lives inside this bundle (no /proc on macOS, +# so match on the full argv via ps; -ww disables column truncation so a deep bundle +# path is never cut off). Scoped to the bundle path → other installs untouched. +# Skip our own shell ($$) and its parent (the Terminal-spawned launcher). +selfpid=$$ +ps -axww -o pid=,command= 2>/dev/null | while read -r pid cmd; do + [ "$pid" = "$selfpid" ] && continue + [ "$pid" = "$PPID" ] && continue + case "$cmd" in + *"$BUNDLE"*) kill -9 "$pid" 2>/dev/null && echo " killed PID $pid" ;; + esac +done +echo "[OK] backend stopped" + +echo "==> Removing desktop alias" +link="$HOME/Desktop/GenericAgent.app" +if [ -L "$link" ]; then + case "$(readlink "$link")" in + "$BUNDLE"*) rm -f "$link" && echo "[OK] removed $link" ;; + *) echo " desktop alias points to another bundle; left in place" ;; + esac +else + echo " no desktop alias found" +fi + +echo "==> Removing settings file" +if [ -f "$HOME/.ga_desktop_settings.json" ]; then + rm -f "$HOME/.ga_desktop_settings.json" && echo "[OK] removed ~/.ga_desktop_settings.json" +else + echo " no settings file found" +fi + +echo "==> Removing WebView data" +for d in "$HOME/Library/WebKit/$APP_ID" \ + "$HOME/Library/Caches/$APP_ID" \ + "$HOME/Library/Application Support/$APP_ID" \ + "$HOME/Library/HTTPStorages/$APP_ID" \ + "$HOME/Library/Saved Application State/$APP_ID.savedState"; do + if [ -e "$d" ]; then rm -rf "$d" && echo "[OK] removed $d"; fi +done +rm -f "$HOME/Library/Preferences/$APP_ID.plist" 2>/dev/null || true + +echo "==> Removing the bundle folder" +cd /tmp || cd / +if rm -rf "$BUNDLE" 2>/dev/null && [ ! -e "$BUNDLE" ]; then + echo "[OK] removed $BUNDLE" +else + nohup bash -c 'for i in $(seq 1 20); do rm -rf "'"$BUNDLE"'" 2>/dev/null; [ -e "'"$BUNDLE"'" ] || exit 0; sleep 1; done' >/dev/null 2>&1 & + echo "[OK] bundle folder will be removed after exit: $BUNDLE" +fi + +echo +echo "GenericAgent has been uninstalled." diff --git a/frontends/desktop/packaging/scripts/windows/install_windows.ps1 b/frontends/desktop/packaging/scripts/windows/install_windows.ps1 new file mode 100644 index 000000000..e501d5b30 --- /dev/null +++ b/frontends/desktop/packaging/scripts/windows/install_windows.ps1 @@ -0,0 +1,388 @@ +<# +GenericAgent Desktop Windows setup script + +Usage examples: + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -ProjectDir D:\GenericAgent_Desktop -Mode PrepareOnly + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -Mode BridgeOnly + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -Mode NpmInstallOnly + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -Mode DesktopBuildOnly + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -Mode DesktopDevOnly + +Mirror examples (default to China mirrors for faster downloads): + # use the built-in defaults (Tsinghua PyPI + npmmirror) + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 + # use a different pip mirror + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -PipIndexUrl https://mirrors.aliyun.com/pypi/simple/ + # disable mirrors, fall back to official PyPI / npm registry + powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -PipIndexUrl "" -NpmRegistry "" + +What this script does: + 1. Locate GenericAgent project dir, which must contain agentmain.py. + 2. Locate a supported Python, or create/use .venv. + 3. Install minimal Python dependencies for desktop bridge. + 4. Copy mykey_template.py to mykey.py if mykey.py is missing. + 5. Write %USERPROFILE%\.ga_desktop_settings.json for the Tauri shell. + 6. Optionally install npm/Tauri dependencies, build debug desktop exe, or start bridge/exe. + +Important: + - This script lives under test_workspace and is still a draft, but these modes have been smoke-tested here: + PrepareOnly, BridgeOnly/manual bridge smoke, NpmInstallOnly, DesktopBuildOnly, and debug exe GUI autostart. + - Development install and packaged-user install are not exactly the same. + Shared part: Python/env/deps/config preparation. + Different part: where files live, whether exe exists, whether Python is bundled. +#> + +param( + [string]$ProjectDir = "", + [string]$PythonPath = "", + [ValidateSet("Auto", "PrepareOnly", "BridgeOnly", "ExeOnly", "NpmInstallOnly", "DesktopDevOnly", "DesktopBuildOnly")] + [string]$Mode = "Auto", + [switch]$NoVenv, + [switch]$SkipPipInstall, + [switch]$SkipNpmInstall, + [switch]$SkipWebView2Check, + # Package mirrors. Default to China mirrors for speed; pass "" to use the official source. + [string]$PipIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple", + [string]$NpmRegistry = "https://registry.npmmirror.com", + # Offline install: when set, pip installs from local wheels only (no network). Used by the portable bundle. + [string]$WheelDir = "", + # Extra packages to install beyond the core deps (e.g. "fastapi uvicorn websockets" for the conductor service). + [string]$ExtraPipPackages = "" +) + +$ErrorActionPreference = "Stop" + +function Write-Step([string]$msg) { Write-Host "`n==> $msg" -ForegroundColor Cyan } +function Write-Ok([string]$msg) { Write-Host "[OK] $msg" -ForegroundColor Green } +function Write-Warn([string]$msg) { Write-Host "[WARN] $msg" -ForegroundColor Yellow } +function Fail([string]$msg) { throw "[ERROR] $msg" } + +function Resolve-ScriptRoot { + if ($PSScriptRoot) { return (Resolve-Path $PSScriptRoot).Path } + return (Get-Location).Path +} + +function Find-ProjectRoot([string]$startDir) { + if ($ProjectDir) { + $p = Resolve-Path $ProjectDir -ErrorAction Stop + if (Test-Path (Join-Path $p "agentmain.py")) { return $p.Path } + Fail "ProjectDir does not contain agentmain.py: $ProjectDir" + } + + $candidates = @( + (Get-Location).Path, + $startDir, + (Join-Path $startDir ".."), + (Join-Path $startDir "..\.."), + (Join-Path $startDir "..\..\.."), + "D:\GenericAgent_Desktop" + ) + + foreach ($c in $candidates) { + try { $rp = Resolve-Path $c -ErrorAction Stop } catch { continue } + if (Test-Path (Join-Path $rp.Path "agentmain.py")) { return $rp.Path } + } + Fail "Cannot locate GenericAgent project root. Pass -ProjectDir ." +} + +function Get-PythonVersionObject([string]$py) { + try { + $out = & $py -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')" 2>$null + if ($LASTEXITCODE -ne 0 -or -not $out) { return $null } + return [version]($out.Trim()) + } catch { return $null } +} + +function Test-SupportedPython([string]$py) { + $v = Get-PythonVersionObject $py + if (-not $v) { return $false } + return ($v.Major -eq 3 -and $v.Minor -ge 10 -and $v.Minor -lt 14) +} + +function Find-Python([string]$root) { + if ($PythonPath) { + if (Test-SupportedPython $PythonPath) { return (Resolve-Path $PythonPath).Path } + Fail "Specified Python is not supported. Need Python >=3.10,<3.14: $PythonPath" + } + + $portableCandidates = @( + (Join-Path $root ".portable\uv-python\python.exe"), + (Join-Path $root ".portable\python\python.exe"), + (Join-Path $root "python\python.exe") + ) + foreach ($p in $portableCandidates) { + if ((Test-Path $p) -and (Test-SupportedPython $p)) { return (Resolve-Path $p).Path } + } + + $cmds = @("python", "py") + foreach ($cmd in $cmds) { + try { + if ($cmd -eq "py") { + $probe = & py -3.12 -c "import sys; print(sys.executable)" 2>$null + if ($LASTEXITCODE -eq 0 -and $probe -and (Test-SupportedPython $probe.Trim())) { return $probe.Trim() } + $probe = & py -3.11 -c "import sys; print(sys.executable)" 2>$null + if ($LASTEXITCODE -eq 0 -and $probe -and (Test-SupportedPython $probe.Trim())) { return $probe.Trim() } + $probe = & py -3.10 -c "import sys; print(sys.executable)" 2>$null + if ($LASTEXITCODE -eq 0 -and $probe -and (Test-SupportedPython $probe.Trim())) { return $probe.Trim() } + } else { + $probe = & python -c "import sys; print(sys.executable)" 2>$null + if ($LASTEXITCODE -eq 0 -and $probe -and (Test-SupportedPython $probe.Trim())) { return $probe.Trim() } + } + } catch { } + } + + Fail "No supported Python found. Install Python 3.10-3.13, or pass -PythonPath." +} + +function Ensure-Venv([string]$root, [string]$basePython) { + if ($NoVenv) { return $basePython } + $venvDir = Join-Path $root ".venv" + $venvPy = Join-Path $venvDir "Scripts\python.exe" + if (-not (Test-Path $venvPy)) { + Write-Host "GAPROGRESS|venv" + Write-Step "Create virtual environment: $venvDir" + & $basePython -m venv $venvDir + if ($LASTEXITCODE -ne 0) { Fail "Failed to create venv." } + } + if (-not (Test-SupportedPython $venvPy)) { Fail "Venv Python is invalid: $venvPy" } + return (Resolve-Path $venvPy).Path +} + +function Install-Dependencies([string]$root, [string]$py) { + if ($SkipPipInstall) { Write-Warn "SkipPipInstall is set; dependencies are not installed."; return } + + # Extra packages (e.g. conductor service deps) appended to the core install. + $extra = @() + if ($ExtraPipPackages) { $extra = $ExtraPipPackages.Split(" ", [StringSplitOptions]::RemoveEmptyEntries) } + + # Offline mode (portable bundle): install from local wheels only, no network, no pip self-upgrade. + if ($WheelDir) { + $wd = (Resolve-Path $WheelDir -ErrorAction Stop).Path + Write-Ok "offline wheels: $wd" + if ($extra.Count) { Write-Ok "extra packages: $($extra -join ', ')" } + Write-Host "GAPROGRESS|deps" + Write-Step "Install GenericAgent dependencies and desktop bridge extras (offline)" + # Install deps directly (NOT an editable -e of the source): an editable install bakes the + # 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 + if ($LASTEXITCODE -ne 0) { Fail "offline pip install failed (check wheels dir)." } + Write-Host "GAPROGRESS|done" + return + } + + # Online mode: use a pip index mirror when -PipIndexUrl is set (default: Tsinghua). Pass -PipIndexUrl "" for official PyPI. + $pipIndexArgs = @() + if ($PipIndexUrl) { + $pipIndexArgs = @("-i", $PipIndexUrl) + Write-Ok "pip index mirror: $PipIndexUrl" + } else { + Write-Warn "No pip mirror set; using official PyPI." + } + Write-Step "Upgrade pip" + & $py -m pip install @pipIndexArgs --upgrade pip + if ($LASTEXITCODE -ne 0) { Fail "pip upgrade failed." } + + Write-Step "Install GenericAgent minimal package and desktop bridge extras" + # pyproject.toml already includes: requests, beautifulsoup4, bottle, simple-websocket-server, aiohttp. + # desktop_bridge.py additionally imports psutil. + & $py -m pip install @pipIndexArgs -e $root psutil @extra + if ($LASTEXITCODE -ne 0) { Fail "pip install failed." } +} + +function Ensure-MyKey([string]$root) { + $mykey = Join-Path $root "mykey.py" + $tpl = Join-Path $root "mykey_template.py" + if (Test-Path $mykey) { + Write-Ok "mykey.py exists" + return + } + if (Test-Path $tpl) { + Copy-Item $tpl $mykey + Write-Warn "Created mykey.py from mykey_template.py. User still needs to fill API keys." + } else { + Write-Warn "mykey.py and mykey_template.py are missing. Model config may not work." + } +} + +function Write-DesktopSettings([string]$root, [string]$py) { + $settingsPath = Join-Path $env:USERPROFILE ".ga_desktop_settings.json" + $obj = [ordered]@{ + python_path = $py + project_dir = $root + bridge_script = (Join-Path $root "frontends\desktop_bridge.py") + } + $json = $obj | ConvertTo-Json -Depth 5 + # PowerShell 5.1 `Set-Content -Encoding UTF8` writes a UTF-8 BOM. + # Rust serde_json does not accept BOM at the beginning, causing the Tauri shell + # to ignore this settings file and auto-discover the wrong project directory. + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($settingsPath, $json, $utf8NoBom) + Write-Ok "Wrote desktop settings: $settingsPath" +} + +function Test-WebView2Installed { + if ($SkipWebView2Check) { return } + Write-Step "Check Microsoft Edge WebView2 Runtime" + $keys = @( + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + "HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + "HKCU:\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + ) + foreach ($k in $keys) { + if (Test-Path $k) { Write-Ok "WebView2 Runtime detected"; return } + } + Write-Warn "WebView2 Runtime not detected. Tauri desktop window may fail on clean Windows." + Write-Warn "Download: https://developer.microsoft.com/microsoft-edge/webview2/" +} + +function Find-DesktopDir([string]$root) { + $desktop = Join-Path $root "frontends\desktop" + if (-not (Test-Path (Join-Path $desktop "src-tauri\tauri.conf.json"))) { + Fail "Tauri desktop dir not found: $desktop" + } + return (Resolve-Path $desktop).Path +} + +function Ensure-NodeAndNpm { + $node = Get-Command node -ErrorAction SilentlyContinue + $npm = Get-Command npm -ErrorAction SilentlyContinue + if (-not $node) { Fail "node is not available in PATH. Install Node.js 20+ for desktop dev/build." } + if (-not $npm) { Fail "npm is not available in PATH. Install Node.js/npm for desktop dev/build." } + $nodeVer = & node --version + $npmVer = & cmd /c npm --version + Write-Ok "Node: $nodeVer; npm: $npmVer" +} + +function Install-DesktopNpm([string]$root) { + if ($SkipNpmInstall) { Write-Warn "SkipNpmInstall is set; desktop npm dependencies are not installed."; return } + $desktop = Find-DesktopDir $root + Ensure-NodeAndNpm + # Use an npm registry mirror when -NpmRegistry is set (default: npmmirror). Pass -NpmRegistry "" for official registry. + $npmRegArgs = @() + if ($NpmRegistry) { + $npmRegArgs = @("--registry", $NpmRegistry) + Write-Ok "npm registry mirror: $NpmRegistry" + } + Write-Step "Install Tauri desktop npm dependencies: $desktop" + Push-Location $desktop + try { + & cmd /c npm install @npmRegArgs + if ($LASTEXITCODE -ne 0) { Fail "npm install failed in $desktop" } + & cmd /c npx tauri --version + if ($LASTEXITCODE -ne 0) { Fail "npx tauri --version failed after npm install" } + } finally { + Pop-Location + } +} + +function Build-DesktopDebug([string]$root) { + $desktop = Find-DesktopDir $root + Install-DesktopNpm $root + Write-Step "Build Tauri debug desktop exe" + Push-Location $desktop + try { + & cmd /c npx tauri build --debug + if ($LASTEXITCODE -ne 0) { Fail "Tauri debug build failed." } + } finally { + Pop-Location + } + $debugExe = Join-Path $desktop "src-tauri\target\debug\ga-desktop.exe" + if (Test-Path $debugExe) { Write-Ok "Debug exe: $debugExe" } else { Write-Warn "Debug exe not found at expected path: $debugExe" } +} + +function Start-DesktopDev([string]$root) { + $desktop = Find-DesktopDir $root + Install-DesktopNpm $root + Write-Step "Start Tauri desktop dev shell" + Write-Warn "This keeps running in current console. If status checks fail, bypass proxy for http://127.0.0.1:14168/status." + Push-Location $desktop + try { + & cmd /c npx tauri dev -- --dev + } finally { + Pop-Location + } +} + +function Find-PackagedExe([string]$root) { + $candidates = @( + (Join-Path $root "frontends\GenericAgent.exe"), + (Join-Path $root "frontends\desktop\src-tauri\target\release\GenericAgent.exe"), + (Join-Path $root "frontends\desktop\src-tauri\target\release\ga-desktop.exe"), + (Join-Path $root "frontends\desktop\src-tauri\target\debug\ga-desktop.exe") + ) + foreach ($p in $candidates) { if (Test-Path $p) { return (Resolve-Path $p).Path } } + return "" +} + +function Start-Bridge([string]$root, [string]$py) { + $bridge = Join-Path $root "frontends\desktop_bridge.py" + if (-not (Test-Path $bridge)) { Fail "desktop_bridge.py not found: $bridge" } + Write-Step "Start bridge: $bridge" + Write-Warn "This keeps running in current console. Browse http://127.0.0.1:14168/status to check." + $env:PYTHONPATH = "$root;$(Join-Path $root 'frontends');$env:PYTHONPATH" + & $py $bridge +} + +function Start-Exe([string]$root) { + $exe = Find-PackagedExe $root + if (-not $exe) { Fail "Packaged GenericAgent.exe not found. Use -Mode BridgeOnly or build Tauri first." } + Write-Step "Start packaged desktop exe" + Start-Process -FilePath $exe -WorkingDirectory (Split-Path $exe -Parent) + Write-Ok "Started: $exe" +} + +Write-Step "Resolve project root" +$scriptRoot = Resolve-ScriptRoot +$root = Find-ProjectRoot $scriptRoot +Write-Ok "Project root: $root" + +Write-Step "Resolve Python" +$basePy = Find-Python $root +Write-Ok "Base Python: $basePy" + +$py = Ensure-Venv $root $basePy +Write-Ok "Runtime Python: $py" + +Install-Dependencies $root $py +Ensure-MyKey $root +Write-DesktopSettings $root $py +Test-WebView2Installed + +if ($Mode -eq "PrepareOnly") { + Write-Ok "Preparation finished. No app started because -Mode PrepareOnly was used." + exit 0 +} + +if ($Mode -eq "BridgeOnly") { + Start-Bridge $root $py + exit 0 +} + +if ($Mode -eq "NpmInstallOnly") { + Install-DesktopNpm $root + Write-Ok "Desktop npm setup finished." + exit 0 +} + +if ($Mode -eq "DesktopBuildOnly") { + Build-DesktopDebug $root + Write-Ok "Desktop debug build finished." + exit 0 +} + +if ($Mode -eq "DesktopDevOnly") { + Start-DesktopDev $root + exit 0 +} + +if ($Mode -eq "ExeOnly") { + Start-Exe $root + exit 0 +} + +# Auto mode: prefer packaged exe if present, otherwise start bridge for source/dev tree. +$exe = Find-PackagedExe $root +if ($exe) { Start-Exe $root } else { Start-Bridge $root $py } diff --git a/frontends/desktop/packaging/scripts/windows/uninstall.bat b/frontends/desktop/packaging/scripts/windows/uninstall.bat new file mode 100644 index 000000000..077652ec4 --- /dev/null +++ b/frontends/desktop/packaging/scripts/windows/uninstall.bat @@ -0,0 +1,37 @@ +@echo off +setlocal +cd /d "%~dp0" + +echo ============================================================ +echo GenericAgent Desktop - Uninstall +echo ============================================================ +echo. +echo This will completely remove GenericAgent from this computer: +echo - stop its background services (ports 14168 / 8900) +echo - delete the desktop shortcut +echo - delete settings (%%USERPROFILE%%\.ga_desktop_settings.json) +echo - delete THIS folder and everything in it: +echo "%~dp0" +echo. +echo This cannot be undone. +echo. +set /p CONFIRM="Type Y to uninstall, anything else to cancel: " +if /i not "%CONFIRM%"=="Y" ( + echo. + echo Cancelled. Nothing was changed. + pause + exit /b 0 +) + +echo. +rem %~dp0 ends with a backslash; passing "...\dir\" makes the trailing \" escape the closing +rem quote, so PowerShell receives a path with a literal quote ("Illegal characters in path"). +rem Strip the trailing backslash before passing the bundle dir. +set "BUNDLE=%~dp0" +if "%BUNDLE:~-1%"=="\" set "BUNDLE=%BUNDLE:~0,-1%" +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0runtime\uninstall_windows.ps1" -BundleDir "%BUNDLE%" + +echo. +echo You can close this window now. +timeout /t 3 >nul +exit /b 0 diff --git a/frontends/desktop/packaging/scripts/windows/uninstall_windows.ps1 b/frontends/desktop/packaging/scripts/windows/uninstall_windows.ps1 new file mode 100644 index 000000000..e2262585d --- /dev/null +++ b/frontends/desktop/packaging/scripts/windows/uninstall_windows.ps1 @@ -0,0 +1,146 @@ +<# +GenericAgent Desktop — portable uninstall (Windows). + +Removes everything THIS portable bundle put on the machine, then deletes the +bundle folder itself: + 1. Stop the bundle's backend processes (bridge 14168 / conductor 8900 / scheduler) + — only processes whose executable lives inside this bundle, so other bundles + on the same machine are left alone. + 2. Remove the desktop shortcut (GenericAgent.lnk) — only when it points into + this bundle. + 3. Remove ~/.ga_desktop_settings.json (shared settings; other bundles rebuild it + automatically on next launch). + 4. Schedule deletion of the bundle folder after this script exits (a folder + cannot delete itself while code runs inside it). + +Invoked by uninstall.bat (which confirms with the user first). Not meant to be +run directly without -BundleDir. +#> + +param( + [Parameter(Mandatory = $true)] + [string]$BundleDir +) + +$ErrorActionPreference = "SilentlyContinue" + +function Write-Step([string]$m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Write-Ok([string]$m) { Write-Host "[OK] $m" -ForegroundColor Green } +function Write-Info([string]$m) { Write-Host " $m" -ForegroundColor Gray } + +# Normalize the bundle dir to an absolute path with a trailing separator for prefix checks. +# Defensive: strip stray quotes / trailing separators a caller may have leaked in (e.g. the +# classic "%~dp0" trailing-backslash-before-quote bug). +$BundleDir = $BundleDir.Trim().Trim('"').TrimEnd('\', '/') +try { $bundle = (Resolve-Path -LiteralPath $BundleDir).Path } catch { $bundle = $BundleDir } +$bundlePrefix = ($bundle.TrimEnd('\') + '\').ToLowerInvariant() + +function Path-IsInsideBundle([string]$p) { + if (-not $p) { return $false } + try { $rp = (Resolve-Path -LiteralPath $p -ErrorAction Stop).Path } catch { $rp = $p } + return $rp.ToLowerInvariant().StartsWith($bundlePrefix) +} + +# ── 1. Graceful backend shutdown, then force-kill bundle-owned processes ────── +Write-Step "Stopping GenericAgent backend services" + +# Best-effort graceful exit: tell the bridge to stop its managed extras and quit. +try { + Invoke-WebRequest -Uri "http://127.0.0.1:14168/services/bridge/exit" -Method Post ` + -TimeoutSec 3 -UseBasicParsing | Out-Null + Start-Sleep -Milliseconds 800 +} catch { } + +# Force-kill anything still listening on our ports, but ONLY if the owning process +# executable lives inside this bundle (don't disturb a second installed copy). +foreach ($port in 14168, 8900) { + foreach ($conn in (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)) { + $proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue + if ($proc -and (Path-IsInsideBundle $proc.Path)) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + Write-Info "killed PID $($proc.Id) ($($proc.ProcessName)) on port $port" + } elseif ($proc) { + Write-Info "port $port held by a process outside this bundle (PID $($proc.Id)); left running" + } + } +} + +# Kill ANY process whose executable image lives inside this bundle, regardless of name +# (GenericAgent.exe, python.exe, or any child tool it spawned). Limiting to the bundle path +# means we never touch a second installed copy. +foreach ($p in (Get-Process -ErrorAction SilentlyContinue)) { + if (Path-IsInsideBundle $p.Path) { + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + Write-Info "killed PID $($p.Id) ($($p.ProcessName))" + } +} +Write-Ok "backend stopped" + +# ── 2. Desktop shortcut (only if it targets this bundle) ───────────────────── +Write-Step "Removing desktop shortcut" +$desktop = [Environment]::GetFolderPath('Desktop') +$lnk = Join-Path $desktop 'GenericAgent.lnk' +if (Test-Path -LiteralPath $lnk) { + $target = $null + try { + $ws = New-Object -ComObject WScript.Shell + $target = $ws.CreateShortcut($lnk).TargetPath + } catch { } + if ((-not $target) -or (Path-IsInsideBundle $target)) { + Remove-Item -LiteralPath $lnk -Force -ErrorAction SilentlyContinue + Write-Ok "removed $lnk" + } else { + Write-Info "desktop shortcut points to another bundle; left in place" + } +} else { + Write-Info "no desktop shortcut found" +} + +# ── 3. Shared settings file ────────────────────────────────────────────────── +Write-Step "Removing settings file" +$settings = Join-Path $env:USERPROFILE '.ga_desktop_settings.json' +if (Test-Path -LiteralPath $settings) { + Remove-Item -LiteralPath $settings -Force -ErrorAction SilentlyContinue + Write-Ok "removed $settings" +} else { + Write-Info "no settings file found" +} + +# ── 3b. WebView2 data dir (cache + localStorage) ───────────────────────────── +# Tauri creates %LOCALAPPDATA%\com.genericagent.app\ (EBWebView: HTTP cache + localStorage) +# outside the bundle, keyed by the app identifier. Only the Tauri desktop shell uses it (the +# project's other frontends — qt/tui/conductor — do not), so removing it is safe. Other +# GenericAgent bundles share the same identifier and would just rebuild it on next launch. +Write-Step "Removing WebView2 data" +$wv = Join-Path $env:LOCALAPPDATA 'com.genericagent.app' +if (Test-Path -LiteralPath $wv) { + Remove-Item -LiteralPath $wv -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $wv) { Write-Info "WebView2 data partially locked; some files remain" } + else { Write-Ok "removed $wv" } +} else { + Write-Info "no WebView2 data found" +} + +# ── 4. Schedule deletion of the bundle folder ──────────────────────────────── +# The folder cannot remove itself while this script (and the uninstall.bat that +# launched it) run from inside it. Spawn a detached cmd that waits for our process +# tree to fully exit, then retries the delete a few times in case a handle lingers. +Write-Step "Scheduling removal of the bundle folder" +$deleter = @" +cd /d "%TEMP%" +for /l %%i in (1,1,20) do ( + rd /s /q "$bundle" 2>nul + if not exist "$bundle" goto done + ping 127.0.0.1 -n 2 >nul +) +:done +"@ +$deleterPath = Join-Path $env:TEMP ("ga_uninstall_{0}.bat" -f ([guid]::NewGuid().ToString('N'))) +Set-Content -LiteralPath $deleterPath -Value $deleter -Encoding ASCII +# Start detached so it survives this script + uninstall.bat exiting. The retry loop +# (20 tries x ~1s) covers the brief window where the parent processes release handles. +Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "`"$deleterPath`"" -WindowStyle Hidden | Out-Null +Write-Ok "bundle folder will be deleted after exit: $bundle" + +Write-Host "" +Write-Host "GenericAgent has been uninstalled." -ForegroundColor Green diff --git a/frontends/desktop/src-tauri/.cargo/config.toml b/frontends/desktop/src-tauri/.cargo/config.toml new file mode 100644 index 000000000..28e3047d5 --- /dev/null +++ b/frontends/desktop/src-tauri/.cargo/config.toml @@ -0,0 +1,7 @@ +# Statically link the MSVC CRT (UCRT + vcruntime) into the Windows exe so the thin +# shell itself runs on systems lacking an in-box Universal CRT (stripped/older +# images) — no DLLs needed next to GenericAgent.exe. Target-scoped: macOS/Linux +# builds never read this section. The embedded python keeps its own app-local UCRT +# (copied in CI next to python.exe) for the bridge/conductor subprocesses. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/frontends/desktop/src-tauri/capabilities/default.json b/frontends/desktop/src-tauri/capabilities/default.json index cf96e341c..aa9ad1e2d 100644 --- a/frontends/desktop/src-tauri/capabilities/default.json +++ b/frontends/desktop/src-tauri/capabilities/default.json @@ -18,6 +18,10 @@ "allow-start-bridge-with-config", "allow-get-config", "allow-export-mykey", + "allow-pick-directory", + "allow-get-ga-source", + "allow-set-ga-source", + "allow-clear-ga-source", "allow-shortcut-should-ask", "allow-shortcut-decide" ] diff --git a/frontends/desktop/src-tauri/permissions/bridge-commands.toml b/frontends/desktop/src-tauri/permissions/bridge-commands.toml index cf78df39b..313f49be9 100644 --- a/frontends/desktop/src-tauri/permissions/bridge-commands.toml +++ b/frontends/desktop/src-tauri/permissions/bridge-commands.toml @@ -18,6 +18,26 @@ identifier = "allow-export-mykey" description = "Save mykey.py via native file dialog." commands.allow = ["export_mykey"] +[[permission]] +identifier = "allow-pick-directory" +description = "Pick a source directory via native folder dialog (import memory)." +commands.allow = ["pick_directory"] + +[[permission]] +identifier = "allow-get-ga-source" +description = "Read the user-set external GenericAgent source directory." +commands.allow = ["get_ga_source"] + +[[permission]] +identifier = "allow-set-ga-source" +description = "Point the desktop shell at an external GenericAgent source and restart the bridge." +commands.allow = ["set_ga_source"] + +[[permission]] +identifier = "allow-clear-ga-source" +description = "Clear the external GA source override and fall back to the bundled runtime." +commands.allow = ["clear_ga_source"] + [[permission]] identifier = "allow-shortcut-should-ask" description = "Check whether to show the first-run desktop-shortcut prompt." diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs index 69ab0e3e8..56b289cde 100644 --- a/frontends/desktop/src-tauri/src/lib.rs +++ b/frontends/desktop/src-tauri/src/lib.rs @@ -12,6 +12,10 @@ use std::os::windows::process::CommandExt; static BRIDGE_PROCESS: Mutex> = Mutex::new(None); +/// Last first-run prepare error, surfaced to the setup window (fallback.html) so the +/// user sees WHY the install failed instead of a generic "configure paths" screen. +static PREPARE_ERROR: Mutex> = Mutex::new(None); + /// Get project root (parent of frontends/) fn project_root() -> PathBuf { std::env::current_exe() @@ -306,6 +310,12 @@ fn shortcut_should_ask() -> bool { bundle_root().is_some() && read_shortcut_pref().is_none() } +/// Returns the last first-run prepare error (if any) so the setup window can display it. +#[tauri::command] +fn get_prepare_error() -> Option { + PREPARE_ERROR.lock().unwrap().clone() +} + /// Frontend reports the user's choice. Persists it and creates the shortcut when enabled. #[tauri::command] fn shortcut_decide(create: bool) { @@ -330,12 +340,48 @@ fn running_inside_app_bundle() -> bool { .unwrap_or(false) } +/// User-set external GenericAgent source directory (方案三: bundle shell + external core). +/// Returns the path only when it still looks like a GA core checkout (agentmain.py exists). +/// The bundle's own bridge/frontend/conductor are used; the saved source only becomes GA_ROOT. +/// An invalid/missing override returns None so callers fall back to the bundle runtime/app. +fn valid_ga_source_override() -> Option { + let s = read_settings() + .get("ga_source_override") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if s.is_empty() { + return None; + } + let p = PathBuf::from(&s); + if p.join("agentmain.py").exists() { + Some(p.to_string_lossy().to_string()) + } else { + None + } +} + +/// Remove a single key from the settings file (merge_settings can only add/overwrite). +fn remove_setting(key: &str) { + let mut obj = read_settings(); + obj.remove(key); + let val = serde_json::Value::Object(obj); + if let Ok(text) = serde_json::to_string_pretty(&val) { + let _ = std::fs::write(settings_path(), text); + } +} + /// Read config from settings file, or auto-discover and save. /// Self-contained bundles always prefer their own runtime/app over stale user settings, /// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout. pub fn get_or_discover_config() -> (String, String) { let path = settings_path(); + // NOTE: the external GA source override does NOT change which project_dir/bridge we run. + // 方案三: we always run the bundle's own bridge + frontend, and inject the external核 via + // the GA_ROOT env (see sanitize_bundle_env). So project_dir stays the bundle here. + if bundle_root().is_some() { let python = find_python(); let project = find_project_dir().unwrap_or_default(); @@ -427,6 +473,13 @@ fn sanitize_bundle_env(cmd: &mut Command) { // Stamp the bridge we spawn with this build's id so a later app launch can tell whether the // bridge holding :14168 is ours (see bridge_identity_matches / GET /services/identity). cmd.env("GA_BUILD_ID", env!("GA_BUILD_ID")); + // 方案三: when the user has set a valid external GA source, inject it as GA_ROOT so the + // (bundle's own) bridge + conductor import the external核 and read/write its data. When + // unset/invalid we leave GA_ROOT unset → the bridge falls back to its own bundle runtime. + match valid_ga_source_override() { + Some(src) => { cmd.env("GA_ROOT", src); } + None => { cmd.env_remove("GA_ROOT"); } + } } /// Run the offline prepare (install_windows.ps1 -Mode PrepareOnly) using bundled python + wheels. @@ -484,14 +537,38 @@ fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result< c }; - cmd.stdout(Stdio::piped()).stderr(Stdio::null()); + cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); sanitize_bundle_env(&mut cmd); #[cfg(windows)] cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW let mut child = cmd.spawn().map_err(|e| format!("failed to launch prepare: {}", e))?; + // Keep only the last N lines of a stream (pip errors are at the end; a full capture + // could be huge and the setup window only needs the tail to explain the failure). + const TAIL: usize = 40; + fn push_tail(buf: &mut Vec, line: String) { + if buf.len() >= TAIL { + buf.remove(0); + } + buf.push(line); + } + + // Drain stderr on a helper thread (both pipes must be drained concurrently or the child + // can deadlock on a full pipe buffer). pip/install errors land here. + let stderr_tail = std::sync::Arc::new(Mutex::new(Vec::::new())); + let stderr_thread = child.stderr.take().map(|err| { + let tail = std::sync::Arc::clone(&stderr_tail); + thread::spawn(move || { + for line in BufReader::new(err).lines().flatten() { + push_tail(&mut tail.lock().unwrap(), line); + } + }) + }); + // Forward the script's ASCII progress keys to the loading window, which localizes them - // (window.gaProgress maps key -> zh/en by navigator.language). + // (window.gaProgress maps key -> zh/en by navigator.language). Non-progress stdout is + // kept (tail only) so a failure can show what the script was doing when it died. + let mut stdout_tail: Vec = Vec::new(); if let Some(out) = child.stdout.take() { for line in BufReader::new(out).lines().flatten() { if let Some(key) = line.trim().strip_prefix("GAPROGRESS|") { @@ -501,13 +578,30 @@ fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result< "done" => report(90, "done"), _ => {} } + } else if !line.trim().is_empty() { + push_tail(&mut stdout_tail, line); } } } let status = child.wait().map_err(|e| format!("prepare wait failed: {}", e))?; + if let Some(h) = stderr_thread { + let _ = h.join(); + } if !status.success() { - return Err(format!("prepare exited with status {:?}", status.code())); + // Surface the real installer output, not just the exit code (shown in the setup + // window via get_prepare_error and written to prepare_error.log). + let mut msg = format!("prepare exited with status {:?}", status.code()); + let err_lines = stderr_tail.lock().unwrap(); + if !err_lines.is_empty() { + msg.push_str("\n\n--- stderr (tail) ---\n"); + msg.push_str(&err_lines.join("\n")); + } + if !stdout_tail.is_empty() { + msg.push_str("\n\n--- output (tail) ---\n"); + msg.push_str(&stdout_tail.join("\n")); + } + return Err(msg); } // Record success so later launches (and relocated copies) skip the prepare step. if let Some(marker) = prepared_marker() { @@ -726,28 +820,151 @@ fn export_mykey(content: String) -> Result, String> { } } +#[tauri::command] +fn pick_directory(title: Option) -> Option { + let mut dlg = rfd::FileDialog::new(); + if let Some(t) = title { + if !t.is_empty() { + dlg = dlg.set_title(&t); + } + } + dlg.pick_folder().map(|p| p.to_string_lossy().into_owned()) +} + +/// Stop the current bridge (ours or a stale one) and free :14168 before respawning. +fn stop_current_bridge() { + request_bridge_shutdown(); + if let Some(mut child) = BRIDGE_PROCESS.lock().unwrap().take() { + let _ = child.kill(); + let _ = child.wait(); + } + let start = Instant::now(); + while is_bridge_running() && start.elapsed() < Duration::from_secs(6) { + thread::sleep(Duration::from_millis(150)); + } + if is_bridge_running() { + force_free_bridge_port(); + let start = Instant::now(); + while is_bridge_running() && start.elapsed() < Duration::from_secs(5) { + thread::sleep(Duration::from_millis(150)); + } + } +} + +/// Restart the bridge with whatever get_or_discover_config() now resolves to, then reload the +/// webview so it reconnects. Shared by set_ga_source / clear_ga_source. +fn switch_bridge(app_handle: &tauri::AppHandle) -> Result { + stop_current_bridge(); + let (py, project) = get_or_discover_config(); + if project.is_empty() { + return Err("no GenericAgent source resolved".into()); + } + spawn_bridge_process(&py, &project)?; + if !wait_for_port(14168, Duration::from_secs(20)) { + return Err("bridge did not become ready within 20s".into()); + } + show_bridge_window(app_handle); + Ok(project) +} + +#[tauri::command] +fn get_ga_source() -> String { + read_settings() + .get("ga_source_override") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +/// Run the contract probe (frontends/ga_contract_probe.py, shipped with the bundle) against a +/// target ga_root using the bundle python. Returns Ok(()) if the核 satisfies the bridge/conductor +/// contract, or Err(message) listing what's missing / why it's incompatible. +fn probe_ga_source(dir: &str) -> Result<(), String> { + let (py, bundle_project) = get_or_discover_config(); + if py.is_empty() { + return Err("no python available to run the compatibility probe".into()); + } + let probe = PathBuf::from(&bundle_project).join("frontends").join("ga_contract_probe.py"); + if !probe.exists() { + // No probe shipped → skip the check rather than block (backward compatible). + return Ok(()); + } + let mut cmd = Command::new(&py); + cmd.arg(&probe).arg(dir); + sanitize_bundle_env(&mut cmd); + // If switching from one external core to another, validate the candidate core, not the + // previously saved GA_ROOT that sanitize_bundle_env may have injected. + cmd.env("GA_ROOT", dir); + #[cfg(windows)] + cmd.creation_flags(0x08000000); + let out = cmd.output().map_err(|e| format!("probe failed to run: {}", e))?; + let stdout = String::from_utf8_lossy(&out.stdout); + // The probe prints a JSON line: {"ok":bool,"missing":[..],"error":".."}. + if let Some(line) = stdout.lines().rev().find(|l| l.trim_start().starts_with('{')) { + if let Ok(v) = serde_json::from_str::(line.trim()) { + if v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false) { + return Ok(()); + } + let missing = v.get("missing") + .and_then(|m| m.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_str()).collect::>().join(", ")) + .unwrap_or_default(); + let err = v.get("error").and_then(|e| e.as_str()).unwrap_or(""); + let mut msg = String::from("this GenericAgent core is not compatible"); + if !missing.is_empty() { msg = format!("{}: missing {}", msg, missing); } + if !err.is_empty() { msg = format!("{} ({})", msg, err); } + return Err(msg); + } + } + Err(format!("compatibility probe returned no verdict: {}", + String::from_utf8_lossy(&out.stderr).lines().last().unwrap_or(""))) +} + +#[tauri::command] +fn set_ga_source(app_handle: tauri::AppHandle, dir: String) -> Result { + let p = PathBuf::from(&dir); + if !p.join("agentmain.py").exists() { + return Err("not a GenericAgent source: agentmain.py not found".into()); + } + // 方案三: we do NOT copy files into 本体. We run the bundle's own bridge/frontend and point + // its ga_root at this external核 via GA_ROOT. Verify the核 satisfies the contract first so a + // stale/incompatible核 fails up front with a clear message instead of a runtime crash. + probe_ga_source(&dir)?; + merge_settings(serde_json::json!({ "ga_source_override": dir })); + switch_bridge(&app_handle) +} + +#[tauri::command] +fn clear_ga_source(app_handle: tauri::AppHandle) -> Result { + remove_setting("ga_source_override"); + switch_bridge(&app_handle) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let args: Vec = std::env::args().collect(); let no_autostart = args.iter().any(|a| a == "--no-autostart"); let dev_mode = args.iter().any(|a| a == "--dev"); - let project_dir = find_project_dir().unwrap_or_default(); - let needs_prepare = needs_first_run_prepare(&project_dir); + // Resolve the effective config once. project_dir is ALWAYS the bundle's own (方案三: we run + // our own bridge/frontend); the external核, if any, is injected via GA_ROOT in + // sanitize_bundle_env. Identity/takeover must compare against the EFFECTIVE ga_root (what the + // bridge will report), not the bundle script dir. + let (eff_py, eff_project) = get_or_discover_config(); + let effective_ga_root = valid_ga_source_override().unwrap_or_else(|| eff_project.clone()); + let needs_prepare = needs_first_run_prepare(&eff_project); - takeover_stale_bridge(&project_dir); + takeover_stale_bridge(&effective_ga_root); let bridge_ok = is_bridge_running(); let mut spawned_bridge = false; // Skip the early spawn when a first-run prepare is required (no venv yet); // the setup thread prepares the env first and then starts the bridge. if !bridge_ok && !no_autostart && !needs_prepare { - // Try to start bridge with saved/discovered config - let (py_str, dir_str) = get_or_discover_config(); - let dir = PathBuf::from(&dir_str); + let dir = PathBuf::from(&eff_project); let script = dir.join("frontends").join("desktop_bridge.py"); if script.exists() { - let mut cmd = Command::new(&py_str); + let mut cmd = Command::new(&eff_py); cmd.arg(&script).current_dir(&dir); sanitize_bundle_env(&mut cmd); #[cfg(windows)] @@ -767,7 +984,7 @@ pub fn run() { let _ = w.set_focus(); } })) - .invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide]) + .invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, pick_directory, get_ga_source, set_ga_source, clear_ga_source, shortcut_should_ask, shortcut_decide, get_prepare_error]) .setup(move |app| { // Show the loading window immediately so the first-run prepare isn't a blank screen. // The window starts on loading.html (a local page), so no "connection refused" flash. @@ -776,7 +993,7 @@ pub fn run() { } let handle = app.handle().clone(); - let project_dir = project_dir.clone(); + let project_dir = eff_project.clone(); thread::spawn(move || { // Progress reporter: push status into the loading window (window.gaProgress). let main_win = handle.get_webview_window("main"); @@ -798,6 +1015,12 @@ pub fn run() { report(5, "start"); if let Err(e) = run_offline_prepare(&project_dir, &report) { eprintln!("[tauri] first-run prepare failed: {}", e); + // Persist the error for the setup window and to a log file next to the + // bundle, so the failure is explicit instead of a silent config screen. + *PREPARE_ERROR.lock().unwrap() = Some(e.clone()); + if let Some(root) = bundle_root() { + let _ = std::fs::write(root.join("prepare_error.log"), &e); + } if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.show(); } if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); } return; diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js index b6ad62bab..0676819a8 100644 --- a/frontends/desktop/static/app.js +++ b/frontends/desktop/static/app.js @@ -216,6 +216,9 @@ let bridgeUiOffline = false; case 'services/bridge/exit': return http('/services/bridge/exit', { method: 'POST' }); case 'services/mykey/get': return http('/services/mykey'); case 'services/mykey/save': return http('/services/mykey', { method: 'POST', body: params || {} }); + case 'services/conductor/model/get': return http('/services/conductor/model'); + case 'services/conductor/model/save': return http('/services/conductor/model', { method: 'POST', body: params || {} }); + case 'memory/import': return http('/memory/import', { method: 'POST', body: params || {} }); case 'app/path/selectGaRoot': return http('/config'); case 'list_continuable_sessions': return { sessions: [] }; case 'restore_session': throw new Error('restore_session is not implemented in web2 bridge'); @@ -282,6 +285,12 @@ let bridgeUiOffline = false; getServicePanel: () => rpc('services/panel', {}), getMykeyContent: () => rpc('services/mykey/get', {}), saveMykeyContent: (content) => rpc('services/mykey/save', { content }), + importMemory: (sourceDir) => rpc('memory/import', { sourceDir }), + getGaSource: () => tauriInvoke('get_ga_source'), + setGaSource: (dir) => tauriInvoke('set_ga_source', { dir }), + clearGaSource: () => tauriInvoke('clear_ga_source'), + getConductorModel: () => rpc('services/conductor/model/get', {}), + saveConductorModel: (llmNo) => rpc('services/conductor/model/save', { llmNo }), tauriInvoke, setBridgeUiOffline: (offline) => { bridgeUiOffline = !!offline; }, pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }), @@ -302,356 +311,9 @@ let bridgeUiOffline = false; })(); /* ═══════════════ i18n ═══════════════ */ -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.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.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.collapse': '收起', 'plan.expand': '展开', 'plan.details': '详情', - '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': '模型配置已导出', - 'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常', - 'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出', - 'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送', - '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.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.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.collapse': 'Collapse', 'plan.expand': 'Expand', 'plan.details': 'Details', - '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', - 'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error', - 'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit', - 'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send', - '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 LANGS = ['zh', 'en']; -const STORE = { lang: 'ga_lang', theme: 'ga_theme', appearance: 'ga_appearance', plain: 'ga_plain', fontSize: 'ga_font_size', llmNo: 'ga_llm_no' }; +const I18N = window.GA_I18N?.dict || { zh: {}, en: {} }; +const LANGS = window.GA_I18N?.languages || ['zh', 'en']; +const STORE = { lang: 'ga_lang', theme: 'ga_theme', appearance: 'ga_appearance', plain: 'ga_plain', fontSize: 'ga_font_size' }; const APPEARANCE_IDS = ['light', 'dark']; const CHAT_FONT_MIN = 10; const CHAT_FONT_MAX = 20; @@ -694,7 +356,6 @@ function syncBootCache() { localStorage.setItem(STORE.fontSize, String(chatFontSize)); if (plainUi) localStorage.setItem(STORE.plain, '1'); else localStorage.removeItem(STORE.plain); - localStorage.setItem(STORE.llmNo, String(state.llmNo)); } async function persistUiPrefs() { try { @@ -718,7 +379,14 @@ async function bridgeFetch(path, opts = {}) { if (!res.ok) throw new Error(data.error || data.message || res.statusText); return data; } -function t(key) { return (I18N[lang] && I18N[lang][key]) || (I18N.zh[key]) || key; } +function t(key, vars) { + if (window.GA_I18N?.t) return window.GA_I18N.t(lang, key, vars); + let text = (I18N[lang] && I18N[lang][key]) || (I18N.zh && I18N.zh[key]) || key; + if (vars) { + for (const [name, value] of Object.entries(vars)) text = text.replaceAll(`{${name}}`, String(value)); + } + return text; +} window.gaT = t; document.addEventListener('collab:running-count', e => { const b = document.getElementById('collab-badge'); @@ -731,6 +399,22 @@ function optionalPh(key) { const sep = (lang === 'en') ? ', ' : ','; return `${t('common.optional')}${sep}${t(key)}`; } +function setTooltip(el, text) { + if (!el) return; + const value = String(text || '').trim(); + el.removeAttribute('title'); + if (el.hasAttribute('data-no-tooltip')) { + delete el.dataset.tooltip; + if (value) el.setAttribute('aria-label', value); + return; + } + if (value) { + el.dataset.tooltip = value; + if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', value); + } else { + delete el.dataset.tooltip; + } +} function applyI18n() { document.documentElement.lang = (lang === 'en') ? 'en' : 'zh-CN'; document.title = t('app.title'); @@ -741,7 +425,8 @@ function applyI18n() { if (el.isContentEditable) el.setAttribute('data-ph', val); // contenteditable 用 :empty::before 显示 else el.setAttribute('placeholder', val); }); - document.querySelectorAll('[data-i18n-title]').forEach(el => { el.setAttribute('title', t(el.dataset.i18nTitle)); }); + document.querySelectorAll('[data-i18n-title]').forEach(el => { setTooltip(el, t(el.dataset.i18nTitle)); }); + migrateNativeTooltips(); renderLangList(); // 语言切换后重算激活模型 chip 文案;若当前会话已有渠道组运行态模型,保留运行态而非退回首选项 const _ap = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo); @@ -1003,6 +688,79 @@ bindClick('export-mykey-btn', async (e) => { showChanToast(t('err.mykeyExport'), err.message || String(err), 'err'); } }); +// 记忆/session 导入与外接 GA 源码。 +async function importMemoryFromDir() { + let sourceDir = ''; + if (window.__TAURI__?.core?.invoke) { + sourceDir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.memoryPickTitle') }); + } else { + sourceDir = window.prompt(t('sys.memoryImportPrompt')) || ''; + } + if (!sourceDir) return; + const res = await window.ga.importMemory(sourceDir); + if (!res || res.ok === false) throw new Error((res && res.error) || 'import failed'); + const detail = `memory: ${res.memoryCopied}, model_responses: ${res.responsesCopied}` + + (res.responsesSkipped ? ` (skip ${res.responsesSkipped})` : '') + + `, ${t('sys.memorySessions')}: ${res.sessionsAdded || 0}` + + (res.backupDir ? `\n${t('sys.memoryImportBackup')}: ${res.backupDir}` : ''); + if (res.sessionsAdded) { + try { await loadSessions(); renderSessionList(); } catch (_) {} + } + showChanToast(t('sys.memoryImported'), detail, 'ok'); +} +bindClick('import-memory-btn', async (e) => { + e.stopPropagation(); + try { + await importMemoryFromDir(); + } catch (err) { + showChanToast(t('err.memoryImport'), err.message || String(err), 'err'); + } +}); +const gaSourceCurrentEl = document.getElementById('ga-source-current'); +const gaSourceClearBtn = document.getElementById('ga-source-clear-btn'); +async function refreshGaSource() { + if (!window.__TAURI__?.core?.invoke) return; + let cur = ''; + try { cur = await window.ga.getGaSource(); } catch (_) { cur = ''; } + if (gaSourceCurrentEl) { + if (cur) { + gaSourceCurrentEl.textContent = `${t('set.gaSourceCurrent')}: ${cur}`; + gaSourceCurrentEl.hidden = false; + } else { + gaSourceCurrentEl.hidden = true; + } + } + if (gaSourceClearBtn) gaSourceClearBtn.hidden = !cur; +} +bindClick('ga-source-btn', async (e) => { + e.stopPropagation(); + if (!window.__TAURI__?.core?.invoke) { + showChanToast(t('err.gaSourceDesktopOnly'), '', 'err'); + return; + } + try { + const dir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.gaSourcePickTitle') }); + if (!dir) return; + showChanToast(t('sys.gaSourceSwitching'), dir, 'ok'); + const project = await window.ga.setGaSource(dir); + await refreshGaSource(); + showChanToast(t('sys.gaSourceSet'), project || dir, 'ok'); + } catch (err) { + showChanToast(t('err.gaSourceSet'), err.message || String(err), 'err'); + } +}); +bindClick('ga-source-clear-btn', async (e) => { + e.stopPropagation(); + try { + showChanToast(t('sys.gaSourceSwitching'), '', 'ok'); + await window.ga.clearGaSource(); + await refreshGaSource(); + showChanToast(t('sys.gaSourceCleared'), '', 'ok'); + } catch (err) { + showChanToast(t('err.gaSourceSet'), err.message || String(err), 'err'); + } +}); +refreshGaSource(); // 侧边栏「快速接入」:点击官方模型按钮 → 打开预填好的添加模型表单 const pqEl = document.getElementById('provider-quickstart'); if (pqEl) pqEl.addEventListener('click', (e) => { @@ -1728,13 +1486,14 @@ function postRenderEnhance(containerEl) { const state = { sessions: new Map(), activeId: null, bridgeReady: false, llmNo: 0, modelProfiles: [], modelName: null, + conductorLlmNo: null, conductorModelName: null, runtime: new Map(), pendingFiles: [], fileSeq: 0, }; function rt(sess) { let r = state.runtime.get(sess.id); - if (!r) { r = { polling:false, busy:false, lastId:0, seen:new Set(), draftEl:null, draftSegs:null, draftTurn:0, taskStartedAt:null, taskEndedAt:null, taskTimerId:null, planCompleteAt:null, planLostAt:null, planHoldItems:[], planLastPayload:null, planLastComplete:false, planHideTimer:null, planDismissedComplete:false, planCollapsed:false, planShowAll:false }; state.runtime.set(sess.id, r); } + if (!r) { r = { polling:false, busy:false, lastId:0, seen:new Set(), draftEl:null, draftSegs:null, draftTurn:0, taskStartedAt:null, taskEndedAt:null, taskTimerId:null, planCompleteAt:null, planLostAt:null, planHoldItems:[], planLastPayload:null, planVisiblePayload:null, planLastComplete:false, planHideTimer:null, planDismissedComplete:false, planCollapsed:false, planShowAll:false }; state.runtime.set(sess.id, r); } return r; } const activeSess = () => state.sessions.get(state.activeId) || null; @@ -1756,7 +1515,8 @@ async function loadSessions() { state.sessions.set(s.id, { id: s.id, bridgeSessionId: s.id, title: s.title, messages: [], untitled: s.untitled ?? true, - pinned: s.pinned ?? false, lastActiveTs: s.updatedAt || s.createdAt + pinned: s.pinned ?? false, lastActiveTs: s.updatedAt || s.createdAt, + llmNo: s.model && s.model.llmNo != null ? s.model.llmNo : null }); } // 刷新后固定恢复「上次正在看的会话」(前端持久化的 ga_active),而不是 bridge 的 @@ -1781,12 +1541,8 @@ const MIN_MSG_LOADING_MS = 450; const HYDRATE_LOADING_TIMEOUT_MS = 10000; const POLL_MSG_LIMIT = 200; const PLAN_LOST_GRACE_MS = 1500; // tuiapp_v2._PLAN_LOST_GRACE_SEC -const PLAN_COMPLETE_GRACE_MS = 3000; // tuiapp_v2._PLAN_GRACE_SEC +const PLAN_DISMISS_STORE = 'ga_plan_dismissed_v1'; -function isPlanPresetPrompt(text) { - const p = String(text || '').toLowerCase(); - return p.includes('plan_sop') || p.includes('plan 模式') || p.includes('plan mode'); -} let _submitInFlight = false; const runToggle = document.getElementById('run-toggle'); const chatStatus = pageStatusBar(runToggle); @@ -1860,6 +1616,43 @@ function planTpl(tpl, v) { return String(tpl || '').replace(/\{(\w+)\}/g, (_, k) => (v[k] != null ? String(v[k]) : `{${k}}`)); } +function planItemsSignature(items) { + return (items || []) + .map(it => `${String(it.status || '')}\u001e${String(it.content || '')}`) + .join('\u001f'); +} + +function planDismissId(sess, plan) { + if (!sess || !plan) return ''; + const total = plan.total ?? (plan.items || []).length; + const done = plan.done ?? (plan.items || []).filter(it => it.status === 'done').length; + return `${sess.id || sess.bridgeSessionId || 'session'}\u001d${done}/${total}\u001d${planItemsSignature(plan.items || [])}`; +} + +function readPlanDismissed() { + try { + const raw = localStorage.getItem(PLAN_DISMISS_STORE); + const parsed = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (_) { + return {}; + } +} + +function isPlanDismissed(sess, plan) { + if (!plan?.complete) return false; + const id = planDismissId(sess, plan); + return !!(id && readPlanDismissed()[id]); +} + +function markPlanDismissed(sess, plan) { + const id = planDismissId(sess, plan); + if (!id) return; + const dismissed = readPlanDismissed(); + dismissed[id] = Date.now(); + try { localStorage.setItem(PLAN_DISMISS_STORE, JSON.stringify(dismissed)); } catch (_) {} +} + let planPollTimer; function syncPlanPollTimer() { const on = !!(activeSess()?.bridgeSessionId && state.bridgeReady); @@ -1880,21 +1673,12 @@ function clearPlanGrace(r) { r.planCompleteAt = r.planLostAt = null; r.planHoldItems = []; r.planLastPayload = null; + r.planVisiblePayload = null; r.planLastComplete = false; r.planDismissedComplete = false; if (r.planHideTimer) { clearTimeout(r.planHideTimer); r.planHideTimer = null; } } -function schedulePlanCompleteDismiss(sess) { - const r = rt(sess); - if (r.planHideTimer) clearTimeout(r.planHideTimer); - r.planHideTimer = setTimeout(() => { - r.planHideTimer = null; - r.planDismissedComplete = true; - if (isActive(sess)) refreshPlanBar(null); - }, PLAN_COMPLETE_GRACE_MS); -} - /** tuiapp_v2._refresh_planbar:用 runtime 里缓存的 items / placeholder 重绘 */ function refreshPlanBarFromRuntime(sess) { const r = rt(sess); @@ -1909,11 +1693,6 @@ function refreshPlanBarFromRuntime(sess) { refreshPlanBar(null); return; } - if (r.planCompleteAt != null && Date.now() - r.planCompleteAt >= PLAN_COMPLETE_GRACE_MS) { - r.planDismissedComplete = true; - refreshPlanBar(null); - return; - } if (!items.length) { if (lp?.active && lp.placeholder) { refreshPlanBar(lp); @@ -1974,7 +1753,9 @@ function applyPlanPayload(sess, raw) { const wasComplete = r.planLastComplete; if (nowComplete && !wasComplete) { r.planCompleteAt = now; - schedulePlanCompleteDismiss(sess); + r.planDismissedComplete = isPlanDismissed(sess, { ...raw, items: items.length ? items : r.planHoldItems }); + } else if (nowComplete) { + r.planDismissedComplete = isPlanDismissed(sess, { ...raw, items: items.length ? items : r.planHoldItems }); } else if (!nowComplete) { r.planCompleteAt = null; r.planDismissedComplete = false; @@ -2052,16 +1833,23 @@ function bindPlanCardUiOnce() { if (!sess) return; const r = rt(sess); const payload = r.planLastPayload; - if (!payload?.active) return; + const visiblePayload = r.planVisiblePayload || payload; + if (e.target.closest('[data-plan-dismiss]')) { + markPlanDismissed(sess, visiblePayload); + r.planDismissedComplete = true; + refreshPlanBar(null); + return; + } + if (!payload?.active && !visiblePayload?.active) return; if (e.target.closest('[data-plan-expand]')) { r.planCollapsed = false; - refreshPlanBar(payload); + refreshPlanBar(visiblePayload); } else if (e.target.closest('[data-plan-collapse]')) { r.planCollapsed = true; - refreshPlanBar(payload); + refreshPlanBar(visiblePayload); } else if (e.target.closest('[data-plan-details]')) { r.planShowAll = !r.planShowAll; - refreshPlanBar(payload); + refreshPlanBar(visiblePayload); } }); } @@ -2073,6 +1861,7 @@ function refreshPlanBar(plan) { planBarEl.hidden = true; planBarEl.replaceChildren(); planBarEl.className = 'plan-card'; + planBarEl._planRenderSig = ''; return; } const sess = activeSess(); @@ -2087,6 +1876,21 @@ function refreshPlanBar(plan) { plan.complete ? 'plan-card--complete' : '', plan.placeholder ? 'plan-card--placeholder' : '', ].filter(Boolean).join(' '); + const planItemsSig = planItemsSignature(plan.items || []); + if (sess && plan.complete && isPlanDismissed(sess, plan)) { + r.planDismissedComplete = true; + planBarEl.hidden = true; + planBarEl.replaceChildren(); + planBarEl.className = 'plan-card'; + planBarEl._planRenderSig = ''; + return; + } + r.planVisiblePayload = plan; + const renderSig = JSON.stringify([ + lang, mod, stepText, done, total, !!r.planShowAll, plan.pathHint || '', planItemsSig + ]); + if (!planBarEl.hidden && planBarEl.className === mod && planBarEl._planRenderSig === renderSig) return; + planBarEl._planRenderSig = renderSig; planBarEl.hidden = false; planBarEl.className = mod; @@ -2099,11 +1903,17 @@ function refreshPlanBar(plan) { btn.dataset.planExpand = '1'; const dot = document.createElement('span'); dot.className = 'plan-status-dot'; + const ico = document.createElement('span'); + ico.className = 'plan-capsule-ic'; + ico.innerHTML = GA_ICON('listChecks'); const txt = document.createElement('span'); txt.className = 'plan-capsule-text'; if (cap.step) txt.innerHTML = `${escapeHtml(cap.tag)} · ${escapeHtml(cap.step)}`; else txt.textContent = cap.tag; - btn.append(dot, txt); + const chev = document.createElement('span'); + chev.className = 'plan-capsule-chevron'; + chev.innerHTML = GA_ICON('caretRight'); + btn.append(dot, ico, txt, chev); planBarEl.append(btn); return; } @@ -2127,11 +1937,19 @@ function refreshPlanBar(plan) { } const actions = document.createElement('div'); actions.className = 'plan-head-actions'; + if (plan.complete) { + const dismissBtn = document.createElement('button'); + dismissBtn.type = 'button'; + dismissBtn.className = 'plan-btn'; + dismissBtn.dataset.planDismiss = '1'; + dismissBtn.innerHTML = `${GA_ICON('x', 'plan-btn-ic')}${escapeHtml(t('plan.dismiss'))}`; + actions.append(dismissBtn); + } const collapseBtn = document.createElement('button'); collapseBtn.type = 'button'; collapseBtn.className = 'plan-btn'; collapseBtn.dataset.planCollapse = '1'; - collapseBtn.textContent = t('plan.collapse'); + collapseBtn.innerHTML = `${GA_ICON('caretDown', 'plan-btn-ic')}${escapeHtml(t('plan.fold'))}`; actions.append(collapseBtn); head.append(actions); frag.append(head); @@ -2191,7 +2009,7 @@ function refreshPlanBar(plan) { det.type = 'button'; det.className = 'plan-btn'; det.dataset.planDetails = '1'; - det.textContent = r.planShowAll ? t('plan.collapse') : t('plan.details'); + det.innerHTML = `${GA_ICON(r.planShowAll ? 'caretDown' : 'caretRight', 'plan-btn-ic')}${escapeHtml(r.planShowAll ? t('plan.collapse') : t('plan.details'))}`; foot.append(det); } if (foot.childNodes.length) frag.append(foot); @@ -2780,7 +2598,7 @@ function renderSessionList() { `
` + `
${pinSvg}${escapeHtml(displayTitle(sess))}
` + `
${busy ? t('status.running') : t('status.idle')}
` + - ``; + ``; convListEl.appendChild(item); } } @@ -2860,6 +2678,15 @@ function setActiveSession(id) { if (id) localStorage.setItem('ga_active', id); // 持久化当前会话,刷新后固定恢复它 const sess = state.sessions.get(id); if (!sess) return; + // 切会话:回显该会话绑定的模型(后端权威)。未绑定(null)则保持当前全局默认显示。 + if (sess.llmNo != null && sess.llmNo !== state.llmNo) { + state.llmNo = sess.llmNo; + state.liveModel = null; + const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === sess.llmNo); + if (p) state.modelName = modelDisplayName(p); + updateModelChip(); + renderSettingsModels(); + } if (msgsEl) msgsEl.innerHTML = ''; const r = rt(sess); r.draftEl = null; @@ -2895,11 +2722,48 @@ async function closeSession(id) { const convMenu = document.getElementById('conv-menu'); let menuTargetId = null; +function hideConvMenu() { + if (!convMenu) return; + convMenu.hidden = true; + menuTargetId = null; +} +function positionConvMenu(anchor) { + if (!convMenu || !anchor) return; + convMenu.hidden = false; + const rect = anchor.getBoundingClientRect(); + const pad = 8; + const gap = 4; + const w = convMenu.offsetWidth; + const h = convMenu.offsetHeight; + const left = Math.max(pad, Math.min(window.innerWidth - w - pad, rect.right - w)); + const below = rect.bottom + gap; + const above = rect.top - h - gap; + const top = below + h <= window.innerHeight - pad ? below : Math.max(pad, above); + convMenu.style.left = `${left}px`; + convMenu.style.top = `${top}px`; +} +function isSessionRowInteractiveTarget(target) { + const el = target instanceof Element ? target : null; + return !!el?.closest?.( + '.ci-more, .ci-rename-input, button, input, textarea, select, a, [contenteditable="true"], [data-no-session-select]' + ); +} convListEl.addEventListener('click', (e) => { const more = e.target.closest('.ci-more'); if (more) { e.stopPropagation(); - menuTargetId = more.closest('.conv-item').dataset.id; + hideTooltip(); + const item = more.closest('.conv-item'); + const nextTargetId = item?.dataset.id || null; + if (!nextTargetId) { + hideConvMenu(); + return; + } + if (!convMenu.hidden && menuTargetId === nextTargetId) { + hideConvMenu(); + return; + } + menuTargetId = nextTargetId; // 根据当前会话置顶状态切菜单文案:置顶 / 取消置顶 const tgt = state.sessions.get(menuTargetId); const pinSpan = convMenu.querySelector('[data-act="pin"] [data-i18n]'); @@ -2908,12 +2772,10 @@ convListEl.addEventListener('click', (e) => { pinSpan.setAttribute('data-i18n', k); pinSpan.textContent = t(k); } - convMenu.hidden = false; - const rect = more.getBoundingClientRect(); - convMenu.style.top = (rect.bottom + 4) + 'px'; - convMenu.style.left = (rect.right - convMenu.offsetWidth) + 'px'; + positionConvMenu(more); return; } + if (isSessionRowInteractiveTarget(e.target)) return; const it = e.target.closest('.conv-item'); if (it && it.dataset.id) { setActiveSession(it.dataset.id); @@ -2944,14 +2806,16 @@ convMenu.addEventListener('click', (e) => { patchSession(sess, { pinned: sess.pinned }); renderSessionList(); } else if (sess && act === 'rename') { - convMenu.hidden = true; + hideConvMenu(); const item = convListEl.querySelector(`.conv-item[data-id="${sess.id}"]`); if (!item) return; const titleEl = item.querySelector('.ci-title'); if (!titleEl) return; const oldTitle = sess.title || ''; + item.classList.add('renaming'); const inp = document.createElement('input'); inp.className = 'ci-rename-input'; + inp.dataset.noSessionSelect = '1'; inp.maxLength = 50; inp.value = oldTitle; titleEl.replaceWith(inp); @@ -2984,9 +2848,9 @@ convMenu.addEventListener('click', (e) => { } else if (sess && act === 'del') { closeSession(sess.id); } - convMenu.hidden = true; + hideConvMenu(); }); -document.addEventListener('click', () => { convMenu.hidden = true; }); +document.addEventListener('click', hideConvMenu); newConvBtn.addEventListener('click', (e) => { e.preventDefault(); newSession(); }); /* ═══════════════ 轮询 + 流式 ═══════════════ */ @@ -3107,13 +2971,32 @@ function applyPollResult(sess, result) { return busy; } -/** 渠道组随故障转移变化时,用运行态当前子模型刷新 chip(非渠道组/无 agent 时不动,保持静态显示) */ +/** 用后端运行态模型刷新 chip + 选择器。后端是权威: + * - 同步该会话绑定的 llmNo(live.llmNo)到 sess/state,切回会话能正确回显; + * - mixin: 显示「渠道组(当前子模型)」,跟随故障转移; + * - native: 显示后端真正在用的模型名(live.current),而非前端静态选择。 */ function applyLiveModel(live, sess = activeSess()) { + if (!live) return; + // 回写该会话绑定的模型下标(权威来自后端)。 + if (live.llmNo != null && sess) sess.llmNo = live.llmNo; + if (isActive(sess) && live.llmNo != null && state.llmNo !== live.llmNo) { + state.llmNo = live.llmNo; + renderSettingsModels(); + } const selected = (state.modelProfiles || []).find(p => (p.id ?? 0) === state.llmNo); - if (!selected || selected.kind !== 'mixin' || !live || !live.isMixin || !live.current) return; - state.liveModel = { ...live, sessionId: sess?.id || state.activeId }; - const label = `${t('model.aggregationShort')}${lang === 'en' ? ' (' : '('}${profileLabel(live.current) || live.current}${lang === 'en' ? ')' : ')'}`; - if (state.modelName !== label) { state.modelName = label; updateModelChip(); } + if (!isActive(sess)) return; + if (selected && selected.kind === 'mixin') { + if (!live.isMixin || !live.current) return; + state.liveModel = { ...live, sessionId: sess?.id || state.activeId }; + const label = `${t('model.aggregationShort')}${lang === 'en' ? ' (' : '('}${profileLabel(live.current) || live.current}${lang === 'en' ? ')' : ')'}`; + if (state.modelName !== label) { state.modelName = label; updateModelChip(); } + return; + } + // native: chip 跟随后端实际模型名(没拿到运行态时回退到选中 profile 的静态名) + state.liveModel = null; + const label = (live.current ? (profileLabel(live.current) || live.current) : null) + || (selected ? modelDisplayName(selected) : null); + if (label && state.modelName !== label) { state.modelName = label; updateModelChip(); } } /** hydrate 批量灌历史,避免逐条 appendMessage 触发全量重绘 */ @@ -3167,9 +3050,19 @@ async function pollSession(sess) { try { const result = await fetchSessionPoll(sess); consecutiveErrors = 0; - const busy = applyPollResult(sess, result); + let busy = applyPollResult(sess, result); if (busy) await new Promise(z => setTimeout(z, 500)); else { + const hasFinalAssistant = (result.messages || []).some(m => m && m.role === 'assistant'); + if (r.draftEl && !hasFinalAssistant) { + await new Promise(z => setTimeout(z, 150)); + const finalResult = await fetchSessionPoll(sess); + busy = applyPollResult(sess, finalResult); + if (busy) { + await new Promise(z => setTimeout(z, 500)); + continue; + } + } if (r.draftEl) { r.draftEl.remove(); r.draftEl = null; r.draftSegs = null; r.draftTurn = 0; } resetTypewriterState(r); break; @@ -3301,16 +3194,6 @@ async function sendPrompt(text) { const previewFiles = usedFiles.filter(f => !f.isImage).map(f => ({ id: 'f-' + f.sid, name: f.name, path: f.path })); if (previewFiles.length) userMsg.files = previewFiles; sess.messages.push(userMsg); appendMessage(sess, userMsg); - if (isPlanPresetPrompt(text)) { - const pr = rt(sess); - pr.planCollapsed = false; - pr.planShowAll = false; - const sidHint = (sess.bridgeSessionId || sess.id || 'sess').replace(/\//g, '_'); - applyPlanPayload(sess, { - active: true, placeholder: true, done: 0, total: 0, complete: false, - step: '', pathHint: `plan_${sidHint}/plan.md`, items: [], - }); - } sess.lastActiveTs = Date.now(); // 仿 TUI:不再从首条消息自动改名 —— 标题在 newSession 时已设为 agent-N, // 之后只接受用户手动 rename。 @@ -3331,12 +3214,16 @@ async function sendPrompt(text) { localStorage.setItem('ga_active', sess.id); // 会话 id 因 bridge 重建而变更,同步持久化 } } - const res = await window.ga.rpc('session/prompt', { sessionId: sid, prompt: composedPrompt, display: text, llmNo: state.llmNo, + const res = await window.ga.rpc('session/prompt', { sessionId: sid, prompt: composedPrompt, display: text, files: previewFiles, imageMetas: previewImgs.map(im => ({ name: im.name, path: im.path })) }); if (res?.error) throw new Error(res.error.message || res.error); removeUsedPendingFiles(usedFiles); const uid = Number(res.userMessageId || res.result?.userMessageId || 0); - if (uid) { r.seen.add(uid); r.lastId = Math.max(r.lastId, uid); } + if (uid) { + userMsg.id = uid; + r.seen.add(uid); + r.lastId = Math.max(r.lastId, uid); + } planPoll(sess); pollSession(sess); return true; @@ -3364,9 +3251,10 @@ async function submitInput() { if (_submitInFlight) return; let text = composerText('chat'); if (!text.trim()) return; - if (text.trim().startsWith('/')) { + const slashName = slashCommandName(text); + if (slashName) { inputEl.innerHTML = ''; - handleSlash(text.trim()); + handleSlash(slashName); return; } if (text.length > 20000) { @@ -3415,8 +3303,103 @@ function showToast(text) { clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.classList.remove('show'), 1800); } -async function handleSlash(cmd) { - const name = cmd.slice(1).split(/\s+/)[0]; + +let tooltipEl = null; +let tooltipTarget = null; +let tooltipTimer = null; +function ensureTooltipEl() { + if (!tooltipEl) { + tooltipEl = document.createElement('div'); + tooltipEl.className = 'ga-tooltip'; + tooltipEl.hidden = true; + document.body.appendChild(tooltipEl); + } + return tooltipEl; +} +function migrateNativeTooltips(root = document) { + const nodes = []; + if (root?.nodeType === Node.ELEMENT_NODE && root.hasAttribute?.('title')) nodes.push(root); + root?.querySelectorAll?.('[title]').forEach(el => nodes.push(el)); + nodes.forEach(el => { + const value = el.getAttribute('title') || ''; + setTooltip(el, value); + }); +} +function tooltipNodeFromEventTarget(target) { + const el = target?.closest?.('[data-tooltip],[title]'); + if (el?.hasAttribute('title')) { + const value = el.getAttribute('title') || ''; + setTooltip(el, value); + } + return el?.dataset?.tooltip ? el : null; +} +function positionTooltip() { + if (!tooltipEl || !tooltipTarget || tooltipEl.hidden) return; + const r = tooltipTarget.getBoundingClientRect(); + const pad = 8; + const tw = tooltipEl.offsetWidth; + const th = tooltipEl.offsetHeight; + const left = Math.max(pad, Math.min(window.innerWidth - tw - pad, r.left + r.width / 2 - tw / 2)); + const topAbove = r.top - th - 8; + const top = topAbove >= pad ? topAbove : Math.min(window.innerHeight - th - pad, r.bottom + 8); + tooltipEl.style.left = `${left}px`; + tooltipEl.style.top = `${Math.max(pad, top)}px`; +} +function showTooltip(target) { + const text = target?.dataset?.tooltip || ''; + if (!text) return; + tooltipTarget = target; + clearTimeout(tooltipTimer); + tooltipTimer = setTimeout(() => { + const el = ensureTooltipEl(); + el.textContent = text; + el.hidden = false; + el.classList.add('show'); + positionTooltip(); + }, 180); +} +function hideTooltip() { + clearTimeout(tooltipTimer); + tooltipTimer = null; + tooltipTarget = null; + if (tooltipEl) { + tooltipEl.classList.remove('show'); + tooltipEl.hidden = true; + } +} +function initCustomTooltips() { + migrateNativeTooltips(); + document.addEventListener('pointerover', (e) => { + const target = tooltipNodeFromEventTarget(e.target); + if (target) showTooltip(target); + }, true); + document.addEventListener('pointerout', (e) => { + if (!tooltipTarget) return; + const from = e.target?.closest?.('[data-tooltip]'); + if (from === tooltipTarget && !tooltipTarget.contains(e.relatedTarget)) hideTooltip(); + }, true); + document.addEventListener('focusin', (e) => { + const target = tooltipNodeFromEventTarget(e.target); + if (target) showTooltip(target); + }, true); + document.addEventListener('focusout', hideTooltip, true); + window.addEventListener('scroll', hideTooltip, true); + window.addEventListener('resize', hideTooltip); + new MutationObserver((records) => { + for (const rec of records) { + if (rec.type === 'attributes' && rec.attributeName === 'title') migrateNativeTooltips(rec.target); + rec.addedNodes?.forEach(node => migrateNativeTooltips(node)); + } + }).observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['title'] }); +} +const SLASH_COMMANDS = new Set(['help', 'new', 'clear', 'stop', 'settings']); +function slashCommandName(text) { + const trimmed = String(text || '').trim(); + if (!trimmed.startsWith('/')) return ''; + const token = trimmed.slice(1).split(/\s+/)[0]; + return SLASH_COMMANDS.has(token) ? token : ''; +} +async function handleSlash(name) { switch (name) { case 'help': showSystem(t('slash.help')); break; case 'new': await newSession(); break; @@ -3468,7 +3451,7 @@ document.querySelectorAll('.feature-grid').forEach(grid => { function updateModelChip() { const name = state.modelName || ''; if (modelNameEl) modelNameEl.textContent = name; - if (collabModelNameEl) collabModelNameEl.textContent = name; + if (collabModelNameEl) collabModelNameEl.textContent = state.conductorModelName || name; } function modelDisplayName(p, fallbackName) { if (p && p.kind === 'mixin') { @@ -3487,7 +3470,38 @@ async function selectModel(id, name) { state.modelName = modelDisplayName(p, name); updateModelChip(); renderSettingsModels(); - await persistUiPrefs(); + // 申请切换:有活跃会话 -> 绑定到该会话(后端权威);同时更新全局默认(供新建会话初始值)。 + // 后端是真相源,前端只发请求;申请失败则回滚显示并提示。 + const sess = activeSess(); + if (sess && sess.bridgeSessionId) { + const prevNo = sess.llmNo; + sess.llmNo = id; + try { + await bridgeFetch(`/session/${encodeURIComponent(sess.bridgeSessionId)}/model`, { method: 'POST', body: { llmNo: id } }); + } catch (ex) { + // 后端没切成功:回滚到该会话原绑定,避免前端显示与后端不一致。 + sess.llmNo = prevNo; + if (prevNo != null) { + state.llmNo = prevNo; + const pp = (state.modelProfiles || []).find(x => (x.id ?? 0) === prevNo); + if (pp) state.modelName = modelDisplayName(pp); + updateModelChip(); + renderSettingsModels(); + } + showChanToast(t('err.modelSwitch'), ex.message || '', 'err'); + return; + } + } else if (sess) { + sess.llmNo = id; + } + await persistUiPrefs(); // 写 ui.llmNo 全局默认 +} +async function selectConductorModel(id, name) { + state.conductorLlmNo = id; + const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === id); + state.conductorModelName = modelDisplayName(p, name); + updateModelChip(); + try { await window.ga.saveConductorModel(id); } catch (_) {} } async function addToMixin(id) { try { @@ -3831,6 +3845,12 @@ async function loadModelProfiles() { state.llmNo = active.id ?? 0; state.modelName = modelDisplayName(active); } + // conductor chip 的显示名只在 loadBridgeConfig/selectConductorModel 更新;导入密钥等 + // 仅刷新 profiles 的路径若不在此一并重算,会让 conductor chip 停在旧文案(如空渠道组)。 + if (state.conductorLlmNo != null) { + const cp = state.modelProfiles.find(p => (p.id ?? 0) === state.conductorLlmNo); + if (cp) state.conductorModelName = modelDisplayName(cp); + } updateModelChip(); renderSettingsModels(); } catch (_) {} @@ -3841,10 +3861,12 @@ const collabModelMenu = document.getElementById('cdb-model-menu'); function renderModelMenu(menuEl) { if (!menuEl) return; const list = state.modelProfiles || []; + const selectedNo = menuEl === collabModelMenu ? state.conductorLlmNo : state.llmNo; + const selectedName = menuEl === collabModelMenu ? state.conductorModelName : state.modelName; const rows = list.map((p, i) => { const no = (p.id ?? i); - const isActive = (state.llmNo === no) ? ' active' : ''; - const label = (isActive && p.kind === 'mixin' && state.modelName) ? state.modelName : modelDisplayName(p); + const isActive = (selectedNo === no) ? ' active' : ''; + const label = (isActive && p.kind === 'mixin' && selectedName) ? selectedName : modelDisplayName(p); return `
${escapeHtml(label || '')}
`; }); menuEl.innerHTML = rows.join(''); @@ -3852,7 +3874,7 @@ function renderModelMenu(menuEl) { } function openModelMenu(chipEl, menuEl) { if (!chipEl || !menuEl) return; - if (typeof convMenu !== 'undefined' && convMenu) convMenu.hidden = true; + if (typeof hideConvMenu === 'function') hideConvMenu(); window.collabComposer?.closeMenu?.(); closeAllModelMenus(); renderModelMenu(menuEl); @@ -3872,7 +3894,7 @@ function closeAllModelMenus() { if (modelChip) modelChip.classList.remove('open'); if (collabModelChip) collabModelChip.classList.remove('open'); } -function bindModelMenuItemClick(menuEl) { +function bindModelMenuItemClick(menuEl, onSelect) { if (!menuEl) return; menuEl.addEventListener('click', (e) => { e.stopPropagation(); @@ -3881,12 +3903,12 @@ function bindModelMenuItemClick(menuEl) { const no = parseInt(item.dataset.llmno, 10); if (Number.isNaN(no)) return; const p = (state.modelProfiles || []).find(x => (x.id ?? 0) === no); - selectModel(no, (p && p.name) || ''); + onSelect(no, (p && p.name) || ''); closeAllModelMenus(); }); } -bindModelMenuItemClick(modelMenu); -bindModelMenuItemClick(collabModelMenu); +bindModelMenuItemClick(modelMenu, selectModel); +bindModelMenuItemClick(collabModelMenu, selectConductorModel); if (modelChip) modelChip.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (modelMenu && !modelMenu.hidden) { closeAllModelMenus(); return; } @@ -3942,10 +3964,18 @@ async function loadBridgeConfig() { if (p) { state.llmNo = cfg.llmNo; state.modelName = modelDisplayName(p); - updateModelChip(); renderSettingsModels(); } } + const cno = cfg.conductor?.llmNo ?? cfg.llmNo; + if (cno != null && state.modelProfiles.length) { + const cp = state.modelProfiles.find(x => (x.id ?? 0) === cno); + if (cp) { + state.conductorLlmNo = cno; + state.conductorModelName = modelDisplayName(cp); + } + } + updateModelChip(); syncBootCache(); } catch (_) {} } @@ -4387,7 +4417,10 @@ window.ga.onBridgeNotification((msg) => { if (msg && msg.type === 'session-state') { for (const sess of state.sessions.values()) { if (sess.bridgeSessionId === msg.sessionId) { - if (msg.status === 'running' || msg.state === 'running') pollSession(sess); + if (['running', 'idle', 'error', 'cancelled'].includes(msg.status) || + ['running', 'idle', 'error', 'cancelled'].includes(msg.state)) { + pollSession(sess); + } if (msg.state === 'idle' || msg.status === 'idle') tokPollBridge(); renderSessionList(); break; @@ -4919,49 +4952,13 @@ if (msgArea) { function uploadRawUrl(path, download) { return `${BRIDGE_ORIGIN}/upload/raw?path=${encodeURIComponent(path || '')}${download ? '&download=1' : ''}`; } -function bridgeIsLocal() { - return location.hostname === '127.0.0.1' || location.hostname === 'localhost'; -} async function openUploadFile(path, name) { - // 远程访问:浏览器无法调起 bridge 那台/本机的系统程序,降级为下载到本机 - if (!bridgeIsLocal()) { - const a = document.createElement('a'); - a.href = uploadRawUrl(path, true); - a.download = name || ''; - document.body.appendChild(a); a.click(); a.remove(); - return; - } - // 本地:bridge 与你同机,调系统默认程序打开 / 在文件夹显示 - const mode = isPreviewableByName(name || path) ? 'open' : 'reveal'; - try { - const res = await fetch(`${BRIDGE_ORIGIN}/path/open`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ kind: 'upload', path, mode }), - }); - const j = await res.json(); - if (!j.ok) throw new Error(j.error || 'open failed'); - } catch (e) { - showChanToast(t('file.openFailed'), e.message || String(e), 'err'); - } -} - -const PREVIEWABLE_EXTS = new Set([ - 'pdf', - 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'heic', 'tiff', - 'txt', 'md', 'log', 'json', 'yaml', 'yml', 'xml', 'csv', 'tsv', 'ini', 'toml', 'env', 'rtf', - 'py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'rs', 'go', 'rb', 'php', 'sh', 'bash', 'zsh', 'fish', 'lua', 'pl', 'r', 'scala', 'kt', 'swift', - 'html', 'htm', 'css', 'scss', 'sass', 'less', 'vue', 'svelte', 'sql', - 'doc', 'docx', 'pages', 'odt', - 'xls', 'xlsx', 'numbers', 'ods', - 'ppt', 'pptx', 'key', 'odp', - 'mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', - 'mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv', -]); -function isPreviewableByName(name) { - const m = String(name || '').match(/\.([^./\\]+)$/); - if (!m) return false; - return PREVIEWABLE_EXTS.has(m[1].toLowerCase()); + const a = document.createElement('a'); + a.href = uploadRawUrl(path, true); + a.download = name || ''; + document.body.appendChild(a); + a.click(); + a.remove(); } /* ═══════════════ 后台服务页 Tab(消息通道 / 状态面板) ═══════════════ */ @@ -5615,6 +5612,7 @@ initChatFontStepper(); applyChatFontSize(chatFontSize, { persist: false }); syncHljsTheme(); applyI18n(); +initCustomTooltips(); updateModelChip(); renderSessionList(); loadCustomPresets(); diff --git a/frontends/desktop/static/fallback.html b/frontends/desktop/static/fallback.html index c53f3c4eb..39caafca2 100644 --- a/frontends/desktop/static/fallback.html +++ b/frontends/desktop/static/fallback.html @@ -2,7 +2,7 @@ -GenericAgent — Setup +GenericAgent +
-

⚙️ Setup Required

-

The backend could not start. Please configure the paths below.

-

后端启动失败,请配置以下路径。

+

+

- +
- +
-

- e.g. C:/Python312/python.exe / ~/miniconda3/envs/myenv/bin/python -

+

- +
- +
-

- The folder containing frontends/desktop_bridge.py -

+

- +
- + + diff --git a/frontends/desktop/static/loading.html b/frontends/desktop/static/loading.html index f02eddb34..e8fe9e595 100644 --- a/frontends/desktop/static/loading.html +++ b/frontends/desktop/static/loading.html @@ -17,21 +17,16 @@ #ga-bar.indet{width:35%;animation:indet 1.1s ease-in-out infinite} @keyframes indet{0%{margin-left:-35%}100%{margin-left:100%}} +
-
正在启动 GenericAgent…
+
diff --git a/frontends/desktop/static/styles.css b/frontends/desktop/static/styles.css index 44c166eb0..710ad4244 100644 --- a/frontends/desktop/static/styles.css +++ b/frontends/desktop/static/styles.css @@ -92,8 +92,11 @@ --shadow-pop:0 8px 24px #14192829; --shadow-modal:0 18px 50px #14192847; - --divider-base:rgba(15, 74, 133, 0.22); + --divider-base:rgba(0,0,0,.12); --divider:var(--divider-base); + --tooltip-bg:var(--card); + --tooltip-fg:var(--txt); + --tooltip-border:var(--line-strong); --radius:4px; --radius-sm:2px; /* —— 折叠块 —— */ @@ -124,7 +127,7 @@ html[data-appearance="light"]:not([data-plain="1"])[data-theme]{ --bg:color-mix(in srgb, var(--accent) 7%, var(--bg-base)); --line:color-mix(in srgb, var(--accent) 16%, var(--line-base)); --line-soft:color-mix(in srgb, var(--accent) 10%, var(--line-soft-base)); - --divider:color-mix(in srgb, var(--accent) 22%, var(--divider-base)); + --divider:var(--divider-base); } /* —— 外观:深色 —— */ @@ -174,7 +177,7 @@ html[data-appearance="dark"]{ --fold-tool-bg:#16202b; --fold-result-border:#3f6647; --fold-result-bg:#15241a; - --divider-base:#569cd6; + --divider-base:rgba(255,255,255,.10); --divider:var(--divider-base); /* 发送键:深色用鲜亮深蓝 */ --ink-green:#2559d8; @@ -183,7 +186,7 @@ html[data-appearance="dark"]{ html[data-appearance="dark"][data-theme]{ --accent-bg:color-mix(in srgb, var(--accent) 22%, var(--panel)); --accent-soft:color-mix(in srgb, var(--accent) 12%, var(--panel)); - --divider:color-mix(in srgb, var(--accent) 18%, var(--divider-base)); + --divider:var(--divider-base); } html,body{ height:100%; } @@ -400,6 +403,17 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f transition:opacity .18s, transform .18s; z-index:9999; } .ga-toast.show{ opacity:1; transform:translateX(-50%) translateY(0); } +.ga-tooltip{ + position:fixed; z-index:10000; max-width:min(320px, calc(100vw - 16px)); + padding:6px 8px; border:1px solid var(--tooltip-border); border-radius:4px; + background:var(--tooltip-bg); + color:var(--tooltip-fg); font:12px/1.35 var(--font-sans); + box-shadow:0 8px 20px #00000026; + pointer-events:none; opacity:0; transform:translateY(2px); + transition:opacity .12s, transform .12s; + white-space:normal; overflow-wrap:anywhere; +} +.ga-tooltip.show{ opacity:1; transform:translateY(0); } .set-btn{ width:100%; display:flex; align-items:center; gap:8px; border:1px solid var(--line); background:var(--card); color:var(--txt-2); @@ -584,7 +598,7 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f background:var(--field-bg); cursor:pointer; text-align:left; transition:border-color .15s, background .15s; } -.pq-btn:hover{ background:var(--card); border-color:var(--accent); } +.pq-btn:hover{ background:var(--line-soft); border-color:var(--line-base); } .pq-logo{ flex:0 0 auto; width:26px; height:26px; border-radius:7px; display:flex; align-items:center; justify-content:center; @@ -594,7 +608,7 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f .pq-btn-name{ font-size:12px; font-weight:500; color:var(--txt); line-height:1.35; } .pq-btn-desc{ font-size:10px; color:var(--muted); line-height:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .pq-arrow{ flex:0 0 auto; width:13px; height:13px; color:var(--muted2); transition:transform .15s, color .15s; } -.pq-btn:hover .pq-arrow{ color:var(--accent); transform:translateX(2px); } +.pq-btn:hover .pq-arrow{ color:var(--txt-2); transform:none; } /* ── 添加模型弹窗内的接入指引横幅 ── */ .model-guide{ @@ -721,9 +735,10 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f .run-state.busy .dot::before{ content:''; position:absolute; - left:50%; top:50%; + inset:0; + margin:auto; width:var(--breathe-core); height:var(--breathe-core); - transform:translate(-50%,-50%); + transform:none; border-radius:50%; background:var(--breathe-color); z-index:2; @@ -732,9 +747,11 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f .run-state.busy .dot::after{ content:''; position:absolute; - left:50%; top:50%; + inset:0; + margin:auto; width:var(--breathe-core); height:var(--breathe-core); - transform:translate(-50%,-50%); + transform:none; + transform-origin:center; border-radius:50%; border:1px solid color-mix(in srgb, var(--breathe-color) 52%, transparent); z-index:1; @@ -742,8 +759,8 @@ body{ background:var(--bg); color:var(--txt); font-size:13px; overflow:hidden; f animation:ga-status-breathe 1.75s ease-out infinite; } @keyframes ga-status-breathe{ - 0%{ transform:translate(-50%,-50%) scale(1); opacity:.55; } - 100%{ transform:translate(-50%,-50%) scale(2.35); opacity:0; } + 0%{ transform:scale(1); opacity:.55; } + 100%{ transform:scale(2.35); opacity:0; } } .link{ color:var(--accent); cursor:pointer; } .muted{ color:var(--muted); } @@ -1263,10 +1280,6 @@ span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; } align-self:end; } .composer-inset.is-inline .composer-bot .spacer{ display:none; } -.composer-inset:focus-within{ - border-color:var(--accent); - box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); -} .composer-inset .input{ width:100%; min-height:38px; max-height:160px; overflow-y:auto; border:none; border-radius:0; background:transparent; @@ -1368,7 +1381,7 @@ span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; } margin:0 -3px; } .rp-resize:hover, .rp-resize.dragging, -.sb-resize:hover, .sb-resize.dragging{ background:var(--accent); } +.sb-resize:hover, .sb-resize.dragging{ background:var(--line-strong); } .body.rp-collapsed .rp-resize, .body.sb-collapsed .sb-resize{ display:none; } @@ -1427,15 +1440,30 @@ span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; } .conv-item{ display:flex; align-items:center; gap:9px; padding:9px 10px; border-radius:4px; cursor:pointer; } .conv-item:hover{ background:var(--line-soft); } .conv-item.active{ background:var(--accent-bg); } +.conv-item.renaming{ + cursor:default; + background:color-mix(in srgb, var(--accent-bg) 70%, var(--panel)); +} .ci-dot{ width:7px; height:7px; border-radius:50%; background:var(--ok); flex:0 0 auto; } .conv-item.idle .ci-dot{ background:var(--dot-idle); } .ci-main{ flex:1; min-width:0; } .ci-title{ font-size:13px; font-weight:500; color:var(--txt-strong); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .ci-rename-input{ - font-size:13px; font-weight:500; color:var(--txt-strong); width:100%; - border:1px solid var(--accent); border-radius:4px; padding:1px 4px; - background:var(--field-bg); outline:none; + display:block; width:100%; min-width:0; height:24px; box-sizing:border-box; + margin:-3px 0 -2px; padding:0 7px; + border:1px solid color-mix(in srgb, var(--accent) 44%, var(--line-strong)); + border-radius:4px; background:var(--card); outline:none; + color:var(--txt-strong); caret-color:var(--accent); + font-family:var(--font-sans); font-size:13px; font-weight:500; line-height:22px; + box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 10%, transparent); + transition:border-color .12s, box-shadow .12s, background .12s; +} +.ci-rename-input:focus{ + border-color:var(--accent); + background:var(--field-bg); + box-shadow:0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent); } +.ci-rename-input::selection{ background:color-mix(in srgb, var(--accent) 28%, transparent); } .ci-pin{ width:14px; height:14px; color:var(--accent); margin-right:3px; vertical-align:-3px; flex:0 0 auto; display:inline-block; } .ci-meta{ font-size:11px; color:var(--muted); margin-top:2px; } .ci-more{ @@ -1446,6 +1474,7 @@ span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; } } .ci-more svg{ width:20px; height:20px; } .conv-item:hover .ci-more, .conv-item.active .ci-more{ opacity:1; } +.conv-item.renaming .ci-more{ opacity:0; pointer-events:none; } .ci-more:hover{ background:var(--hover-dim); color:var(--txt-2); } .ctx-menu{ @@ -1480,58 +1509,67 @@ span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; } } .plan-card{ position:absolute; - left:10px; - bottom:100%; - margin-bottom:0; + left:12px; + bottom:calc(100% + 8px); z-index:13; - width:min(600px, 100%); + width:min(640px, calc(100% - 24px)); min-width:280px; max-width:100%; box-sizing:border-box; - font-size:12px; - line-height:1.35; + font-size:13px; + line-height:1.45; color:var(--txt); pointer-events:auto; } .plan-card[hidden]{ display:none !important; } .plan-card--expanded{ - padding:10px 12px 8px; - background:var(--card); + padding:14px 16px 12px 18px; + background:color-mix(in srgb, var(--card) 94%, var(--accent) 6%); border:1px solid var(--line); - border-bottom:none; - border-left:3px solid var(--accent); - border-radius:var(--radius-sm) var(--radius-sm) 0 0; - box-shadow:0 -4px 16px #14192812, 0 -1px 0 var(--line) inset; + border-radius:8px; + box-shadow:0 12px 32px #14192818, 0 1px 2px #14192814; + overflow:hidden; +} +.plan-card--expanded::before{ + content:""; + position:absolute; + left:0; + top:0; + bottom:0; + width:4px; + background:var(--accent); } .plan-card--collapsed{ width:auto; min-width:0; max-width:min(520px, 70%); - margin-bottom:0; } -/* 吸附:卡片底与输入框顶共边,视觉上连成一体 */ +/* Keep the composer rounded while the plan card floats above it. */ .composer-slot:has(.plan-card:not([hidden])) .composer-inset{ - border-top-left-radius:0; - border-top-right-radius:0; + border-top-left-radius:var(--radius); + border-top-right-radius:var(--radius); } .composer-slot:has(.plan-card--collapsed:not([hidden])) .plan-capsule{ - border-radius:var(--radius-sm) var(--radius-sm) 0 0; - border-bottom:1px solid var(--line); - box-shadow:0 -4px 12px #14192810; + box-shadow:0 10px 24px #14192814, 0 1px 2px #14192810; } .plan-card-head{ display:flex; align-items:center; - gap:8px; - margin-bottom:6px; + gap:10px; + margin-bottom:10px; + min-height:26px; } .plan-status-dot{ flex:0 0 auto; - width:7px; height:7px; + width:8px; height:8px; border-radius:50%; background:var(--accent); + box-shadow:0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent); +} +.plan-card--complete .plan-status-dot{ + background:var(--success); + box-shadow:0 0 0 4px var(--success-ring); } -.plan-card--complete .plan-status-dot{ background:var(--success); } .plan-card--placeholder .plan-status-dot{ animation:plan-dot-pulse 1.4s ease-in-out infinite; } @@ -1541,8 +1579,8 @@ span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; } } .plan-title{ flex:1 1 auto; - font-weight:600; - font-size:12px; + font-weight:700; + font-size:14px; color:var(--txt-strong); min-width:0; white-space:nowrap; @@ -1551,104 +1589,198 @@ span.flatpickr-weekday{ color:var(--muted) !important; font-size:11px; } } .plan-progress{ flex:0 0 auto; - font-weight:600; + display:inline-flex; + align-items:center; + min-height:24px; + padding:0 8px; + border:1px solid color-mix(in srgb, var(--accent) 26%, var(--line)); + border-radius:999px; + background:color-mix(in srgb, var(--accent) 9%, var(--card)); + font-weight:700; font-size:12px; - color:var(--accent); + color:var(--txt-strong); font-family:var(--font-num); font-variant-numeric:var(--font-num-variant); } -.plan-card--complete .plan-progress{ color:var(--success); } +.plan-card--complete .plan-progress{ + color:var(--success); + border-color:color-mix(in srgb, var(--success) 28%, var(--line)); + background:var(--success-bg); +} .plan-head-actions{ flex:0 0 auto; display:flex; align-items:center; - gap:4px; + gap:6px; } .plan-btn{ - border:0; + display:inline-flex; + align-items:center; + justify-content:center; + gap:5px; + min-height:26px; + border:1px solid transparent; background:transparent; - padding:2px 6px; - font-size:11px; - font-weight:500; - color:var(--accent); + padding:0 8px; + font-size:12px; + font-weight:600; + color:var(--muted); cursor:pointer; - border-radius:var(--radius-sm); + border-radius:6px; font-family:var(--font-sans); + transition:background .15s, border-color .15s, color .15s; +} +.plan-btn:hover{ + background:var(--field-bg); + border-color:var(--line); + color:var(--txt-strong); +} +.plan-btn-ic{ + width:12px; + height:12px; + flex:0 0 auto; } -.plan-btn:hover{ background:var(--accent-soft); } .plan-current{ display:flex; - gap:4px; + gap:6px; align-items:flex-start; - margin-bottom:8px; - padding-left:15px; + margin:0 0 10px 0; + padding:8px 10px; + border:1px solid var(--line-soft); + border-radius:6px; + background:color-mix(in srgb, var(--field-bg) 82%, var(--accent) 18%); } .plan-current-label{ flex:0 0 auto; color:var(--muted); - font-size:11px; + font-size:12px; } .plan-current-text{ flex:1 1 auto; - color:var(--accent); - font-weight:500; + color:var(--txt-strong); + font-weight:600; word-break:break-word; } .plan-card--complete .plan-current-text{ color:var(--success); } .plan-wait{ color:var(--muted2); - padding:0 0 4px 15px; - font-size:11px; + padding:4px 0 2px; + font-size:12px; } .plan-items{ list-style:none; margin:0; - padding:0 0 4px 15px; + padding:0; } .plan-item{ display:flex; - gap:6px; + gap:10px; align-items:flex-start; - margin-top:3px; + padding:5px 0; + min-height:26px; } .plan-item-mark{ flex:0 0 auto; - width:1.1em; - text-align:center; - line-height:1.35; + position:relative; + display:inline-flex; + align-items:center; + justify-content:center; + width:16px; + height:16px; + margin-top:2px; + border:1px solid var(--line-strong); + border-radius:50%; + background:var(--card); + color:var(--muted); + font-size:0; } .plan-item-text{ flex:1 1 auto; word-break:break-word; + color:var(--txt-2); +} +.plan-item--done .plan-item-mark{ + color:var(--success); + border-color:color-mix(in srgb, var(--success) 40%, var(--line)); + background:var(--success-bg); +} +.plan-item--done .plan-item-mark::after{ + content:""; + width:7px; + height:4px; + border-left:2px solid currentColor; + border-bottom:2px solid currentColor; + transform:translateY(-1px) rotate(-45deg); } -.plan-item--done .plan-item-mark, .plan-item--done .plan-item-text{ color:var(--muted); - opacity:.75; + opacity:.78; } .plan-item--done .plan-item-text{ text-decoration:line-through; } -.plan-item--current .plan-item-mark{ color:var(--accent); } -.plan-item--current .plan-item-text{ color:var(--accent); font-weight:500; } -.plan-item--pending .plan-item-mark, +.plan-item--current .plan-item-mark{ + border-color:var(--accent); + background:var(--accent); + box-shadow:0 0 0 4px color-mix(in srgb, var(--accent) 12%, transparent); +} +.plan-item--current .plan-item-mark::after{ + content:""; + width:5px; + height:5px; + border-radius:50%; + background:var(--on-accent); +} +.plan-item--current .plan-item-text{ + color:var(--txt-strong); + font-weight:700; +} +.plan-item--pending .plan-item-mark::after{ + content:""; + width:4px; + height:4px; + border-radius:50%; + background:var(--line-strong); +} .plan-item--pending .plan-item-text{ color:var(--muted); } -.plan-item--error .plan-item-mark, +.plan-item--error .plan-item-mark{ + color:var(--danger); + border-color:color-mix(in srgb, var(--danger) 38%, var(--line)); + background:var(--danger-bg); +} +.plan-item--error .plan-item-mark::before, +.plan-item--error .plan-item-mark::after{ + content:""; + position:absolute; + width:8px; + height:2px; + border-radius:999px; + background:currentColor; +} +.plan-item--error .plan-item-mark::before{ transform:rotate(45deg); } +.plan-item--error .plan-item-mark::after{ transform:rotate(-45deg); } .plan-item--error .plan-item-text{ color:var(--danger); } -.plan-item--warn .plan-item-mark, -.plan-item--warn .plan-item-text{ color:#c67600; } +.plan-item--warn .plan-item-mark{ + color:#c67600; + border-color:color-mix(in srgb, #c67600 38%, var(--line)); + background:color-mix(in srgb, #c67600 12%, var(--card)); + font-size:10px; + font-weight:800; +} +.plan-item--warn .plan-item-mark::after{ content:"!"; } +.plan-item--warn .plan-item-text{ color:#9b6100; } html[data-appearance="dark"] .plan-item--warn .plan-item-mark, html[data-appearance="dark"] .plan-item--warn .plan-item-text{ color:#f0a020; } .plan-foot{ display:flex; justify-content:flex-end; gap:8px; - padding-top:4px; + padding-top:10px; border-top:1px solid var(--line-soft); - margin-top:4px; + margin-top:6px; } .plan-more-hint{ flex:1 1 auto; align-self:center; - font-size:11px; + font-size:12px; color:var(--muted2); text-align:left; } @@ -1657,19 +1789,36 @@ html[data-appearance="dark"] .plan-item--warn .plan-item-text{ color:#f0a020; } align-items:center; gap:8px; max-width:100%; - padding:6px 12px; + min-height:34px; + padding:0 12px 0 10px; border:1px solid var(--line); - border-left:3px solid var(--accent); border-radius:999px; - background:var(--card); + background:color-mix(in srgb, var(--card) 94%, var(--accent) 6%); cursor:pointer; font-size:12px; - font-weight:500; + font-weight:600; color:var(--txt); - box-shadow:var(--shadow); + box-shadow:0 10px 24px #14192814, 0 1px 2px #14192810; font-family:var(--font-sans); + transition:background .15s, border-color .15s, box-shadow .15s; +} +.plan-capsule:hover{ + border-color:var(--line-strong); + background:var(--card); + box-shadow:0 12px 28px #1419281f, 0 1px 2px #14192812; +} +.plan-capsule-ic{ + width:15px; + height:15px; + flex:0 0 auto; + color:var(--muted); +} +.plan-capsule-chevron{ + width:12px; + height:12px; + flex:0 0 auto; + color:var(--muted2); } -.plan-capsule:hover{ border-color:var(--line-strong); background:var(--field-bg); } .plan-capsule-text{ min-width:0; white-space:nowrap; @@ -1682,7 +1831,9 @@ html[data-appearance="dark"] .plan-item--warn .plan-item-text{ color:#f0a020; } color:var(--accent); font-weight:600; } -.plan-card--complete .plan-capsule{ border-left-color:var(--success); } +.plan-card--complete .plan-capsule{ + background:color-mix(in srgb, var(--card) 88%, var(--success) 12%); +} .plan-card--complete .plan-capsule-text em{ color:var(--success); } /* ─── chat composer: Plan/Auto toggle ─── */ diff --git a/frontends/desktop_bridge.py b/frontends/desktop_bridge.py index 2ff937aa3..c6aae3a63 100644 --- a/frontends/desktop_bridge.py +++ b/frontends/desktop_bridge.py @@ -36,7 +36,7 @@ """ from __future__ import annotations -import asyncio, atexit, contextlib, importlib, json, os, re, subprocess, sys +import asyncio, atexit, contextlib, importlib, json, os, re, shutil, subprocess, sys from collections import Counter, deque import threading, time, traceback, uuid from dataclasses import dataclass, field @@ -47,7 +47,29 @@ APP_DIR = Path(__file__).resolve().parent +def _ga_root_override() -> Optional[Path]: + """External core dir injected by the desktop shell (design 三: bundle bridge + external核). + Priority: --ga-root arg, then GA_ROOT env. Only honored when it holds agentmain.py; + an invalid/missing value returns None so we fall back to the bundle's own derivation.""" + val = "" + for i, a in enumerate(sys.argv): + if a == "--ga-root" and i + 1 < len(sys.argv): + val = sys.argv[i + 1] + elif a.startswith("--ga-root="): + val = a.split("=", 1)[1] + if not val: + val = os.environ.get("GA_ROOT", "") + val = (val or "").strip() + if not val: + return None + root = Path(val).expanduser().resolve() + return root if (root / "agentmain.py").exists() else None + + def find_default_ga_root() -> Path: + override = _ga_root_override() + if override is not None: + return override candidates = [ APP_DIR / "..", APP_DIR / ".." / "..", @@ -70,6 +92,24 @@ def strip_final_info_marker(text: Any) -> str: return _FINAL_INFO_RE.sub('', str(text or '')) +def normalize_final_turn_segs(full: str, outputs: Any) -> Optional[List[str]]: + if not outputs or not isinstance(outputs, (list, tuple)): + return None + segs = [strip_final_info_marker(s) for s in outputs] + full_text = strip_final_info_marker(full) + if not segs: + return None + joined = "".join(segs) + if full_text.strip() == joined.strip(): + return segs + if joined and full_text.startswith(joined): + suffix = full_text[len(joined):] + if suffix.strip(): + segs[-1] = segs[-1] + suffix + return segs + return None + + for _s in (sys.stdout, sys.stderr): with contextlib.suppress(Exception): _s.reconfigure(encoding="utf-8", errors="replace") @@ -99,6 +139,9 @@ class Session: plan_scan_baseline: int = 0 plan_path: str = "" llm_history: Optional[List[dict]] = None + # 该会话绑定的模型下标(mykey.py 配置块顺序,== agent.llmclients 下标)。 + # None = 未绑定,发消息时回退到全局默认 ui.llmNo,保持旧会话平滑迁移。 + llm_no: Optional[int] = None def _load_plan_baseline(item: dict, msgs: list) -> int: @@ -110,14 +153,14 @@ def _load_plan_baseline(item: dict, msgs: list) -> int: def _sanitize_desktop_plan_path(session_id: str, plan_path: str) -> str: - """Desktop: drop shared plan_demo paths so sessions do not read the same file.""" + """Keep only real plan-mode paths; never invent a placeholder path on load.""" import plan_state p = (plan_path or "").strip() if not p: return "" - if plan_state.is_session_scoped_plan_path(p, session_id): - return p - return plan_state.default_session_plan_path(session_id) + if plan_state.is_plan_mode_path(p): + return p.lstrip("./\\") + return "" class AgentManager: @@ -127,6 +170,8 @@ def __init__(self): self.config: Dict[str, Any] = {} self.sessions: Dict[str, Session] = {} self.active_session_id: Optional[str] = None + self._sessions_dir = Path(self.ga_root) / "temp" / "desktop_sessions" + # Legacy monolithic store; migrated into _sessions_dir on first load, then retired. self._sessions_file = Path(self.ga_root) / "temp" / "desktop_sessions.json" self._load_sessions() @@ -134,54 +179,157 @@ def __init__(self): def mykey_path(self) -> str: return str(Path(self.ga_root) / "mykey.py") - def _persist(self): + def _session_dict(self, s: "Session") -> dict: + llm_hist = None + if s.agent and hasattr(s.agent, 'llmclient'): + try: llm_hist = s.agent.llmclient.backend.history + except Exception: pass + if llm_hist is None: + llm_hist = s.llm_history + return {"id": s.id, "title": s.title, "cwd": s.cwd, + "created_at": s.created_at, "updated_at": s.updated_at, + "messages": s.messages, "msg_seq": s.msg_seq, + "pinned": s.pinned, "untitled": s.untitled, + "plan_scan_baseline": s.plan_scan_baseline, + "plan_path": s.plan_path or "", + "llm_no": s.llm_no, + "llm_history": llm_hist} + + def _session_file(self, sid: str) -> Path: + return self._sessions_dir / f"{sid}.json" + + def _persist_session(self, s: "Session"): + """Write a single session file. Cost is O(one session), independent of how many + sessions exist — this is the fix for the monolithic-file scaling problem.""" try: - self._sessions_file.parent.mkdir(parents=True, exist_ok=True) - arr = [] + self._sessions_dir.mkdir(parents=True, exist_ok=True) with self.lock: - for s in self.sessions.values(): - llm_hist = None - if s.agent and hasattr(s.agent, 'llmclient'): - try: llm_hist = s.agent.llmclient.backend.history - except Exception: pass - if llm_hist is None: - llm_hist = s.llm_history - arr.append({"id": s.id, "title": s.title, "cwd": s.cwd, - "created_at": s.created_at, "updated_at": s.updated_at, - "messages": s.messages, "msg_seq": s.msg_seq, - "pinned": s.pinned, "untitled": s.untitled, - "plan_scan_baseline": s.plan_scan_baseline, - "plan_path": s.plan_path or "", - "llm_history": llm_hist}) - self._sessions_file.write_text(json.dumps(arr, ensure_ascii=False, default=str), encoding="utf-8") + data = self._session_dict(s) + tmp = self._sessions_dir / f"{s.id}.json.tmp" + tmp.write_text(json.dumps(data, ensure_ascii=False, default=str), encoding="utf-8") + os.replace(tmp, self._session_file(s.id)) # atomic swap + except Exception as e: + print(f"[bridge] persist session {s.id} failed: {e}", file=sys.stderr) + + def _delete_session_file(self, sid: str): + try: + f = self._session_file(sid) + if f.exists(): + f.unlink() except Exception as e: - print(f"[bridge] persist sessions failed: {e}", file=sys.stderr) + print(f"[bridge] delete session file {sid} failed: {e}", file=sys.stderr) + + def _persist(self): + """Write every session (one file each). Used for bulk ops (import) / full flush.""" + with self.lock: + sessions = list(self.sessions.values()) + for s in sessions: + self._persist_session(s) + + def _session_from_item(self, item: dict) -> "Session": + msgs = item.get("messages", []) + return Session(id=item["id"], title=item.get("title", "New chat"), + cwd=item.get("cwd", self.ga_root), + created_at=item.get("created_at", time.time()), + updated_at=item.get("updated_at", time.time()), + messages=msgs, + msg_seq=item.get("msg_seq", 0), + pinned=item.get("pinned", False), + untitled=item.get("untitled", True), + plan_scan_baseline=_load_plan_baseline(item, msgs), + plan_path=_sanitize_desktop_plan_path(item["id"], item.get("plan_path") or ""), + status="idle", agent=None, + llm_history=item.get("llm_history"), + llm_no=item.get("llm_no")) def _load_sessions(self): + # New format: one file per session under temp/desktop_sessions/. try: - if not self._sessions_file.exists(): - return - arr = json.loads(self._sessions_file.read_text(encoding="utf-8")) - for item in arr: - msgs = item.get("messages", []) - sess = Session(id=item["id"], title=item.get("title", "New chat"), - cwd=item.get("cwd", self.ga_root), - created_at=item.get("created_at", time.time()), - updated_at=item.get("updated_at", time.time()), - messages=msgs, - msg_seq=item.get("msg_seq", 0), - pinned=item.get("pinned", False), - untitled=item.get("untitled", True), - plan_scan_baseline=_load_plan_baseline(item, msgs), - plan_path=_sanitize_desktop_plan_path( - item["id"], item.get("plan_path") or ""), - status="idle", agent=None, - llm_history=item.get("llm_history")) - self.sessions[sess.id] = sess - if self.sessions: - self.active_session_id = max(self.sessions.values(), key=lambda s: s.updated_at).id + if self._sessions_dir.is_dir(): + for f in self._sessions_dir.glob("*.json"): + try: + item = json.loads(f.read_text(encoding="utf-8")) + sess = self._session_from_item(item) + self.sessions[sess.id] = sess + except Exception as e: + print(f"[bridge] load session {f.name} failed: {e}", file=sys.stderr) except Exception as e: - print(f"[bridge] load sessions failed: {e}", file=sys.stderr) + print(f"[bridge] load sessions dir failed: {e}", file=sys.stderr) + + # One-time migration from the legacy monolithic desktop_sessions.json. + try: + if self._sessions_file.exists(): + arr = json.loads(self._sessions_file.read_text(encoding="utf-8")) + for item in arr: + if not isinstance(item, dict) or item.get("id") in self.sessions: + continue + try: + sess = self._session_from_item(item) + self.sessions[sess.id] = sess + self._persist_session(sess) + except Exception as e: + print(f"[bridge] migrate session failed: {e}", file=sys.stderr) + # Retire the legacy file so we do not migrate again next launch. + with contextlib.suppress(Exception): + self._sessions_file.rename( + self._sessions_file.parent / (self._sessions_file.name + ".migrated")) + except Exception as e: + print(f"[bridge] migrate sessions failed: {e}", file=sys.stderr) + + if self.sessions: + self.active_session_id = max(self.sessions.values(), key=lambda s: s.updated_at).id + + def import_sessions(self, source_dir: str) -> dict: + """把 source 的桌面会话合并进当前列表(按 id 去重)。 + + 兼容两种源格式:新版 temp/desktop_sessions/.json,以及旧版单文件 + temp/desktop_sessions.json(含已退休的 .migrated)。只落盘新增的会话。 + """ + src = Path(source_dir).expanduser().resolve() + items: List[dict] = [] + found = False + + # New per-session format. + src_dir = src / "temp" / "desktop_sessions" + if src_dir.is_dir(): + for f in src_dir.glob("*.json"): + try: + items.append(json.loads(f.read_text(encoding="utf-8"))) + found = True + except Exception: + continue + + # Legacy monolithic format (live or already retired). + for legacy in (src / "temp" / "desktop_sessions.json", + src / "temp" / "desktop_sessions.json.migrated"): + if legacy.is_file(): + found = True + try: + arr = json.loads(legacy.read_text(encoding="utf-8")) + if isinstance(arr, list): + items.extend(x for x in arr if isinstance(x, dict)) + except Exception: + continue + + if not found: + return {"sessionsAdded": 0, "sessionsSkipped": 0, "sessionsFileFound": False} + + added = 0 + skipped = 0 + new_sessions: List["Session"] = [] + with self.lock: + for item in items: + sid = item.get("id") + if not sid or sid in self.sessions: + skipped += 1 + continue + sess = self._session_from_item(item) + self.sessions[sid] = sess + new_sessions.append(sess) + added += 1 + for sess in new_sessions: + self._persist_session(sess) + return {"sessionsAdded": added, "sessionsSkipped": skipped, "sessionsFileFound": True} def _mykey_file(self) -> Path: p = Path(self.ga_root) / "mykey.py" @@ -418,20 +566,22 @@ def list_model_profiles(self): except Exception as e: print(f"get model profiles failed: {e}", file=sys.stderr) return [] - active = self.config.get("llmNo", 0) - # collect all mixin members for inMixin check - all_mixin_members: set = set() + # A profile can be referenced by any mixin channel, not only the first one. + # Keep each mixin row's own members for display, but mark native profiles as + # inMixin when they appear in any mixin. + all_mixin_members: set[str] = set() for k in keys: + cfg = mk.get(k) if isinstance(mk.get(k), dict) else {} if "mixin" in k: - c = mk.get(k) if isinstance(mk.get(k), dict) else {} - all_mixin_members.update(str(m) for m in (c.get("llm_nos") or [])) + all_mixin_members.update(str(m) for m in (cfg.get("llm_nos") or [])) + active = self.config.get("llmNo", 0) out = [] for i, k in enumerate(keys): cfg = mk.get(k) if isinstance(mk.get(k), dict) else {} if "mixin" in k: - mems = [str(m) for m in (cfg.get("llm_nos") or [])] + members = [str(m) for m in (cfg.get("llm_nos") or [])] out.append({"id": i, "varName": k, "kind": "mixin", "name": "", - "members": mems, "active": i == active}) + "members": members, "active": i == active}) else: name = self._base_display_name(k, cfg) out.append({"id": i, "varName": k, "kind": "native", "name": name, @@ -503,18 +653,20 @@ def reorder_mixin(self, members: list) -> dict: @staticmethod def _live_model(sess: Session) -> Optional[dict]: - """该会话 agent 当前真正在用的模型(渠道组会随故障转移变化)。 - agent 还没建(没跑过 turn)时返回 None,前端回退到静态显示。""" + """该会话 agent 当前真正在用的模型(渠道组会随故障转移变化)。 + agent 还没建(没跑过 turn)时返回静态绑定信息,前端据 llmNo 回显选择器。 + llmNo: agent 存活取 agent.llm_no(权威运行态),否则取 sess.llm_no(可能 None)。""" ag = getattr(sess, "agent", None) if ag is None: - return None + return {"current": None, "isMixin": False, "llmNo": sess.llm_no} try: back = ag.llmclient.backend + live_no = getattr(ag, "llm_no", sess.llm_no) if "Mixin" in type(back).__name__: - return {"current": back.current_name, "isMixin": True} - return {"current": back.name, "isMixin": False} + return {"current": back.current_name, "isMixin": True, "llmNo": live_no} + return {"current": back.name, "isMixin": False, "llmNo": live_no} except Exception: - return None + return {"current": None, "isMixin": False, "llmNo": sess.llm_no} def snapshot(self, sess: Session, include_messages: bool = True) -> dict: out = { @@ -544,17 +696,17 @@ def add_message(self, sess: Session, role: str, content: str, **extra) -> dict: sess.updated_at = time.time() if role == "user" and content.strip() and sess.title == "New chat": sess.title = content.strip().replace("\n", " ")[:40] - self._persist() + self._persist_session(sess) return msg def create_session(self, cwd: Optional[str] = None) -> Session: sid = "sess-" + uuid.uuid4().hex[:12] - sess = Session(id=sid, cwd=str(cwd or self.ga_root)) + sess = Session(id=sid, cwd=str(cwd or self.ga_root), llm_no=_global_default_llm_no()) with self.lock: self.sessions[sid] = sess self.active_session_id = sid emit_session_state(sess, "created") - self._persist() + self._persist_session(sess) return sess def get_session(self, sid: str) -> Session: @@ -575,14 +727,12 @@ def delete_session(self, sid: str) -> dict: with contextlib.suppress(Exception): sess.agent.abort() emit_session_state(sess, "closed") - self._persist() + self._delete_session_file(sid) _purge_session_uploads(sid) return {"ok": True, "sessionId": sid} - def submit_prompt(self, sid: str, prompt: Any, images: Optional[list] = None, llm_no: Optional[int] = None, display: Optional[str] = None, files_meta: Optional[list] = None, image_metas: Optional[list] = None) -> dict: + def submit_prompt(self, sid: str, prompt: Any, images: Optional[list] = None, display: Optional[str] = None, files_meta: Optional[list] = None, image_metas: Optional[list] = None) -> dict: prompt, image_ids = normalize_prompt(prompt, images) - if llm_no is not None: - self.config["llmNo"] = int(llm_no) with self.lock: sess = self.sessions.get(sid) if not sess: @@ -599,28 +749,25 @@ def submit_prompt(self, sid: str, prompt: Any, images: Optional[list] = None, ll if image_metas: extra["images"] = image_metas user_msg = self.add_message(sess, "user", prompt, **extra) - import plan_state - if plan_state.is_plan_preset_prompt(prompt): - plan_state.bind_plan_session(sess, prompt) - self._persist() sess.status = "running" sess.cancel_requested = False sess.last_error = "" sess.partial = {"id": sess.msg_seq + 1, "role": "assistant", "content": "", "ts": time.time(), "partial": True, "curr_turn": 0, "turn_segs": []} # turn_segs[i]=第i轮全文(权威结构化,前端按轮渲染);content保留双轨兜底 - t = threading.Thread(target=self.run_agent_turn, args=(sess, prompt, None, llm_no), daemon=True, name=f"Turn-{sid}") + t = threading.Thread(target=self.run_agent_turn, args=(sess, prompt, None), daemon=True, name=f"Turn-{sid}") sess.thread = t t.start() seq = sess.msg_seq emit_session_state(sess, "running") return {"ok": True, "sessionId": sid, "accepted": True, "userMessageId": user_msg["id"], "seq": seq} - def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = None, llm_no: Optional[int] = None): + def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = None): try: if sess.agent is None: sess.agent = self.make_agent(sess) agent = sess.agent - no = self.config.get("llmNo") if llm_no is None else llm_no + # 模型取会话绑定 sess.llm_no,未绑定回退全局默认。切换走 set_session_model。 + no = sess.llm_no if sess.llm_no is not None else _global_default_llm_no() if no is not None and hasattr(agent, "next_llm"): with contextlib.suppress(Exception): agent.next_llm(int(no)) @@ -660,9 +807,8 @@ def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = No _segs[_idx - 1] = str(_outs[-2]) if "done" in item: full = strip_final_info_marker(item.get("done") or "") - done_outputs = item.get("outputs") # done时=turn_resps.copy()全量轮 + done_outputs = normalize_final_turn_segs(full, item.get("outputs")) # done时=turn_resps.copy()全量轮 if done_outputs: - done_outputs = [strip_final_info_marker(s) for s in done_outputs] with self.lock: if sess.partial is not None: sess.partial["content"] = full @@ -692,12 +838,10 @@ def run_agent_turn(self, sess: Session, prompt: str, images: Optional[list] = No with self.lock: sess.partial = None full = strip_final_info_marker(full) - if done_outputs: - done_outputs = [strip_final_info_marker(s) for s in done_outputs] import plan_state plan_state.sync_plan_path_from_text(sess, full, sess.cwd or self.ga_root) # 轨道2: 落库时带结构化全量轮(权威turn_segs),前端按轮渲染;content保留兜底 - _final_segs = [str(s) for s in done_outputs] if done_outputs else None + _final_segs = normalize_final_turn_segs(full, done_outputs) if _final_segs: self.add_message(sess, "assistant", full, turn_segs=_final_segs) else: @@ -777,6 +921,11 @@ def restore_context(self, sid: str) -> dict: if sess.agent is not None: return {"ok": True, "sessionId": sid, "restored": False, "reason": "agent already alive"} agent = self.make_agent(sess) + # 恢复 agent 时按会话绑定 seed 模型(未绑定则全局默认),保持显示/使用一致。 + no = sess.llm_no if sess.llm_no is not None else _global_default_llm_no() + if no is not None and hasattr(agent, "next_llm"): + with contextlib.suppress(Exception): + agent.next_llm(int(no)) if sess.llm_history: try: agent.llmclient.backend.history = sess.llm_history @@ -801,6 +950,21 @@ def restore_context(self, sid: str) -> dict: sess.status = "idle" return {"ok": True, "sessionId": sid, "restored": True, "messageCount": len(sess.llm_history or sess.messages)} + def set_session_model(self, sid: str, llm_no: int) -> dict: + """前端申请切换某会话的模型(唯一入口)。写 sess.llm_no(权威)并持久化; + agent 存活时立即 next_llm 让运行态跟上。返回该会话的运行态模型快照。""" + with self.lock: + sess = self.sessions.get(sid) + if not sess: + raise web.HTTPNotFound(text=json.dumps({"error": f"session not found: {sid}"}, ensure_ascii=False), content_type="application/json") + sess.llm_no = int(llm_no) + if sess.agent is not None and hasattr(sess.agent, "next_llm"): + with contextlib.suppress(Exception): + sess.agent.next_llm(int(llm_no)) + sess.updated_at = time.time() + self._persist_session(sess) + return {"ok": True, "sessionId": sid, "llmNo": sess.llm_no, "model": self._live_model(sess)} + import base64 @@ -908,11 +1072,13 @@ def discover_extra_services(ga_root: Path) -> List[dict]: # conductor 跟 scheduler 一样,bridge 启动时自动拉起。--no-browser 是关键: # conductor.py 默认会用 webbrowser.open 在用户浏览器弹一个 8900 端口 UI, # 桌面版自启时不需要这个独立 UI(用户从「指挥家」页直接访问)。 - conductor = ga_root / "frontends" / "conductor.py" + # 方案三:conductor 深度桌面耦合,恒用 bundle 自带的那份(APP_DIR 侧), + # 通过 GA_ROOT(见 start_service env)让它 import 外部核。 + conductor = APP_DIR / "conductor.py" if conductor.is_file(): out.append({ "id": "frontends/conductor.py", - "cmd": [sys.executable, "frontends/conductor.py", "--no-browser"], + "cmd": [sys.executable, str(conductor), "--no-browser"], }) return out @@ -1066,7 +1232,9 @@ def start_service(self, sid: str) -> dict: self._notify(sid, err=err) return {"ok": False, "error": "not_configured", "service": self._state(sid, err=err)} self.buffers[sid] = deque(maxlen=500) - env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"} + # Pass the effective ga_root so bundle-side extras (conductor) import the external核. + env = {**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8", + "GA_ROOT": str(self.ga_root)} kw: Dict[str, Any] = dict( cwd=str(self.ga_root), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", bufsize=1, env=env, @@ -1100,6 +1268,37 @@ def stop_all_extras(self) -> None: with contextlib.suppress(Exception): self.stop_service(sid) + def _extra_is_broken(self, sid: str) -> bool: + """判断一个 extra 是否「已经坏掉、需要重启才能恢复」: + - 进程异常退出(非用户主动停)→ 坏(覆盖 scheduler 那种进程级崩溃); + - 进程还活着,但捕获日志里出现 `Exception in thread conductor-agent` + → 内部 agent 线程已崩死,uvicorn 还在跑但再也处理不了任务 → 坏。 + 健康运行中的进程返回 False:它会在下个任务靠自身 mtime 热重载读到新 mykey, + 不该被打断。每次 start_service 都会换新缓冲,故缓冲里的崩溃签名只反映当前进程。""" + proc = self.procs.get(sid) + if proc is None: + return False # 没起过 / 用户主动停掉 → 不复活 + if proc.poll() is not None: + return sid not in self._stopping # 意外退出 = 坏 + buf = self.buffers.get(sid) + return bool(buf) and any("Exception in thread conductor-agent" in ln for ln in buf) + + def restart_broken_extras(self) -> None: + """mykey 被整体重写(导入密钥/编辑渠道配置)后,只重启「已经坏掉」的 + conductor/scheduler。健康运行中的进程不动——它们会在下个任务靠自身 mtime + 热重载新 key,强行重启反而会打断正在跑的任务。""" + for sid in sorted(set(self._catalog) - set(self._im_catalog)): + if not self._extra_is_broken(sid): + continue + with contextlib.suppress(Exception): + self.stop_service(sid) + try: + res = self.start_service(sid) + tag = "ok" if res.get("ok") else f"fail: {res.get('error')}" + except Exception as e: + tag = f"exception {type(e).__name__}: {e}" + print(f"[restart-broken] {sid}: {tag}", file=sys.stderr) + def stop_service(self, sid: str) -> dict: if sid not in self._catalog: raise KeyError(sid) @@ -1228,14 +1427,37 @@ async def status_handler(request): _UI_KEYS = ("lang", "theme", "appearance", "plain", "llmNo", "fontSize") -def _desktop_ui() -> dict: +def _settings_doc() -> dict: try: - ui = json.loads(_SETTINGS.read_text(encoding="utf-8")).get("ui") - return dict(ui) if isinstance(ui, dict) else {} + doc = json.loads(_SETTINGS.read_text(encoding="utf-8")) if _SETTINGS.is_file() else {} + return doc if isinstance(doc, dict) else {} except Exception: return {} +def _write_settings_doc(doc: dict) -> None: + _SETTINGS.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _desktop_ui() -> dict: + ui = _settings_doc().get("ui") + return dict(ui) if isinstance(ui, dict) else {} + + +def _conductor_settings() -> dict: + conductor = _settings_doc().get("conductor") + return dict(conductor) if isinstance(conductor, dict) else {} + + +def _global_default_llm_no() -> int: + """全局默认模型下标。会话未绑定(sess.llm_no is None)时回退到它。""" + no = _desktop_ui().get("llmNo") + try: + return int(no) if no is not None else 0 + except (TypeError, ValueError): + return 0 + + async def get_config_handler(request): profiles = manager.list_model_profiles() active = next((p["id"] for p in profiles if p.get("active")), manager.config.get("llmNo", 0)) @@ -1243,6 +1465,7 @@ async def get_config_handler(request): if "llmNo" not in cfg: cfg["llmNo"] = active cfg.update(_desktop_ui()) + cfg["conductor"] = _conductor_settings() return json_ok({"gaRoot": manager.ga_root, "mykeyPath": manager.mykey_path, "config": cfg}) @@ -1253,13 +1476,11 @@ async def save_config_handler(request): patch = {k: cfg[k] for k in _UI_KEYS if k in cfg} if patch: try: - doc = json.loads(_SETTINGS.read_text(encoding="utf-8")) if _SETTINGS.is_file() else {} - if not isinstance(doc, dict): - doc = {} + doc = _settings_doc() ui = doc["ui"] if isinstance(doc.get("ui"), dict) else {} ui.update(patch) doc["ui"] = ui - _SETTINGS.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8") + _write_settings_doc(doc) except Exception as e: print(f"[bridge] save ui prefs failed: {e}", file=sys.stderr) manager.config.update(cfg) @@ -1350,7 +1571,7 @@ async def patch_session_handler(request): if "plan_scan_baseline" in data: sess.plan_scan_baseline = int(data["plan_scan_baseline"]) sess.updated_at = time.time() - manager._persist() + manager._persist_session(sess) return json_ok({"ok": True, "session": manager.snapshot(sess, include_messages=False)}) @@ -1362,10 +1583,9 @@ async def prompt_handler(request): display = data.get("display") files_meta = data.get("files") or [] # 非图片附件 [{name, path}] image_metas = data.get("imageMetas") or [] # 图片附件 [{name, path}](不含 dataUrl) - llm_no = data.get("llmNo") - if llm_no is not None: - llm_no = int(llm_no) - return json_ok(manager.submit_prompt(sid, prompt, images, llm_no=llm_no, display=display, + # 模型不再随 prompt 携带:切换模型走 POST /session/{sid}/model 这一唯一入口, + # 发消息只使用会话已绑定的 sess.llm_no(未绑定则回退全局默认)。 + return json_ok(manager.submit_prompt(sid, prompt, images, display=display, files_meta=files_meta, image_metas=image_metas)) @@ -1386,6 +1606,18 @@ async def restore_handler(request): return json_ok(manager.restore_context(sid)) +async def session_model_handler(request): + sid = request.match_info["sid"] + data = await read_json(request) + no = data.get("llmNo", data.get("llm_no")) + if no is None: + return json_ok({"ok": False, "error": "missing llmNo"}, status=400) + try: + return json_ok(manager.set_session_model(sid, int(no))) + except (TypeError, ValueError): + return json_ok({"ok": False, "error": "invalid llmNo"}, status=400) + + async def plan_handler(request): sid = request.match_info["sid"] return json_ok(manager.plan_snapshot(sid)) @@ -1639,9 +1871,114 @@ async def mykey_save_handler(request): profiles = manager._save_mykey_text(str(content)) except Exception as e: return json_ok({"ok": False, "error": str(e)}, status=400) + # 导入/整体重写 mykey 后,只有「已崩坏」的 conductor/scheduler 才重启(死了才救); + # 健康运行中的进程不打断,它们会在下个任务靠自身 mtime 热重载读到新 key。 + services.restart_broken_extras() return json_ok({"ok": True, "path": str(manager._mykey_file()), "profiles": profiles}) +def _import_memory_from(source_dir: str, ga_root: str) -> dict: + """把 source_dir 的 memory/ 与 temp/model_responses/ 导入到 ga_root。 + + memory/: 先整体备份现有 ga_root/memory 到 temp/memory_import_backup_/,再覆盖同名文件、补齐新文件。 + temp/model_responses/: 文件名带 pid/logid 天然唯一,只拷目标端不存在的,已存在的跳过。 + """ + src = Path(source_dir).expanduser().resolve() + dst_root = Path(ga_root).resolve() + if not src.is_dir(): + raise ValueError(f"source is not a directory: {src}") + if src == dst_root: + raise ValueError("source is the same as current GA root") + + src_mem = src / "memory" + src_resp = src / "temp" / "model_responses" + if not src_mem.is_dir() and not src_resp.is_dir(): + raise ValueError("not a GA directory (no memory/ or temp/model_responses/)") + + memory_copied = 0 + responses_copied = 0 + responses_skipped = 0 + backup_dir = "" + + # --- memory/: 备份后覆盖 --- + if src_mem.is_dir(): + dst_mem = dst_root / "memory" + if dst_mem.is_dir() and any(dst_mem.iterdir()): + ts = time.strftime("%Y%m%d_%H%M%S") + backup_root = dst_root / "temp" / f"memory_import_backup_{ts}" + backup_root.mkdir(parents=True, exist_ok=True) + shutil.copytree(dst_mem, backup_root / "memory") + backup_dir = str(backup_root) + dst_mem.mkdir(parents=True, exist_ok=True) + for item in src_mem.rglob("*"): + rel = item.relative_to(src_mem) + target = dst_mem / rel + if item.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + memory_copied += 1 + + # --- temp/model_responses/: 补齐缺失 --- + if src_resp.is_dir(): + dst_resp = dst_root / "temp" / "model_responses" + dst_resp.mkdir(parents=True, exist_ok=True) + for item in src_resp.rglob("*"): + if item.is_dir(): + continue + rel = item.relative_to(src_resp) + target = dst_resp / rel + if target.exists(): + responses_skipped += 1 + continue + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item, target) + responses_copied += 1 + + return { + "ok": True, + "memoryCopied": memory_copied, + "responsesCopied": responses_copied, + "responsesSkipped": responses_skipped, + "backupDir": backup_dir, + } + + +async def memory_import_handler(request): + data = await read_json(request) + source_dir = (data.get("sourceDir") or "").strip() + if not source_dir: + return json_ok({"ok": False, "error": "missing_sourceDir"}, status=400) + try: + result = _import_memory_from(source_dir, manager.ga_root) + result.update(manager.import_sessions(source_dir)) + except Exception as e: + return json_ok({"ok": False, "error": str(e)}, status=400) + return json_ok(result) + + +async def conductor_model_get_handler(request): + return json_ok({"model": _conductor_settings()}) + + +async def conductor_model_save_handler(request): + data = await read_json(request) + try: + llm_no = int(data.get("llmNo")) + except (TypeError, ValueError): + return json_ok({"ok": False, "error": "invalid_llmNo"}, status=400) + try: + doc = _settings_doc() + conductor = doc["conductor"] if isinstance(doc.get("conductor"), dict) else {} + conductor["llmNo"] = llm_no + doc["conductor"] = conductor + _write_settings_doc(doc) + except Exception as e: + return json_ok({"ok": False, "error": str(e)}, status=500) + return json_ok({"ok": True, "model": conductor}) + + async def service_start_handler(request): body = await read_json(request) sid = body.get("id") or request.query.get("id") @@ -1781,6 +2118,7 @@ def create_app(): app.router.add_get("/session/{sid}/plan", plan_handler) app.router.add_post("/session/{sid}/cancel", cancel_handler) app.router.add_post("/session/{sid}/restore", restore_handler) + app.router.add_post("/session/{sid}/model", session_model_handler) app.router.add_post("/path/open", path_open_handler) app.router.add_post("/upload", upload_handler) app.router.add_delete("/upload", upload_delete_handler) @@ -1794,6 +2132,9 @@ def create_app(): app.router.add_get("/services/panel", service_panel_handler) app.router.add_get("/services/mykey", mykey_get_handler) app.router.add_post("/services/mykey", mykey_save_handler) + app.router.add_post("/memory/import", memory_import_handler) + app.router.add_get("/services/conductor/model", conductor_model_get_handler) + app.router.add_post("/services/conductor/model", conductor_model_save_handler) app.router.add_post("/services/stop-extras", stop_extras_handler) app.router.add_post("/services/start-extras", start_extras_handler) app.router.add_get("/services/identity", identity_handler) diff --git a/frontends/ga_contract_probe.py b/frontends/ga_contract_probe.py new file mode 100644 index 000000000..ffa8dd366 --- /dev/null +++ b/frontends/ga_contract_probe.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""GenericAgent core contract probe (design 三: bundle bridge/frontend + external核). + +Given a target ga_root, verify the external核 satisfies the symbol/signature contract that +the desktop bridge + conductor + cost_tracker rely on — WITHOUT instantiating GenericAgent +(that would read mykey and build LLM clients). We only: + - import the核 modules against `ga_root` (this doubles as a dependency check: if the bundle + python can't import the核's deps, the import fails and we report incompatible), and + - introspect the GenericAgent class + module-level functions via `inspect`. + +Note: importing agentmain/llmcore can still run module top-level create-if-missing +initializers in the target核 (for example seeding default memory/config files). The probe +does not intentionally overwrite existing files and never constructs a GenericAgent instance. + +Runtime-only surface (instance attrs like llmclient.backend.history, llmclients, task_queue, +handler.working, backend.current_name) cannot be checked statically and is intentionally not +probed here; it is covered by the "核 ≈ upstream" compatibility and graceful degrade. + +Usage: python ga_contract_probe.py +Output: a single JSON line on stdout, e.g. {"ok": true, "missing": []} + or {"ok": false, "missing": ["GenericAgent.put_task(source=)"], "error": "..."} +Exit code: 0 when ok, 1 otherwise (stdout JSON is authoritative either way). +""" +import sys, os, json, inspect + + +def _probe(ga_root: str) -> dict: + missing = [] + ga_root = os.path.abspath(os.path.expanduser(ga_root)) + if not os.path.exists(os.path.join(ga_root, "agentmain.py")): + return {"ok": False, "missing": [], "error": f"agentmain.py not found under {ga_root}"} + + if ga_root not in sys.path: + sys.path.insert(0, ga_root) + + # Import the核 (also a dependency check for the running python). + try: + import agentmain + except Exception as e: + return {"ok": False, "missing": [], "error": f"import agentmain failed: {e!r}"} + try: + import llmcore + except Exception as e: + return {"ok": False, "missing": [], "error": f"import llmcore failed: {e!r}"} + + GA = getattr(agentmain, "GenericAgent", None) + if GA is None: + return {"ok": False, "missing": ["agentmain.GenericAgent"], "error": ""} + + # GenericAgent methods the bridge/conductor call. + for m in ("run", "put_task", "next_llm", "load_llm_sessions", "get_llm_name", "abort"): + if not callable(getattr(GA, m, None)): + missing.append(f"GenericAgent.{m}()") + + # Signature-level checks (not just names). + put_task = getattr(GA, "put_task", None) + if callable(put_task): + try: + params = inspect.signature(put_task).parameters + # bridge calls put_task(prompt, images=[]); conductor calls put_task(msg, source=...) + if "source" not in params: + missing.append("GenericAgent.put_task(source=)") + if "images" not in params: + missing.append("GenericAgent.put_task(images=)") + except (ValueError, TypeError): + pass # builtins/opaque signatures: skip rather than false-fail + + get_llm_name = getattr(GA, "get_llm_name", None) + if callable(get_llm_name): + try: + if "model" not in inspect.signature(get_llm_name).parameters: + missing.append("GenericAgent.get_llm_name(model=)") + except (ValueError, TypeError): + pass + + # llmcore module-level functions the bridge + cost_tracker depend on. + if not callable(getattr(llmcore, "reload_mykeys", None)): + missing.append("llmcore.reload_mykeys()") + rec = getattr(llmcore, "_record_usage", None) + if not callable(rec): + missing.append("llmcore._record_usage()") + else: + try: + # cost_tracker wraps _record_usage(usage, api_mode) + if len(inspect.signature(rec).parameters) < 2: + missing.append("llmcore._record_usage(usage, api_mode)") + except (ValueError, TypeError): + pass + + return {"ok": not missing, "missing": missing, "error": ""} + + +def main(): + if len(sys.argv) < 2: + print(json.dumps({"ok": False, "missing": [], "error": "usage: ga_contract_probe.py "})) + sys.exit(1) + try: + result = _probe(sys.argv[1]) + except Exception as e: + result = {"ok": False, "missing": [], "error": f"probe crashed: {e!r}"} + print(json.dumps(result, ensure_ascii=False)) + sys.exit(0 if result.get("ok") else 1) + + +if __name__ == "__main__": + main() diff --git a/frontends/plan_state.py b/frontends/plan_state.py index bab87ee98..6b75e8fa6 100644 --- a/frontends/plan_state.py +++ b/frontends/plan_state.py @@ -200,14 +200,20 @@ def _msg_content(m) -> str: return c if isinstance(c, str) else "" +def _msg_role(m) -> str: + if isinstance(m, dict): return str(m.get("role") or "") + return str(getattr(m, "role", "") or "") + + def plan_path_mention_in_messages(messages, start_idx: int = 0) -> Optional[str]: + """Latest assistant/tool `enter_plan_mode(...)` path; plain chat text is not a signal.""" for m in reversed(_slice(messages, start_idx)): + if _msg_role(m) == "user": + continue text = _msg_content(m) if not text: continue if "enter_plan_mode" in text and (hit := _ENTER_PLAN_RE.search(text)): return hit.group(1).strip().strip("\"'") - if "plan.md" in text and (hits := _PATH_RE.findall(text)): - return hits[-1].strip().strip("\"'") return None @@ -222,6 +228,8 @@ def _resolve_stashed_at(p: str, root: str) -> Optional[str]: def _find_path_at(messages, start_idx: int, root: str) -> Optional[str]: for m in reversed(_slice(messages, start_idx)): + if _msg_role(m) == "user": + continue text = _msg_content(m) if not text or "plan.md" not in text: continue for hit in reversed(_PATH_RE.findall(text)): @@ -229,32 +237,45 @@ def _find_path_at(messages, start_idx: int, root: str) -> Optional[str]: return None -def default_session_plan_path(session_id: str) -> str: - sid = (session_id or "sess").replace("/", "_") - return f"temp/plan_{sid}/plan.md" +def _store_plan_path(path: str, root: str) -> str: + p = (path or "").strip() + if not p: + return "" + if os.path.isabs(p) and root: + for base in (os.path.join(root, "temp"), root): + try: + rel = os.path.relpath(p, base) + except ValueError: + continue + if rel and not rel.startswith(".."): + return rel.replace("\\", "/") + return p.replace("\\", "/").lstrip("./") -def is_session_scoped_plan_path(path: str, session_id: str) -> bool: - """Desktop: only bind/adopt paths under this session's plan_{id}/ tree.""" - if not path: - return False - sid = (session_id or "sess").replace("/", "_") - norm = path.replace("\\", "/").lstrip("./") - return f"plan_{sid}/" in norm or norm.endswith(f"plan_{sid}/plan.md") +def is_plan_mode_path(path: str) -> bool: + norm = (path or "").replace("\\", "/").lstrip("./") + return re.fullmatch(r"(?:temp/)?plan_[A-Za-z0-9_\-]+/plan\.md", norm) is not None -def is_plan_preset_prompt(prompt: str) -> bool: - p = (prompt or "").lower() - return "plan_sop" in p or "plan 模式" in p or "plan mode" in p - - -def bind_plan_session(sess: Any, prompt: str = "", path: Optional[str] = None) -> str: - """Bind plan card to this session only (avoids sharing plan_demo/ across sessions).""" - rel = (path or "").strip() or default_session_plan_path(getattr(sess, "id", "") or "") - sess.plan_path = rel - msgs = list(getattr(sess, "messages", []) or []) - sess.plan_scan_baseline = len(msgs) - return rel +def ensure_agent_plan_mode(sess: Any, root: str) -> None: + agent = getattr(sess, "agent", None) + bound = (getattr(sess, "plan_path", "") or "").strip() + if not agent or not bound or _stashed_plan_path(agent): + return + if not _resolve_stashed_at(bound, root): + return + for src in (getattr(agent, "handler", None), agent): + if not src: + continue + enter = getattr(src, "enter_plan_mode", None) + if callable(enter): + enter(bound) + return + for src in (getattr(agent, "handler", None), agent): + working = getattr(src, "working", None) + if isinstance(working, dict): + working["in_plan_mode"] = bound + return def sync_plan_path_from_text(sess: Any, text: str, root: str) -> None: @@ -267,23 +288,20 @@ def sync_plan_path_from_text(sess: Any, text: str, root: str) -> None: raw = m.group(1).strip().strip("\"'") if not raw: return - sess.plan_path = raw.lstrip("./") + sess.plan_path = _store_plan_path(raw, root) def session_plan_active(sess: Any, agent, messages, start_idx: int, root: str) -> bool: """Desktop: active when this session has a bound plan_path (not global plan_*/ scan).""" bound = (getattr(sess, "plan_path", None) or "").strip() - if not bound: - return False if _stashed_plan_path(agent): return True + if not bound: + return False if _resolve_stashed_at(bound, root): return True if getattr(sess, "status", "") == "running": return True - # Plan preset bound this session — keep placeholder until plan.md appears or path changes - if is_session_scoped_plan_path(bound, getattr(sess, "id", "")): - return True return False @@ -315,15 +333,24 @@ def desktop_plan_payload_from_session(sess: Any, ga_root: str = "") -> dict: partial = getattr(sess, "partial", None) if isinstance(partial, dict) and isinstance(partial.get("content"), str): sync_plan_path_from_text(sess, partial["content"], root) - sid = getattr(sess, "id", "") or "" + live_path = _stashed_plan_path(agent) + if live_path and not (getattr(sess, "plan_path", None) or "").strip(): + sess.plan_path = _store_plan_path(live_path, root) + if not getattr(sess, "plan_scan_baseline", 0): + sess.plan_scan_baseline = len(raw) if not (getattr(sess, "plan_path", None) or "").strip(): mentioned = plan_path_mention_in_messages(raw, base) - if mentioned and is_session_scoped_plan_path(mentioned, sid): - sess.plan_path = mentioned.lstrip("./") + if mentioned: + sess.plan_path = _store_plan_path(mentioned, root) else: - return {"active": False} + found = _find_path_at(raw, base, root) + if found: + sess.plan_path = _store_plan_path(found, root) + else: + return {"active": False} if not session_plan_active(sess, agent, raw, base, root): return {"active": False} + ensure_agent_plan_mode(sess, root) bound = getattr(sess, "plan_path", "") or "" path = _desktop_resolve_plan_file(sess, bound, root, agent, raw, base) items = [] diff --git a/ga.py b/ga.py index c6d4ccfd9..a652868ba 100644 --- a/ga.py +++ b/ga.py @@ -426,7 +426,7 @@ def do_file_read(self, args, response): next_prompt += "\n[SYSTEM TIPS] 正在读取记忆或SOP文件,若决定按sop执行请提取sop中的关键点(特别是靠后的)update working memory." return StepOutcome(result, next_prompt=next_prompt) - def export_history(self, fn): + def export_history(self, fn): with open(fn, 'w', encoding='utf-8') as f: json.dump(self.parent.llmclient.backend.history, f, ensure_ascii=False) def enter_project_mode(self, name): self.parent._ga_project_mode_name = name def _in_plan_mode(self): return self.working.get('in_plan_mode') diff --git a/mykey_template.py b/mykey_template.py index 4907c686c..d9c044e50 100644 --- a/mykey_template.py +++ b/mykey_template.py @@ -156,7 +156,6 @@ # 'stream': False, # 某些渠道不支持 SSE 流式时改 False # # 'user_agent': 'claude-cli/2.1.113 (external, cli)', # } - # ── 1b. Anthropic 官方直连 ────────────────────────────────────────────────── # 官方端点,apikey 以 sk-ant- 开头 → 自动切到 x-api-key 鉴权。 # 真 Anthropic 端点不需要 fake_cc_system_prompt。 @@ -311,4 +310,3 @@ # 'secret_key': 'sk-lf-...', # 'host': 'https://cloud.langfuse.com', # 或自托管地址 # } -