diff --git a/.gitignore b/.gitignore index de10c0b7..c64230cc 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,12 @@ dist/ build/ *.egg-info/ .cow.pid + +# Desktop backend packaging: keep the source files (spec/requirements/script) +# tracked even though the generic build/ rule above ignores them, but never +# track the build outputs or local venv. +!desktop/build/ +desktop/build/* +!desktop/build/cowagent-backend.spec +!desktop/build/requirements-desktop.txt +!desktop/build/build-backend.sh diff --git a/desktop/build/build-backend.sh b/desktop/build/build-backend.sh new file mode 100755 index 00000000..940c1afa --- /dev/null +++ b/desktop/build/build-backend.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# Build the desktop backend into a self-contained onedir bundle via PyInstaller. +# Run from anywhere; paths are resolved relative to the repo root. +# +# Usage: +# bash desktop/build/build-backend.sh # build +# PYTHON=python3.11 bash desktop/build/build-backend.sh # pick interpreter +# +# Output: desktop/build/dist/cowagent-backend/ (folder with the executable) +set -euo pipefail + +# --- resolve paths -------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BUILD_DIR="$SCRIPT_DIR" +VENV_DIR="$BUILD_DIR/.venv-build" + +# Prefer Python 3.11 when available: on 3.13+ web.py must be installed from a +# GitHub git source (the PyPI build fails), which is flaky on some networks. +# 3.11 installs web.py straight from PyPI and has the best PyInstaller support. +if [ -z "${PYTHON:-}" ]; then + for cand in \ + "/Library/Frameworks/Python.framework/Versions/3.11/bin/python3.11" \ + "python3.11" \ + "python3.12" \ + "python3"; do + if command -v "$cand" >/dev/null 2>&1; then + PYTHON="$cand" + break + fi + done +fi +# Prefer Python 3.11: it installs web.py from PyPI (no GitHub clone) and avoids +# 3.13's removed-cgi compatibility shims. Override with PYTHON=... if needed. +pick_python() { + if [ -n "${PYTHON:-}" ]; then echo "$PYTHON"; return; fi + for c in python3.11 python3.12 python3.10 python3; do + if command -v "$c" >/dev/null 2>&1; then echo "$c"; return; fi + done + echo python3 +} +PYTHON="$(pick_python)" + +echo "==> Repo root: $ROOT" +echo "==> Using Python: $($PYTHON --version 2>&1) ($PYTHON)" + +# --- isolated build venv -------------------------------------------------- +if [ ! -d "$VENV_DIR" ]; then + echo "==> Creating build venv at $VENV_DIR" + "$PYTHON" -m venv "$VENV_DIR" +fi +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" + +echo "==> Installing build dependencies" +pip install -q --upgrade pip +# Don't leave a half-populated venv behind if deps fail (e.g. flaky network): +# the next run would otherwise reuse a broken venv. +if ! pip install -q -r "$BUILD_DIR/requirements-desktop.txt"; then + echo "!! Dependency install failed. Removing the build venv so a retry starts clean." >&2 + deactivate || true + rm -rf "$VENV_DIR" + exit 1 +fi +pip install -q pyinstaller + +# --- run pyinstaller from repo root so relative datas resolve ------------- +cd "$ROOT" +echo "==> Running PyInstaller (onedir)" +pyinstaller "$BUILD_DIR/cowagent-backend.spec" \ + --noconfirm \ + --distpath "$BUILD_DIR/dist" \ + --workpath "$BUILD_DIR/build-work" + +echo "" +echo "==> Done. Bundle at: $BUILD_DIR/dist/cowagent-backend/" +du -sh "$BUILD_DIR/dist/cowagent-backend/" 2>/dev/null || true +echo "==> Smoke test: COW_DESKTOP=1 \"$BUILD_DIR/dist/cowagent-backend/cowagent-backend\"" diff --git a/desktop/build/cowagent-backend.spec b/desktop/build/cowagent-backend.spec new file mode 100644 index 00000000..9e20841e --- /dev/null +++ b/desktop/build/cowagent-backend.spec @@ -0,0 +1,145 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec for the CowAgent desktop backend (onedir). + +Produces a self-contained `cowagent-backend` folder that the Electron app +spawns directly, so end users don't need Python installed. + +onedir is chosen over onefile because the backend reads data files via paths +relative to the source tree (e.g. config-template.json, skills/, chat.html); +onedir preserves that layout, while onefile's temp-extraction would break it. + +Build from the repo root: + pyinstaller desktop/build/cowagent-backend.spec --noconfirm +""" +import os +from PyInstaller.utils.hooks import collect_submodules, collect_data_files + +# Resolve the repo root from the spec's own location (desktop/build/ -> root), +# independent of the current working directory. PyInstaller exposes SPECPATH. +ROOT = os.path.abspath(os.path.join(SPECPATH, '..', '..')) + + +def rp(*parts): + """Absolute path under the repo root.""" + return os.path.join(ROOT, *parts) + +# --- Hidden imports ------------------------------------------------------- +# Channels are imported dynamically by channel_factory via string names, so +# PyInstaller's static analysis can't see them. List every channel we ship +# (Feishu is intentionally excluded — lark-oapi is dropped from the desktop +# build to save ~116MB). +hiddenimports = [ + # channels (dynamic import in channel/channel_factory.py) + 'channel.web.web_channel', + 'channel.terminal.terminal_channel', + 'channel.weixin.weixin_channel', + 'channel.wechatmp.wechatmp_channel', + 'channel.wechatcom.wechatcomapp_channel', + 'channel.wechat_kf.wechat_kf_channel', + 'channel.dingtalk.dingtalk_channel', + 'channel.wecom_bot.wecom_bot_channel', + 'channel.qq.qq_channel', + 'channel.telegram.telegram_channel', + 'channel.slack.slack_channel', + 'channel.discord.discord_channel', +] + +# Agent tools and model providers are imported lazily in places; collect their +# submodules so nothing is missed at runtime. +hiddenimports += collect_submodules('agent.tools') +hiddenimports += collect_submodules('models') +hiddenimports += collect_submodules('voice') +hiddenimports += collect_submodules('bridge') + +# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`, +# so the framework package must be present even though desktop mode never loads +# actual plugins (it's only ~tens of KB of code). +hiddenimports += [ + 'plugins', + 'plugins.event', + 'plugins.plugin', + 'plugins.plugin_manager', +] + +# Third-party SDKs that use lazy/conditional imports internally. +hiddenimports += collect_submodules('dashscope') +hiddenimports += [ + 'tiktoken_ext', + 'tiktoken_ext.openai_public', +] + +# --- Data files ----------------------------------------------------------- +# Runtime-read files/dirs that must travel with the executable. Paths are +# (source, dest_dir_in_bundle). +datas = [ + (rp('config-template.json'), '.'), + (rp('skills'), 'skills'), + # Web console served on the backend port: ship chat.html plus its static + # assets (~1.9MB) so the browser-based console works as a debug/fallback + # entry alongside the Electron UI. + (rp('channel', 'web', 'chat.html'), 'channel/web'), + (rp('channel', 'web', 'static'), 'channel/web/static'), +] + +# Some libraries (tiktoken encodings, etc.) ship data files. +datas += collect_data_files('tiktoken_ext', include_py_files=False) + +# --- Excludes ------------------------------------------------------------- +# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop +# mode), tests/docs, and dev-only packages. +excludes = [ + 'lark_oapi', # Feishu — ~116MB, excluded from desktop build + 'tests', + 'pip', + 'wheel', + 'pytest', + 'playwright', # browser tool is opt-in, not bundled +] + +block_cipher = None + +a = Analysis( + [rp('app.py')], + pathex=[ROOT], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=excludes, + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name='cowagent-backend', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name='cowagent-backend', +) diff --git a/desktop/build/requirements-desktop.txt b/desktop/build/requirements-desktop.txt new file mode 100644 index 00000000..dc6c0776 --- /dev/null +++ b/desktop/build/requirements-desktop.txt @@ -0,0 +1,52 @@ +# Desktop backend dependencies (slimmed down from the full requirements). +# +# Goal: keep the package light. The desktop client only needs the web channel +# (which Electron talks to) plus the agent core; the remaining IM channels are +# cheap (~27MB total) so we keep them, but Feishu's `lark-oapi` (~116MB) is +# dropped — it is by far the heaviest dependency and not needed for a C-end +# desktop app. Feishu is hidden in desktop mode (see COW_DESKTOP in app.py). + +# ---- core ---- +numpy>=1.21 +aiohttp>=3.8.6,<3.10 +requests>=2.28.2 +chardet>=5.1.0 +Pillow +python-dotenv>=1.0.0 +PyYAML>=6.0 +croniter>=2.0.0 +click>=8.0 +qrcode +json-repair + +# ---- web framework (web channel) ---- +# web.py 0.62 fails to build on Python 3.13+ (cgi removed); use the GitHub fix. +web.py; python_version < "3.13" +web.py @ git+https://github.com/webpy/webpy.git ; python_version >= "3.13" +legacy-cgi; python_version >= "3.13" + +# ---- AI model SDKs ---- +zai-sdk +dashscope +tenacity # used by some dashscope submodules (retry logic) +google-generativeai +tiktoken>=0.3.2 + +# ---- voice (TTS/ASR) — kept per product decision ---- +pydub>=0.25.1 +gTTS>=2.3.1 + +# ---- document parsing (web_fetch / knowledge) ---- +pypdf +python-docx +openpyxl +python-pptx + +# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ---- +wechatpy +pycryptodome +dingtalk_stream +websocket-client>=1.4.0 +python-telegram-bot +slack_bolt +discord.py