From fff2af9c26879a1441a06d40e606002ebc574713 Mon Sep 17 00:00:00 2001 From: Liang Jiaqing Date: Fri, 22 May 2026 10:04:40 +0800 Subject: [PATCH] add supergrok_proxy --- assets/install-macos-app.sh | 179 ------------ assets/install_python_windows.bat | 111 -------- assets/supergrok_proxy.py | 390 ++++++++++++++++++++++++++ docs/macos_desktop_installation_zh.md | 85 ++++++ frontends/stapp.py | 23 +- tests/test_tgapp_inline_selection.py | 234 ---------------- 6 files changed, 486 insertions(+), 536 deletions(-) delete mode 100755 assets/install-macos-app.sh delete mode 100644 assets/install_python_windows.bat create mode 100644 assets/supergrok_proxy.py create mode 100644 docs/macos_desktop_installation_zh.md delete mode 100644 tests/test_tgapp_inline_selection.py diff --git a/assets/install-macos-app.sh b/assets/install-macos-app.sh deleted file mode 100755 index e438af58..00000000 --- a/assets/install-macos-app.sh +++ /dev/null @@ -1,179 +0,0 @@ -#!/bin/bash - -# GenericAgent macOS Desktop App Installation Script -# -# Usage: -# bash assets/install-macos-app.sh [--auto] -# -# This installer creates a small .app bundle that opens Terminal and runs -# `python3 launch.pyw` from the current GenericAgent checkout. - -if [ -z "${BASH_VERSION}" ]; then - if command -v bash >/dev/null 2>&1; then - exec bash -- "${0}" "$@" - else - echo "Error: This script requires bash." - exit 1 - fi -fi - -set -euo pipefail - -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m' -log_info() { echo -e "${BLUE}ℹ️ $1${NC}"; } -log_success() { echo -e "${GREEN}✅ $1${NC}"; } -log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; } -log_error() { echo -e "${RED}❌ $1${NC}"; } - -AUTO_MODE=false -for arg in "$@"; do - case "$arg" in - --auto) AUTO_MODE=true ;; - esac -done - -APP_NAME="GenericAgent" -PRIMARY_INSTALL_DIR="/Applications" -FALLBACK_INSTALL_DIR="${HOME}/Applications" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" -ICON_PATH="${PROJECT_ROOT}/assets/images/logo.jpg" -LAUNCH_SCRIPT="${PROJECT_ROOT}/launch.pyw" - -echo -e "${CYAN}" -echo "╔═══════════════════════════════════════════════════════════╗" -echo "║ GenericAgent — macOS Desktop App Installer ║" -echo "╚═══════════════════════════════════════════════════════════╝" -echo -e "${NC}" - -if [[ "$(uname)" != "Darwin" ]]; then - log_error "This script only supports macOS." - exit 1 -fi - -if ! command -v python3 >/dev/null 2>&1; then - log_error "python3 is not installed." - exit 1 -fi - -if [ ! -f "${LAUNCH_SCRIPT}" ]; then - log_error "launch.pyw not found at ${LAUNCH_SCRIPT}" - exit 1 -fi - -project_path_for_applescript="${PROJECT_ROOT}/" -project_path_for_applescript="${project_path_for_applescript//\\/\\\\}" -project_path_for_applescript="${project_path_for_applescript//\"/\\\"}" - -detect_existing_app() { - if [ -d "${PRIMARY_INSTALL_DIR}/${APP_NAME}.app" ]; then - echo "${PRIMARY_INSTALL_DIR}/${APP_NAME}.app" - return - fi - if [ -d "${FALLBACK_INSTALL_DIR}/${APP_NAME}.app" ]; then - echo "${FALLBACK_INSTALL_DIR}/${APP_NAME}.app" - return - fi -} - -existing_app_path="$(detect_existing_app || true)" -if [ -n "${existing_app_path}" ]; then - log_warning "${APP_NAME}.app already exists at ${existing_app_path}" -fi - -if [ "${AUTO_MODE}" = false ]; then - echo "" - echo "This will install a desktop app that launches GenericAgent" - echo "from Spotlight, Launchpad, or the Applications folder." - echo "" - if [ -n "${existing_app_path}" ]; then - read -p "Reinstall ${APP_NAME}.app? (y/N) " -n 1 -r - else - read -p "Continue? (Y/n) " -n 1 -r - fi - echo - if [ -n "${existing_app_path}" ]; then - [[ ! ${REPLY:-} =~ ^[Yy]$ ]] && { echo "Aborted."; exit 0; } - else - [[ ${REPLY:-} =~ ^[Nn]$ ]] && { echo "Aborted."; exit 0; } - fi -fi - -TMP_DIR="$(mktemp -d)" -trap 'rm -rf "${TMP_DIR}"' EXIT - -log_info "Building ${APP_NAME}.app..." - -cat > "${TMP_DIR}/${APP_NAME}.applescript" </dev/null 2>&1 - sips -z 32 32 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_16x16@2x.png" >/dev/null 2>&1 - sips -z 32 32 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_32x32.png" >/dev/null 2>&1 - sips -z 64 64 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_32x32@2x.png" >/dev/null 2>&1 - sips -z 128 128 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_128x128.png" >/dev/null 2>&1 - sips -z 256 256 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_128x128@2x.png" >/dev/null 2>&1 - sips -z 256 256 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_256x256.png" >/dev/null 2>&1 - sips -z 512 512 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_256x256@2x.png" >/dev/null 2>&1 - sips -z 512 512 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_512x512.png" >/dev/null 2>&1 - cp "${ICON_PATH}" "${ICONSET_DIR}/icon_512x512@2x.png" - - iconutil -c icns "${ICONSET_DIR}" -o "${TMP_DIR}/ga-icon.icns" - cp "${TMP_DIR}/ga-icon.icns" "${TMP_DIR}/${APP_NAME}.app/Contents/Resources/applet.icns" - log_success "Icon applied from assets/images/logo.jpg" -else - log_warning "Logo not found at ${ICON_PATH}, using default icon." -fi - -install_bundle() { - local install_dir="$1" - local destination="${install_dir}/${APP_NAME}.app" - mkdir -p "${install_dir}" - rm -rf "${destination}" - cp -R "${TMP_DIR}/${APP_NAME}.app" "${destination}" -} - -install_path="" -if install_bundle "${PRIMARY_INSTALL_DIR}" 2>/dev/null; then - install_path="${PRIMARY_INSTALL_DIR}/${APP_NAME}.app" -else - log_warning "Could not write to ${PRIMARY_INSTALL_DIR}; falling back to ${FALLBACK_INSTALL_DIR}" - install_bundle "${FALLBACK_INSTALL_DIR}" - install_path="${FALLBACK_INSTALL_DIR}/${APP_NAME}.app" -fi - -log_success "Installed to: ${install_path}" - -echo "" -echo -e "${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}" -echo -e "${CYAN}║${NC} ✨ ${APP_NAME} Desktop App installed successfully! ${CYAN}║${NC}" -echo -e "${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}" -echo "" -echo -e "${BLUE}Launch methods:${NC}" -echo " • Spotlight: Cmd + Space → type '${APP_NAME}' → Enter" -echo " • Launchpad: Find the '${APP_NAME}' icon" -echo " • Finder: Open ${install_path}" -echo "" -echo -e "${BLUE}Runtime behavior:${NC}" -echo " The app uses the current checkout path embedded at install time:" -echo " ${PROJECT_ROOT}" -echo " If you move the repo later, re-run this installer." -echo "" -echo -e "${BLUE}Uninstall:${NC}" -echo " rm -rf '${install_path}'" -echo "" diff --git a/assets/install_python_windows.bat b/assets/install_python_windows.bat deleted file mode 100644 index b2873705..00000000 --- a/assets/install_python_windows.bat +++ /dev/null @@ -1,111 +0,0 @@ -@echo off -setlocal enabledelayedexpansion -title Python One-Click Installer -color 0A - -echo. -echo ======================================== -echo Python One-Click Installer (Windows) -echo ======================================== -echo. - -net session >nul 2>&1 -if %errorlevel% neq 0 ( - echo [!] Administrator privileges required. Restarting with elevation... - powershell -Command "Start-Process '%~f0' -Verb RunAs" - exit /b -) - -echo [OK] Administrator privileges confirmed -echo. - -python --version >nul 2>&1 -if %errorlevel% equ 0 ( - echo [OK] Python already installed: - python --version - echo. - choice /C YN /M "Install latest version anyway? (Y=Yes / N=Exit)" - if errorlevel 2 goto :end -) - -set PYTHON_VERSION=3.12.9 -set MIRROR_URL=https://npmmirror.com/mirrors/python/3.12.9/python-3.12.9-amd64.exe -set OFFICIAL_URL=https://www.python.org/ftp/python/3.12.9/python-3.12.9-amd64.exe -set INSTALLER=%TEMP%\python_installer.exe - -echo [*] Preparing to download Python %PYTHON_VERSION% -echo [*] Trying mirror source first... -echo. - -powershell -NoProfile -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '%MIRROR_URL%' -OutFile '%INSTALLER%' -UseBasicParsing" - -if not exist "%INSTALLER%" goto :official -for %%A in ("%INSTALLER%") do if %%~zA lss 1000000 goto :official -echo [OK] Mirror download complete -goto :install - -:official -echo [!] Mirror failed, switching to official source... -powershell -NoProfile -Command "[Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; $ProgressPreference='SilentlyContinue'; Invoke-WebRequest -Uri '%OFFICIAL_URL%' -OutFile '%INSTALLER%' -UseBasicParsing" - -if not exist "%INSTALLER%" ( - echo [x] Download failed. Please check your network connection and retry. - pause - goto :end -) -for %%A in ("%INSTALLER%") do if %%~zA lss 1000000 ( - echo [x] Downloaded file is incomplete. Please check your network and retry. - pause - goto :end -) -echo [OK] Official source download complete - -:install -echo. -echo [*] Installing Python %PYTHON_VERSION% (this may take 2-5 minutes^)... -echo. - -start /wait "" "%INSTALLER%" /passive InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 - -set INSTALL_CODE=%errorlevel% -del /f /q "%INSTALLER%" >nul 2>&1 - -if %INSTALL_CODE% neq 0 ( - echo [x] Installation failed with error code: %INSTALL_CODE% - pause - goto :end -) - -echo [+] Installation complete! -echo. - -timeout /t 3 /nobreak >nul - -set "PATH=C:\Program Files\Python312;C:\Program Files\Python312\Scripts;%PATH%" - -python --version >nul 2>&1 -if %errorlevel% equ 0 ( - echo [OK] Python installed successfully: - python --version - echo. - echo [OK] pip version: - pip --version - echo. - echo [*] Configuring pip mirror (Tsinghua^)... - pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple - pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn - echo. - echo [*] Installing requests... - pip install requests - echo. - echo ======================================== - echo All done! Open a new terminal to use - echo python and pip commands. - echo ======================================== -) else ( - echo [!] PATH not yet refreshed. Please close this window and open a new terminal. -) - -:end -echo. -pause diff --git a/assets/supergrok_proxy.py b/assets/supergrok_proxy.py new file mode 100644 index 00000000..41e411be --- /dev/null +++ b/assets/supergrok_proxy.py @@ -0,0 +1,390 @@ +""" +SuperGrok Local Proxy - CLI +本地 OpenAI 兼容代理,通过 xAI OAuth (PKCE) 登录 SuperGrok,自动管理 token 并转发请求。 + +用法: + python assets/supergrok_proxy.py login + python assets/supergrok_proxy.py serve --port 15433 + python assets/supergrok_proxy.py models + python assets/supergrok_proxy.py test --model grok-4.3 + +GenericAgent/mykey 配置示例: + native_oai_config_supergrok_proxy = { + 'name': 'supergrok', + 'apikey': 'dummy', + 'apibase': 'http://127.0.0.1:15433/v1', + 'model': 'grok-4.3', + 'max_retries': 3, + 'read_timeout': 600, + 'stream': False, + } +""" +from __future__ import annotations +import argparse +import base64 +import hashlib +import json +import os +import secrets +import threading +import time +import uuid +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer, HTTPServer +from urllib.parse import urlencode, urlparse, parse_qs + +import requests + +XAI_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828' +XAI_AUTHORIZE_URL = 'https://auth.x.ai/oauth2/authorize' +XAI_TOKEN_URL = 'https://auth.x.ai/oauth2/token' +XAI_SCOPE = 'openid profile email offline_access grok-cli:access api:access' +XAI_CALLBACK_PORT = 56121 +XAI_CALLBACK_URI = f'http://127.0.0.1:{XAI_CALLBACK_PORT}/callback' +XAI_API_BASE = 'https://api.x.ai/v1' +DEFAULT_STORE = os.path.join(os.path.expanduser('~'), '.genericagent', 'xai_oauth.json') +DEFAULT_PROXY_PORT = 15433 +REFRESH_MARGIN = 120 + + +def log(msg): + print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +def b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode().rstrip('=') + + +def make_proxies(proxy: str | None): + if not proxy: + return None + return {'http': proxy, 'https': proxy} + + +class TokenManager: + def __init__(self, store_path=DEFAULT_STORE, proxy='http://127.0.0.1:2082'): + self.store_path = os.path.expanduser(store_path) + self.proxy = proxy or None + self.proxies = make_proxies(self.proxy) + self.lock = threading.Lock() + self.access_token = None + self.refresh_token = None + self.expires_at = 0.0 + self.load() + + def load(self): + try: + with open(self.store_path, encoding='utf-8') as f: + data = json.load(f) + self.access_token = data.get('access_token') + self.refresh_token = data.get('refresh_token') + self.expires_at = float(data.get('expires_at') or 0) + if self.access_token: + remain = int(self.expires_at - time.time()) + log(f"Token loaded: ***{self.access_token[-6:]} expires_in={remain}s") + except FileNotFoundError: + log(f"No saved token: {self.store_path}") + except Exception as e: + log(f"Failed to load token: {e}") + + def save(self, data): + os.makedirs(os.path.dirname(self.store_path), exist_ok=True) + with open(self.store_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + try: + os.chmod(self.store_path, 0o600) + except OSError: + pass + log(f"Token saved: {self.store_path}") + + def login(self, open_browser=True, timeout=300): + verifier = b64url(secrets.token_bytes(32)) + challenge = b64url(hashlib.sha256(verifier.encode()).digest()) + state = secrets.token_urlsafe(24) + nonce = secrets.token_urlsafe(24) + params = { + 'client_id': XAI_CLIENT_ID, + 'redirect_uri': XAI_CALLBACK_URI, + 'response_type': 'code', + 'scope': XAI_SCOPE, + 'state': state, + 'nonce': nonce, + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + 'plan': 'generic', + 'referrer': 'generic-agent', + } + auth_url = f'{XAI_AUTHORIZE_URL}?{urlencode(params)}' + result = {} + + class CallbackHandler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def do_GET(self): + qs = parse_qs(urlparse(self.path).query) + result.update({k: v[0] for k, v in qs.items() if v}) + self.send_response(200) + self.send_header('Content-Type', 'text/html; charset=utf-8') + self.end_headers() + self.wfile.write('SuperGrok login complete. You can close this tab.'.encode('utf-8')) + + httpd = HTTPServer(('127.0.0.1', XAI_CALLBACK_PORT), CallbackHandler) + httpd.timeout = 1 + log(f"Callback listening: {XAI_CALLBACK_URI}") + log("Open this URL to login xAI/SuperGrok:") + print(auth_url, flush=True) + if open_browser: + try: + webbrowser.open(auth_url) + except Exception as e: + log(f"Browser open failed: {e}") + + deadline = time.time() + timeout + while not result.get('code') and time.time() < deadline: + httpd.handle_request() + httpd.server_close() + + if not result.get('code'): + raise RuntimeError(f'OAuth timeout after {timeout}s') + if result.get('state') and result['state'] != state: + raise RuntimeError('OAuth state mismatch') + + log("Exchanging authorization code for token...") + resp = requests.post(XAI_TOKEN_URL, data={ + 'grant_type': 'authorization_code', + 'client_id': XAI_CLIENT_ID, + 'code': result['code'], + 'redirect_uri': XAI_CALLBACK_URI, + 'code_verifier': verifier, + 'code_challenge': challenge, + 'code_challenge_method': 'S256', + }, proxies=self.proxies, timeout=30) + resp.raise_for_status() + data = resp.json() + data['expires_at'] = time.time() + int(data.get('expires_in', 3600)) + self.access_token = data.get('access_token') + self.refresh_token = data.get('refresh_token') + self.expires_at = data['expires_at'] + self.save(data) + log("Login OK") + return self.access_token + + def get_token(self): + with self.lock: + if self.access_token and time.time() < self.expires_at - REFRESH_MARGIN: + return self.access_token + return self.refresh() + + def refresh(self): + if not self.refresh_token: + raise RuntimeError('No refresh_token. Run login first.') + log("Refreshing token...") + resp = requests.post(XAI_TOKEN_URL, data={ + 'grant_type': 'refresh_token', + 'client_id': XAI_CLIENT_ID, + 'refresh_token': self.refresh_token, + }, proxies=self.proxies, timeout=30) + resp.raise_for_status() + data = resp.json() + data['expires_at'] = time.time() + int(data.get('expires_in', 3600)) + if 'refresh_token' not in data: + data['refresh_token'] = self.refresh_token + self.access_token = data.get('access_token') + self.refresh_token = data.get('refresh_token') + self.expires_at = data['expires_at'] + self.save(data) + log(f"Refresh OK expires_in={int(self.expires_at - time.time())}s") + return self.access_token + + +def make_proxy_handler(token_mgr: TokenManager): + class ProxyHandler(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def log_message(self, fmt, *args): + pass + + def _send_json_error(self, code, msg): + body = json.dumps({'error': {'message': str(msg), 'type': 'supergrok_proxy_error'}}).encode('utf-8') + self.send_response(code) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _target_url(self): + path = self.path + if path.startswith('/v1/'): + path = path[3:] + elif path == '/v1': + path = '' + return XAI_API_BASE + path + + def do_GET(self): + try: + token = token_mgr.get_token() + target = self._target_url() + log(f"GET {self.path} -> {target}") + resp = requests.get(target, headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/json', + 'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0', + }, proxies=token_mgr.proxies, timeout=60) + content = resp.content + self.send_response(resp.status_code) + self.send_header('Content-Type', resp.headers.get('content-type', 'application/json')) + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + log(f"RESP {resp.status_code}") + except Exception as e: + log(f"GET error: {e}") + self._send_json_error(502, e) + + def do_POST(self): + try: + length = int(self.headers.get('Content-Length', 0)) + raw = self.rfile.read(length) if length else b'{}' + try: + body = json.loads(raw.decode('utf-8')) if raw else {} + except Exception: + body = None + stream = bool(body.get('stream')) if isinstance(body, dict) else False + model = body.get('model', '?') if isinstance(body, dict) else '?' + token = token_mgr.get_token() + target = self._target_url() + log(f"POST {self.path} model={model} stream={stream}") + resp = requests.post(target, headers={ + 'Authorization': f'Bearer {token}', + 'Content-Type': self.headers.get('Content-Type', 'application/json'), + 'Accept': 'text/event-stream' if stream else 'application/json', + 'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0', + 'x-request-id': str(uuid.uuid4()), + }, data=raw, proxies=token_mgr.proxies, timeout=180, stream=stream) + + ctype = resp.headers.get('content-type', '') + if stream or 'text/event-stream' in ctype: + self.send_response(resp.status_code) + self.send_header('Content-Type', 'text/event-stream') + self.send_header('Cache-Control', 'no-cache') + self.end_headers() + for chunk in resp.iter_content(chunk_size=None): + if chunk: + self.wfile.write(chunk) + self.wfile.flush() + else: + content = resp.content + self.send_response(resp.status_code) + self.send_header('Content-Type', ctype or 'application/json') + self.send_header('Content-Length', str(len(content))) + self.end_headers() + self.wfile.write(content) + + if resp.status_code >= 400: + log(f"RESP {resp.status_code} {'' if stream else resp.text[:500]}") + else: + log(f"RESP {resp.status_code}") + except Exception as e: + log(f"POST error: {e}") + self._send_json_error(502, e) + + return ProxyHandler + + +def cmd_login(args): + TokenManager(args.store, args.upstream_proxy).login(open_browser=not args.no_browser, timeout=args.timeout) + + +def cmd_refresh(args): + TokenManager(args.store, args.upstream_proxy).refresh() + + +def cmd_serve(args): + mgr = TokenManager(args.store, args.upstream_proxy) + if not mgr.access_token: + log("No token found. Starting login first...") + mgr.login(open_browser=not args.no_browser, timeout=args.timeout) + else: + mgr.get_token() + server = ThreadingHTTPServer((args.host, args.port), make_proxy_handler(mgr)) + log(f"Serving OpenAI-compatible proxy at http://{args.host}:{args.port}/v1") + log("Press Ctrl+C to stop.") + try: + server.serve_forever() + except KeyboardInterrupt: + log("Stopping...") + finally: + server.server_close() + + +def cmd_models(args): + mgr = TokenManager(args.store, args.upstream_proxy) + token = mgr.get_token() + resp = requests.get(f'{XAI_API_BASE}/models', headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/json', + 'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0', + }, proxies=mgr.proxies, timeout=60) + print(resp.status_code) + print(resp.text) + resp.raise_for_status() + + +def cmd_test(args): + mgr = TokenManager(args.store, args.upstream_proxy) + token = mgr.get_token() + payload = {'model': args.model, 'messages': [{'role': 'user', 'content': args.prompt}], 'stream': False} + resp = requests.post(f'{XAI_API_BASE}/chat/completions', headers={ + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'GenericAgent-SuperGrok-Proxy/1.0', + }, json=payload, proxies=mgr.proxies, timeout=120) + print(resp.status_code) + print(resp.text) + resp.raise_for_status() + + +def build_parser(): + p = argparse.ArgumentParser(description='SuperGrok xAI OAuth local OpenAI-compatible proxy') + p.add_argument('--store', default=DEFAULT_STORE, help=f'token store path, default: {DEFAULT_STORE}') + p.add_argument('--upstream-proxy', default='http://127.0.0.1:2082', help='proxy for xAI auth/api; empty disables') + # Defaults for zero-argument mode: run once, login if needed, then serve. + p.set_defaults(func=cmd_serve, host='127.0.0.1', port=DEFAULT_PROXY_PORT, no_browser=False, timeout=300) + sub = p.add_subparsers(dest='cmd') + + sp = sub.add_parser('login', help='open browser and login xAI OAuth') + sp.add_argument('--no-browser', action='store_true') + sp.add_argument('--timeout', type=int, default=300) + sp.set_defaults(func=cmd_login) + + sp = sub.add_parser('refresh', help='refresh saved token') + sp.set_defaults(func=cmd_refresh) + + sp = sub.add_parser('serve', help='serve local OpenAI-compatible proxy') + sp.add_argument('--host', default='127.0.0.1') + sp.add_argument('--port', type=int, default=DEFAULT_PROXY_PORT) + sp.add_argument('--no-browser', action='store_true') + sp.add_argument('--timeout', type=int, default=300) + sp.set_defaults(func=cmd_serve) + + sp = sub.add_parser('models', help='call /v1/models directly') + sp.set_defaults(func=cmd_models) + + sp = sub.add_parser('test', help='send one chat completion request directly') + sp.add_argument('--model', default='grok-4.3') + sp.add_argument('--prompt', default='Say OK in one word.') + sp.set_defaults(func=cmd_test) + return p + + +def main(argv=None): + args = build_parser().parse_args(argv) + if args.upstream_proxy == '': + args.upstream_proxy = None + args.func(args) + + +if __name__ == '__main__': + main() diff --git a/docs/macos_desktop_installation_zh.md b/docs/macos_desktop_installation_zh.md new file mode 100644 index 00000000..71b13fc8 --- /dev/null +++ b/docs/macos_desktop_installation_zh.md @@ -0,0 +1,85 @@ +# GenericAgent 桌面版安装指南 + +## 📦 安装步骤 + +### 第一步:打开安装包 + +双击下载的 `GenericAgent_x.x.x_aarch64.dmg` 文件,会弹出一个安装窗口。 + +将左边的 **GenericAgent** 图标拖到右边的 **Applications** 文件夹图标上,等待拷贝完成。 + +拷贝完成后,可以右键点击桌面上的 DMG 图标,选择「推出」来关闭安装包。 + +--- + +### 第二步:首次打开前的准备(重要) + +由于本应用暂未通过 Apple 官方签名认证,macOS 会阻止首次打开。这是正常的安全提示,不代表应用有问题。 + +请按以下步骤解除限制: + +#### 1. 打开「终端」应用 + +不知道终端是什么?别担心,它就是一个可以输入命令的工具。打开方式: + +- 按下键盘上的 `Command(⌘) + 空格键`,会弹出搜索框(Spotlight) +- 输入 `终端` 或 `Terminal`,按回车键打开 + +你会看到一个黑色或白色的文字窗口,里面有一个闪烁的光标,这就是终端。 + +#### 2. 输入解除限制的命令 + +在终端窗口中,复制粘贴以下这行命令(整行复制,一个字都不要漏��: + +``` +xattr -cr /Applications/GenericAgent.app +``` + +粘贴方法:在终端窗口里按 `Command(⌘) + V` + +然后按 `回车键(Enter)` 执行。 + +> 如果终端要求输入密码,输入你的 Mac 开机密码(输入时不会显示任何字符,这是正常的),然后按回车。 + +执行完毕后,终端不会有任何提示,这代表成功了。 + +#### 3. 打开 GenericAgent + +现在可以正常打开应用了: + +- 打开 Finder → 侧边栏点击「应用程序」 +- 找到 **GenericAgent**,双击打开 + +首次打开可能还会弹出一个确认框,点击「打开」即可。之后就不会再弹出了。 + +--- + +## ❓ 常见问题 + +### Q: 提示「GenericAgent 已损坏,无法打开」怎么办? + +这不是真的损坏,是 macOS 的安全机制。请回到第二步,确保在终端中执行了 `xattr -cr` 命令。 + +### Q: 提示「无法打开,因为无法验证开发者」怎么办? + +方法一(推荐):执行第二步的终端命令。 + +方法二:右键点击 GenericAgent.app → 选择「打开」→ 在弹出的对话框中点击「打开」。 + +### Q: 我的 Mac 是 Intel 芯片的,能用吗? + +当前版本仅支持 Apple Silicon(M1/M2/M3/M4)芯片的 Mac。如果你的 Mac 是 2020 年之前购买的,大概率是 Intel 芯片,暂时无法使用本安装包。 + +查看方法:点击左上角 → 「关于本机」,如果芯片一栏显示 Apple M1/M2/M3/M4,就可以使用。 + +### Q: 终端命令执行后没有任何反应? + +没有反应就是成功了。Unix/macOS 的设计哲学是「没有消息就是好消息」。 + +--- + +## 🔧 系统要求 + +- macOS 12 (Monterey) 或更高版本 +- Apple Silicon 芯片(M1/M2/M3/M4) +- 约 50MB 可用磁盘空间 diff --git a/frontends/stapp.py b/frontends/stapp.py index fa8e966c..a19c134a 100644 --- a/frontends/stapp.py +++ b/frontends/stapp.py @@ -152,11 +152,7 @@ def render_segments(segments, suffix=''): if seg['type'] == 'fold': with st.expander(seg['title'], expanded=False): st.markdown(seg['content']) else: - # Strip meta tags from text segments — folded turns already - # promote them to expander titles; for the first/last segments - # they'd otherwise leak into the chat as raw text (esp. after /continue - # restores a multi-turn body). - st.markdown(_SUMMARY_TAG_RE.sub('', seg['content']) + suffix) + st.markdown(seg['content'] + suffix) def agent_backend_stream(prompt=None): """Drain main task display_queue. @@ -236,14 +232,17 @@ except (ImportError, AttributeError): from streamlit.components.v1 import html as _embed_html # ≤1.55 _js_scroll_fix = ( "!function(){var p=window.parent;if(p.__sfx2)return;p.__sfx2=1;var d=p.document;" - "function f(){var m=d.querySelector('section.main');if(!m)return;" - "var s=m.scrollTop;m.style.minHeight=m.scrollHeight+1+'px';void m.offsetHeight;" - "m.style.minHeight='';void m.offsetHeight;m.scrollTop=s}" + "var pending=0;" + "function f(){pending=0;var m=d.querySelector('section.main');if(!m)return;" + "var s=m.scrollTop,h=m.scrollHeight;" + "m.style.minHeight=h+1+'px';void m.offsetHeight;" + "m.style.minHeight='';void m.offsetHeight;" + "m.scrollTop=s}" + "function schedule(){if(!pending){pending=1;requestAnimationFrame(f)}}" "d.addEventListener('transitionend',function(e){" - "e.target.closest&&e.target.closest('details')&&setTimeout(f,60)},!0);" - "new MutationObserver(function(){setTimeout(f,80)})" - ".observe(d.body,{subtree:1,attributes:1,attributeFilter:['open']});" - "setInterval(f,5000)}()" + "e.target.closest&&e.target.closest('details')&&setTimeout(schedule,60)},!0);" + "new MutationObserver(function(){setTimeout(schedule,80)})" + ".observe(d.body,{subtree:1,attributes:1,attributeFilter:['open']})}()" ) # IME composition fix (macOS only) - prevents Enter from submitting during CJK input _js_ime_fix = ("" if os.name == 'nt' else diff --git a/tests/test_tgapp_inline_selection.py b/tests/test_tgapp_inline_selection.py deleted file mode 100644 index a800a3b7..00000000 --- a/tests/test_tgapp_inline_selection.py +++ /dev/null @@ -1,234 +0,0 @@ -import importlib -import sys -import types -import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[1] -FRONTENDS = REPO_ROOT / "frontends" -for path in (str(REPO_ROOT), str(FRONTENDS)): - if path not in sys.path: - sys.path.insert(0, path) - - -def _install_import_stubs(): - telegram = types.ModuleType("telegram") - - class InlineKeyboardButton: - def __init__(self, text, callback_data=None): - self.text = text - self.callback_data = callback_data - - class InlineKeyboardMarkup: - def __init__(self, rows): - self.inline_keyboard = rows - - telegram.BotCommand = object - telegram.InlineKeyboardButton = InlineKeyboardButton - telegram.InlineKeyboardMarkup = InlineKeyboardMarkup - - constants = types.ModuleType("telegram.constants") - constants.ChatType = types.SimpleNamespace(PRIVATE="private") - constants.MessageLimit = types.SimpleNamespace(MAX_TEXT_LENGTH=4096) - constants.ParseMode = types.SimpleNamespace(MARKDOWN_V2="MarkdownV2") - - error = types.ModuleType("telegram.error") - error.RetryAfter = type("RetryAfter", (Exception,), {}) - - ext = types.ModuleType("telegram.ext") - ext.ApplicationBuilder = object - ext.CallbackQueryHandler = object - ext.MessageHandler = object - ext.ContextTypes = types.SimpleNamespace(DEFAULT_TYPE=object) - ext.filters = types.SimpleNamespace( - COMMAND=object(), - PHOTO=object(), - TEXT=object(), - Document=types.SimpleNamespace(ALL=object()), - ) - - helpers = types.ModuleType("telegram.helpers") - helpers.escape_markdown = lambda text, version=2, entity_type=None: text or "" - - request = types.ModuleType("telegram.request") - request.HTTPXRequest = object - - class FakeAgent: - def __init__(self): - self.verbose = False - self.inc_out = False - self.llm_no = 0 - self.prompts = [] - - def list_llms(self): - return [ - (0, "gpt-4o", self.llm_no == 0), - (1, "claude-sonnet", self.llm_no == 1), - ] - - def next_llm(self, n): - if n not in (0, 1): - raise IndexError(n) - self.llm_no = n - - def get_llm_name(self): - return self.list_llms()[self.llm_no][1] - - def put_task(self, prompt, source=None): - self.prompts.append((prompt, source)) - return object() - - agentmain = types.ModuleType("agentmain") - agentmain.GeneraticAgent = FakeAgent - - chatapp_common = types.ModuleType("chatapp_common") - chatapp_common.FILE_HINT = "FILE_HINT" - chatapp_common.HELP_TEXT = "" - chatapp_common.TELEGRAM_MENU_COMMANDS = [] - chatapp_common.clean_reply = lambda text: text - chatapp_common.ensure_single_instance = lambda *args, **kwargs: None - chatapp_common.extract_files = lambda text: [] - chatapp_common.format_restore = lambda: (([], "", 0), None) - chatapp_common.redirect_log = lambda *args, **kwargs: None - chatapp_common.require_runtime = lambda *args, **kwargs: None - chatapp_common.split_text = lambda text, limit: [text] - - continue_cmd = types.ModuleType("continue_cmd") - continue_cmd.handle_frontend_command = lambda *args, **kwargs: "" - continue_cmd.reset_conversation = lambda *args, **kwargs: "" - - btw_cmd = types.ModuleType("btw_cmd") - btw_cmd.handle_frontend_command = lambda *args, **kwargs: "" - - llmcore = types.ModuleType("llmcore") - llmcore.mykeys = {} - - sys.modules.update( - { - "telegram": telegram, - "telegram.constants": constants, - "telegram.error": error, - "telegram.ext": ext, - "telegram.helpers": helpers, - "telegram.request": request, - "agentmain": agentmain, - "chatapp_common": chatapp_common, - "continue_cmd": continue_cmd, - "btw_cmd": btw_cmd, - "llmcore": llmcore, - } - ) - - -_install_import_stubs() -tgapp = importlib.import_module("tgapp") - - -class FakeMessage: - text = "/llm" - - def __init__(self): - self.replies = [] - - async def reply_text(self, text, reply_markup=None, **kwargs): - self.replies.append(types.SimpleNamespace(text=text, reply_markup=reply_markup)) - return self.replies[-1] - - -class FakeQuery: - def __init__(self, data): - self.data = data - self.message = FakeMessage() - self.answers = [] - self.edited_text = None - self.edited_markup = None - - async def answer(self, text=None, show_alert=False): - self.answers.append((text, show_alert)) - - async def edit_message_text(self, text, reply_markup=None): - self.edited_text = text - self.edited_markup = reply_markup - - async def edit_message_reply_markup(self, reply_markup=None): - self.edited_markup = reply_markup - - -class FakeUpdate: - effective_user = types.SimpleNamespace(id=1) - - def __init__(self, query=None, message=None): - self.callback_query = query - self.message = message - - -class TelegramInlineSelectionTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - tgapp._ask_menu_store.clear() - tgapp._llm_menu_store.clear() - tgapp.agent = tgapp.GeneraticAgent() - - async def fake_stream(*args, **kwargs): - return None - - self._original_stream = tgapp._stream - tgapp._stream = fake_stream - - async def asyncTearDown(self): - tgapp._stream = self._original_stream - - def test_multi_ask_markup_tracks_selected_items(self): - markup = tgapp._build_ask_user_markup( - "menu", - ["Python", "Go"], - multi=True, - selected_indexes=[0], - ) - - rows = markup.inline_keyboard - self.assertEqual(rows[0][0].text, "✓ Python") - self.assertEqual(rows[0][0].callback_data, "ask:menu:toggle:0") - self.assertEqual(rows[1][0].callback_data, "ask:menu:toggle:1") - self.assertEqual(rows[2][0].callback_data, "ask:menu:done") - - async def test_multi_ask_done_submits_joined_selection(self): - tgapp._ask_menu_store["menu"] = { - "question": "Pick [多选]", - "candidates": ["Python", "JavaScript", "Go"], - "multi": True, - "selected": [0, 2], - } - query = FakeQuery("ask:menu:done") - ctx = types.SimpleNamespace(user_data={}) - - await tgapp.handle_ask_callback(FakeUpdate(query=query), ctx) - - self.assertNotIn("menu", tgapp._ask_menu_store) - self.assertIn("Python; Go", query.edited_text) - self.assertEqual(tgapp.agent.prompts[-1][0], "FILE_HINT\n\nPython; Go") - self.assertEqual(tgapp.agent.prompts[-1][1], "telegram") - - async def test_llm_command_sends_inline_keyboard_and_callback_switches(self): - message = FakeMessage() - - await tgapp.cmd_llm(FakeUpdate(message=message), types.SimpleNamespace()) - - self.assertEqual(message.replies[0].text, tgapp._LLM_MENU_PROMPT) - menu_id = next(iter(tgapp._llm_menu_store)) - rows = message.replies[0].reply_markup.inline_keyboard - self.assertEqual(rows[1][0].callback_data, f"llm:{menu_id}:1") - - query = FakeQuery(f"llm:{menu_id}:1") - await tgapp.handle_llm_callback( - FakeUpdate(query=query), - types.SimpleNamespace(user_data={}), - ) - - self.assertEqual(tgapp.agent.llm_no, 1) - self.assertIn("claude-sonnet", query.edited_text) - self.assertNotIn(menu_id, tgapp._llm_menu_store) - - -if __name__ == "__main__": - unittest.main()